Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixing Manually cascade delete M2M models when related model is deleted #2620

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from

Conversation

DraKen0009
Copy link
Contributor

@DraKen0009 DraKen0009 commented Nov 27, 2024

Proposed Changes

  • Manually cascade delete M2M models when related model is deleted

Associated Issue

Merge Checklist

  • Tests added/fixed
  • Linting Complete

Only PR's with test cases included and passing lint and test pipelines will be reviewed

@ohcnetwork/care-backend-maintainers @ohcnetwork/care-backend-admins

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced deletion logic across multiple models to prevent deletion if referenced elsewhere, ensuring data integrity.
    • New validation checks for deleting AssetLocation, Bed, Facility, PatientRegistration, PatientConsultation, and investigation-related records.
    • Improved user deletion logic with transaction handling and validation checks.
  • Bug Fixes

    • Improved error handling during deletion processes to raise appropriate validation errors.
  • Tests

    • Added comprehensive unit tests to validate the new deletion behaviors and ensure referential integrity across related models.

@DraKen0009 DraKen0009 requested a review from a team as a code owner November 27, 2024 21:53
Copy link

coderabbitai bot commented Nov 27, 2024

📝 Walkthrough
📝 Walkthrough

Walkthrough

This pull request introduces a series of modifications across various models in the Django application to enhance the deletion process. A new delete method is added to multiple classes, implementing validation checks to prevent deletion if the instance is referenced in related models. This change aims to enforce referential integrity by raising ValidationError when deletion is attempted under such conditions. Additionally, a new test suite is introduced to validate these changes and ensure proper functionality.

Changes

File Path Change Summary
care/facility/models/asset.py Added delete method in AssetLocation with validation for references in related models.
care/facility/models/bed.py Updated delete method in Bed to include validation for references in ConsultationBed.
care/facility/models/facility.py Updated delete method in Facility to prevent deletion if referenced in FacilityDefaultAssetLocation.
care/facility/models/patient.py Added delete method in PatientRegistration with validation for related PatientSample.
care/facility/models/patient_consultation.py Added delete method in PatientConsultation with validation for references in multiple models.
care/facility/models/patient_investigation.py Added delete methods in PatientInvestigationGroup, PatientInvestigation, and InvestigationSession with validation for InvestigationValue.
care/users/models.py Enhanced delete method in User with validation for references in FacilityUser and UserDefaultAssetLocation.
care/facility/tests/test_m2m_soft_delete_for_related_fields.py Added tests for deletion behavior across various models to validate new constraints.
care/utils/tests/test_utils.py Added utility methods for creating test instances related to facilities and investigations.

Assessment against linked issues

Objective Addressed Explanation
Ensure M2M models are updated to deleted=True when related models are deleted (#1330) The changes do not implement cascading soft deletes for all M2M models as expected.

Possibly related PRs

  • Remove event handlers #2593: This PR involves the removal of event creation logic, which complements the validation checks introduced here to enhance data integrity by managing references and preventing unwanted deletions.

Suggested reviewers

  • vigneshhari

"In the realm of models, where data does play,
Deletion's now guarded, come what may.
With checks in place, we ensure the right flow,
Preventing mishaps, as we all know!
So here's to integrity, standing so tall,
A soft delete dance, we won't let it fall!" 🎉


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between f6334b8 and 3892c2b.

📒 Files selected for processing (2)
  • care/facility/tests/test_m2m_soft_delete_for_related_fields.py (1 hunks)
  • care/utils/tests/test_utils.py (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • care/facility/tests/test_m2m_soft_delete_for_related_fields.py
  • care/utils/tests/test_utils.py

Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Outside diff range and nitpick comments (26)
care/facility/models/patient_investigation.py (3)

42-47: I couldn't help but notice some... interesting... style choices

While the validation logic is perfect (really, it is), there are a couple of things that caught my eye:

  • That lonely empty line at 45 seems a bit out of place
  • The return statement before super().delete() is unnecessary since delete methods typically return None

Here's a slightly cleaner version:

 def delete(self, *args):
     if InvestigationValue.objects.filter(investigation=self).exists():
         error = f"Cannot delete PatientInvestigation {self} because they are referenced as `investigation` in InvestigationValue records."
-
         raise ValidationError(error)
-    return super().delete(*args)
+    super().delete(*args)

58-62: Another return statement and a rather cryptic error message

The validation logic is solid, but:

  • Using external_id in the error message might not be very user-friendly
  • There's that unnecessary return statement again

Consider this more user-friendly version:

 def delete(self, *args):
     if InvestigationValue.objects.filter(session=self).exists():
-        error = f"Cannot delete InvestigationSession {self.external_id} because they are referenced as `session` in InvestigationValue records."
+        error = f"Cannot delete Investigation Session #{self.id} because it has associated investigation values."
         raise ValidationError(error)
-    return super().delete(*args)
+    super().delete(*args)

Line range hint 19-62: Perhaps we could DRY this up a bit?

I couldn't help but notice that all three models implement very similar delete validation logic. Consider creating a mixin or moving this to the BaseModel:

class DeleteProtectionMixin:
    def delete(self, *args):
        protected_relations = getattr(self, 'protected_relations', {})
        for relation_name, error_template in protected_relations.items():
            if getattr(self, relation_name).exists():
                raise ValidationError(error_template.format(self=self))
        super().delete(*args)

This would reduce duplication and standardize the validation across models.

care/facility/models/bed.py (3)

62-64: The error message could be more... helpful.

The current error message could be enhanced to provide more actionable information to the user.

-           error = f"Cannot delete Bed {self} because they are referenced as `bed` in ConsultationBed records."
+           error = f"Cannot delete Bed '{self}' as it is currently assigned to active consultations. Please end or reassign these consultations first."

62-65: Inconsistent handling of related models detected.

While AssetBed records are soft-deleted, ConsultationBed records prevent deletion entirely. This inconsistency might be... interesting to explain to users.

Consider one of these approaches:

  1. Soft-delete both AssetBed and ConsultationBed records:
    def delete(self, *args, **kwargs) -> None:
-       if ConsultationBed.objects.filter(bed=self).exists():
-           error = f"Cannot delete Bed {self} because they are referenced as `bed` in ConsultationBed records."
-           raise ValidationError(error)
        AssetBed.objects.filter(bed=self).update(deleted=True)
+       ConsultationBed.objects.filter(bed=self).update(deleted=True)
        super().delete(*args, **kwargs)
  1. Prevent deletion if either type of relationship exists:
    def delete(self, *args, **kwargs) -> None:
+       if AssetBed.objects.filter(bed=self).exists():
+           raise ValidationError(f"Cannot delete Bed '{self}' as it has associated assets.")
        if ConsultationBed.objects.filter(bed=self).exists():
            error = f"Cannot delete Bed {self} because they are referenced as `bed` in ConsultationBed records."
            raise ValidationError(error)
-       AssetBed.objects.filter(bed=self).update(deleted=True)
        super().delete(*args, **kwargs)

Line range hint 107-116: Missing deletion handling in ConsultationBedAsset.

I couldn't help but notice that ConsultationBedAsset might need similar deletion handling as its siblings.

Consider adding a delete method:

def delete(self, *args, **kwargs) -> None:
    with transaction.atomic():
        # Add any necessary validation or related model updates
        super().delete(*args, **kwargs)
care/facility/models/asset.py (1)

Line range hint 1-324: We might want to reconsider the use of PROTECT across M2M relationships

I notice that most M2M relationships in this file use on_delete=models.PROTECT. While this is generally a safe default, it creates friction with our goal of implementing cascade deletes. Perhaps we should:

  1. Document the rationale for using PROTECT
  2. Consider if CASCADE or SET_NULL might be more appropriate for some relationships
  3. Establish consistent guidelines for when to use each on_delete strategy

Would you like me to help draft architectural guidelines for managing model relationships in the context of soft deletes?

care/facility/models/facility.py (3)

313-315: Enhance the validation error message

The error message could be more helpful by including the count of references and suggesting what action to take.

-            error = f"Cannot delete Facility {self} because they are referenced as `facility` in FacilityDefaultAssetLocation records."
+            reference_count = FacilityDefaultAssetLocation.objects.filter(facility=self).count()
+            error = (
+                f"Cannot delete Facility '{self}' because it is referenced in {reference_count} "
+                f"FacilityDefaultAssetLocation record(s). Please remove these references first."
+            )

316-321: Consider using the default manager instead of _base_manager

Using _base_manager bypasses the default manager and its filters. Unless there's a specific reason to use it (which should be documented), consider using the default manager.

         Asset.objects.filter(
-            current_location_id__in=AssetLocation._base_manager.filter(  # noqa: SLF001
+            current_location_id__in=AssetLocation.objects.filter(
                 facility_id=self.id
             ).values_list("id", flat=True)
         ).update(deleted=True)

322-325: Add error handling and document the deletion order

The code would benefit from explicit error handling and documentation about the order of operations.

+        # Order of operations:
+        # 1. Delete facility users (hard delete as it's a through table)
+        # 2. Clear testing facility references in patient samples (soft delete not needed)
+        try:
             FacilityUser.objects.filter(facility=self).delete()
             PatientSample.objects.filter(testing_facility=self).update(
                 testing_facility=None
             )
+        except Exception as e:
+            raise ValidationError(f"Error while deleting facility relationships: {str(e)}")
         return super().delete(*args)
care/facility/models/patient.py (2)

478-484: Add a docstring to explain the override behavior

The delete method would benefit from a docstring explaining its purpose and the validation it performs.

 def delete(self, *args):
+    """
+    Override delete to prevent deletion of patients referenced in PatientSample records.
+    
+    Raises:
+        ValidationError: If the patient is referenced in any PatientSample records.
+    """
     from care.facility.models.patient_sample import PatientSample

481-483: Improve the error message with specific record details

The error message could be more helpful by including the count and IDs of the blocking records.

-        if PatientSample.objects.filter(patient=self).exists():
-            error = f"Cannot delete PatientRegistration {self} because they are referenced as `patient` in PatientSample records."
+        blocking_samples = PatientSample.objects.filter(patient=self)
+        if blocking_samples.exists():
+            sample_ids = list(blocking_samples.values_list('id', flat=True))
+            error = (
+                f"Cannot delete PatientRegistration {self} because they are referenced "
+                f"in {len(sample_ids)} PatientSample records (IDs: {', '.join(map(str, sample_ids))}). "
+                "Please delete these samples first."
+            )
care/utils/tests/test_utils.py (3)

744-744: Consider using a more descriptive default name.

Using now() directly as the name might make test outputs less readable. Perhaps something like f"Investigation Group {now()}" would be more helpful for debugging?

-        data = {"name": now()}
+        data = {"name": f"Investigation Group {now()}"}

754-760: Add parameter validation for asset location creation methods.

While the implementation is clean, it might be good to add some basic validation to ensure the location belongs to the correct facility for FacilityDefaultAssetLocation and has appropriate permissions for UserDefaultAssetLocation.

Here's a suggested implementation for create_facility_default_asset_location:

     @classmethod
     def create_facility_default_asset_location(
         cls, facility: Facility, location: AssetLocation
     ) -> FacilityDefaultAssetLocation:
+        if location.facility_id != facility.id:
+            raise ValueError("Location must belong to the specified facility")
         data = {"facility": facility, "location": location}
         return FacilityDefaultAssetLocation.objects.create(**data)

Also applies to: 761-767


730-741: LGTM, but could be more concise.

The implementation is correct, though it could be slightly more concise by directly passing the kwargs to create.

     @classmethod
     def create_facility_user(
         cls, facility: Facility, user: User, created_by: User, **kwargs
     ) -> FacilityUser:
-        data = {
-            "facility": facility,
-            "created_by": created_by,
-            "user": user,
-        }
-        data.update(**kwargs)
-        return FacilityUser.objects.create(**data)
+        return FacilityUser.objects.create(
+            facility=facility,
+            created_by=created_by,
+            user=user,
+            **kwargs
+        )
care/users/models.py (3)

411-413: Typically, imports should be at the top of the file

While importing inside methods is possible, placing the imports at the top of the file is generally better practice for readability and maintainability. Unless there's a compelling reason, such as avoiding circular imports, moving these imports might be a good idea.


415-421: Refactor duplicate validation checks into a helper method

Noticing that the validation checks are quite similar, perhaps extracting them into a helper function might reduce duplication and improve code clarity.


416-416: Ensure consistency in error messages

It might be helpful to use {self.username} consistently in the error messages to avoid any confusion. In one place, you're using {self.username}, and in another, {self}. Keeping it uniform enhances readability.

Also applies to: 420-420

care/facility/tests/test_m2m_soft_delete_for_related_fields.py (8)

72-78: You might find assertRaisesMessage more straightforward here

Using assertRaisesMessage simplifies exception assertions by combining the exception type and message check into a single context manager.

Apply this diff to refactor your test:

 def test_facility_user_delete_when_related_created_by_is_deleted_for_existing_facility_user(self):
     # case 1 when facility user exists for created_by user
     self.assertTrue(FacilityUser.objects.filter(created_by=self.user2).exists())

-    with self.assertRaises(ValidationError) as context:
-        self.user2.delete()
-
-    self.assertIn(
-        f"Cannot delete User {self.user2} because they are referenced as `created_by` in FacilityUser records.",
-        context.exception,
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete User {self.user2} because they are referenced as `created_by` in FacilityUser records."
+    ):
+        self.user2.delete()

128-134: Consider simplifying exception checks with assertRaisesMessage

Refactoring to assertRaisesMessage can make your test assertions more concise and readable.

Here's the suggested change:

 def test_delete_patient_investigation_with_investigation_value(self):
     # Create an InvestigationValue referencing the PatientInvestigation
     InvestigationValue.objects.create(
         investigation=self.investigation,
         group=self.investigation_group,
         consultation=self.consultation,
         session=self.investigation_session,
     )

     # Attempt to delete the investigation
-    with self.assertRaises(ValidationError) as context:
-        self.investigation.delete()
-
-    self.assertIn(
-        f"Cannot delete PatientInvestigation {self.investigation!s} because they are referenced as `investigation` in InvestigationValue records.",
-        context.exception,
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete PatientInvestigation {self.investigation!s} because they are referenced as `investigation` in InvestigationValue records."
+    ):
+        self.investigation.delete()

165-172: Streamline your test with assertRaisesMessage

Using assertRaisesMessage can make exception assertion more direct and less verbose.

Consider this change:

 def test_delete_investigation_session_with_investigation_value(self):
     # Create an InvestigationValue referencing the InvestigationSession
     InvestigationValue.objects.create(
         investigation=self.investigation,
         group=self.investigation_group,
         consultation=self.consultation,
         session=self.investigation_session,
     )

     # Attempt to delete the session
-    with self.assertRaises(ValidationError) as context:
-        self.investigation_session.delete()
-
-    self.assertIn(
-        f"Cannot delete InvestigationSession {self.investigation_session.external_id} because they are referenced as `session` in InvestigationValue records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete InvestigationSession {self.investigation_session.external_id} because they are referenced as `session` in InvestigationValue records."
+    ):
+        self.investigation_session.delete()

303-309: Using assertRaisesMessage might be beneficial here

This change can make your exception assertions cleaner and more maintainable.

Here's the suggested modification:

 def test_delete_patient_registration_with_related_sample(self):
     # Ensure the sample is linked to the patient
     self.assertTrue(PatientSample.objects.filter(patient=self.patient).exists())

     # Attempt to delete the patient
-    with self.assertRaises(ValidationError) as context:
-        self.patient.delete()
-
-    self.assertIn(
-        f"Cannot delete PatientRegistration {self.patient} because they are referenced as `patient` in PatientSample records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete PatientRegistration {self.patient} because they are referenced as `patient` in PatientSample records."
+    ):
+        self.patient.delete()

338-344: Simplify exception assertion with assertRaisesMessage

This adjustment can enhance the clarity of your test.

Implement this change:

 def test_delete_patient_consultation_with_related_sample(self):
     # Ensure the sample is linked to the consultation
     self.assertTrue(
         PatientSample.objects.filter(consultation=self.consultation).exists()
     )

     # Attempt to delete the consultation
-    with self.assertRaises(ValidationError) as context:
-        self.consultation.delete()
-
-    self.assertIn(
-        f"Cannot delete PatientConsultation {self.consultation} because they are referenced as `consultation` in PatientSample records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete PatientConsultation {self.consultation} because they are referenced as `consultation` in PatientSample records."
+    ):
+        self.consultation.delete()

468-475: Switch to assertRaisesMessage for concise exception testing

This refactoring can make your test assertions more straightforward.

Here's the recommended change:

 def test_delete_user_with_related_user_default_asset_location(self):
     # Ensure the user is linked to UserDefaultAssetLocation
     self.assertTrue(
         UserDefaultAssetLocation.objects.filter(user=self.user).exists()
     )

     # Attempt to delete the user
-    with self.assertRaises(ValidationError) as context:
-        self.user.delete()
-
-    # Assert that the exception is raised
-    self.assertIn(
-        f"Cannot delete User {self.user} because they are referenced as `user` in UserDefaultAssetLocation records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete User {self.user} because they are referenced as `user` in UserDefaultAssetLocation records."
+    ):
+        self.user.delete()

627-633: Refactor exception handling using assertRaisesMessage

This will make your test code cleaner and more precise.

Consider applying this change:

 def test_delete_location_with_related_facility_default_asset_location(self):
     # Ensure the location is linked to FacilityDefaultAssetLocation
     self.assertTrue(
         FacilityDefaultAssetLocation.objects.filter(location=self.location).exists()
     )

     # Attempt to delete the location
-    with self.assertRaises(ValidationError) as context:
-        self.location.delete()
-
-    # Assert that the correct exception is raised
-    self.assertIn(
-        f"Cannot delete AssetLocation {self.location} because they are referenced as `location` in FacilityDefaultAssetLocation records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete AssetLocation {self.location} because they are referenced as `location` in FacilityDefaultAssetLocation records."
+    ):
+        self.location.delete()

741-748: Using assertRaisesMessage could enhance your test

This approach simplifies exception assertions and improves readability.

Here's how you can refactor:

 def test_delete_consultation_with_related_consultation_bed(self):
     # Ensure the consultation is linked to ConsultationBed
     self.assertTrue(
         ConsultationBed.objects.filter(consultation=self.consultation).exists()
     )

     # Attempt to delete the consultation
-    with self.assertRaises(ValidationError) as context:
-        self.consultation.delete()
-
-    # Assert that the correct exception is raised
-    self.assertIn(
-        f"Cannot delete PatientConsultation {self.consultation} because they are referenced as `consultation` in ConsultationBed records.",
-        str(context.exception),
-    )
+    with self.assertRaisesMessage(
+        ValidationError,
+        f"Cannot delete PatientConsultation {self.consultation} because they are referenced as `consultation` in ConsultationBed records."
+    ):
+        self.consultation.delete()
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between f217f3a and f6334b8.

📒 Files selected for processing (9)
  • care/facility/models/asset.py (2 hunks)
  • care/facility/models/bed.py (1 hunks)
  • care/facility/models/facility.py (2 hunks)
  • care/facility/models/patient.py (2 hunks)
  • care/facility/models/patient_consultation.py (2 hunks)
  • care/facility/models/patient_investigation.py (4 hunks)
  • care/facility/tests/test_m2m_soft_delete_for_related_fields.py (1 hunks)
  • care/users/models.py (2 hunks)
  • care/utils/tests/test_utils.py (2 hunks)
🔇 Additional comments (8)
care/facility/models/patient_investigation.py (2)

3-3: Finally, a proper exception import!

The ValidationError import is correctly placed and necessary for the new delete methods.


19-23: Oh, I see we're living dangerously with potential race conditions

While the validation logic is correct, there's a small window between checking for references and actual deletion where new references could be created. Consider using transactions or database constraints for truly bulletproof protection.

Let's check if there are any existing database constraints:

care/facility/models/bed.py (1)

62-65: Verify the impact on active consultations.

Let's ensure we're not affecting any active consultations with this change.

✅ Verification successful

The validation looks perfectly fine, actually

The current validation is already handling this quite elegantly. It prevents bed deletion when there are any ConsultationBed records referencing it, regardless of their status. This is exactly what we want because:

  • Active consultations (end_date__isnull=True) are protected
  • Historical consultation records (end_date__isnull=False) are also preserved
  • The validation message is clear and informative

The code is already doing what it should to maintain data integrity. I suppose we could make it more complex, but why fix what isn't broken? 🙂

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check for active consultations that might be affected

# Search for ConsultationBed usage patterns
ast-grep --pattern 'class ConsultationBed {
  $$$
  consultation = models.ForeignKey($$$)
  $$$
}'

# Look for any consultation-related queries or filters
rg -l "ConsultationBed.*filter|filter.*ConsultationBed" 

Length of output: 823


Script:

#!/bin/bash
# Let's check the actual ConsultationBed model definition and its relationships
rg -A 10 "class ConsultationBed" care/facility/models/patient_consultation.py

# Check how ConsultationBed is being used in filters
rg "ConsultationBed.*filter|filter.*ConsultationBed" care/facility/api/viewsets/patient_consultation.py care/facility/api/viewsets/patient.py

# Check if there are any status or active consultation checks
rg "consultation.*status|status.*consultation" care/facility/models/patient_consultation.py

# Look for any consultation state management
ast-grep --pattern 'class ConsultationBed {
  $$$
  status = $$$
  $$$
}'

Length of output: 683


Script:

#!/bin/bash
# Let's see what these ConsultationBed filters are actually doing
rg -B 2 -A 4 "ConsultationBed.*filter|filter.*ConsultationBed" care/facility/api/viewsets/patient.py care/facility/api/viewsets/patient_consultation.py

# And check the actual validation in the bed model
rg -B 2 -A 2 "Cannot delete Bed" care/facility/models/bed.py

# Look for any end_date related logic
rg "end_date" care/facility/models/patient_consultation.py

Length of output: 2510

care/facility/models/asset.py (2)

5-5: LGTM! Though you could have grouped it with the other Django imports...

The addition of ValidationError import is necessary for the new delete method implementation.


58-65: ⚠️ Potential issue

This implementation seems to be doing the opposite of what was intended... 🤔

The PR objectives clearly state that we need to implement cascade delete for M2M models, but this implementation is preventing deletion instead. There are several concerns:

  1. The current implementation raises ValidationError, preventing deletion entirely
  2. It doesn't implement the soft delete cascade mentioned in the PR objectives
  3. The related models use on_delete=models.PROTECT which conflicts with the cascade delete goal

Consider this alternative implementation that aligns with the PR objectives:

 def delete(self, *args):
-    if UserDefaultAssetLocation.objects.filter(location=self).exists():
-        error = f"Cannot delete AssetLocation {self} because they are referenced as `location` in UserDefaultAssetLocation records."
-        raise ValidationError(error)
-    if FacilityDefaultAssetLocation.objects.filter(location=self).exists():
-        error = f"Cannot delete AssetLocation {self} because they are referenced as `location` in FacilityDefaultAssetLocation records."
-        raise ValidationError(error)
+    # Implement soft delete cascade for M2M models
+    UserDefaultAssetLocation.objects.filter(location=self).update(deleted=True)
+    FacilityDefaultAssetLocation.objects.filter(location=self).update(deleted=True)
     return super().delete(*args)

Let's verify if there are any other M2M models referencing AssetLocation that we need to handle:

care/facility/models/facility.py (1)

305-325: Verify all M2M relationships are handled

The implementation looks good, but let's verify we haven't missed any M2M relationships that might need similar handling.

✅ Verification successful

All M2M relationships are properly handled in the delete method

Looking at the codebase analysis, the current implementation already handles all relevant M2M relationships that could impact facility deletion:

  • users M2M through FacilityUser: ✓ Handled with FacilityUser.objects.filter(facility=self).delete()
  • assets M2M through AssetLocation: ✓ Handled with soft delete
  • PatientSample relationship: ✓ Handled by setting to None

The other M2M relationships found in the codebase are either:

  • Not directly related to Facility (e.g., User-Skill, ConsultationBed-Asset)
  • Already protected by the FacilityDefaultAssetLocation check
  • Have ON DELETE behavior defined in their models that safely handles cascading

Though I must say, it's quite thorough how you've implemented the deletion checks... almost too thorough 😏

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for ManyToManyField definitions in models
echo "Searching for ManyToManyField relationships..."
rg "ManyToManyField\(" -A 5

# Search for through relationships
echo "Searching for through relationships..."
rg "through\s*=" -A 5

# Search for related_name in model relationships
echo "Searching for related relationships..."
rg "related_name\s*=" -A 5

Length of output: 134331

care/users/models.py (1)

409-427: Deletion logic updates appear sound

The updated delete method appropriately handles related records and ensures data integrity. Wrapping the method with @transaction.atomic effectively makes the operations atomic, which is good practice.

care/facility/tests/test_m2m_soft_delete_for_related_fields.py (1)

1-822: Tests are comprehensive and align with PR objectives

Your test cases thoroughly cover the scenarios outlined in the PR objectives, ensuring that deletion behaviors are correctly validated across models.

care/facility/models/bed.py Show resolved Hide resolved
care/facility/models/patient_consultation.py Show resolved Hide resolved
care/facility/models/facility.py Show resolved Hide resolved
care/facility/models/patient.py Show resolved Hide resolved
care/utils/tests/test_utils.py Outdated Show resolved Hide resolved
if ConsultationBed.objects.filter(bed=self).exists():
error = f"Cannot delete Bed {self} because they are referenced as `bed` in ConsultationBed records."
raise ValidationError(error)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we actually delete this ? ie call the super delete method ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, didnt see the old bits !

@@ -54,6 +55,15 @@ class RoomType(enum.Enum):
null=True, blank=True, default=None, max_length=200
)

def delete(self, *args):
if UserDefaultAssetLocation.objects.filter(location=self).exists():
error = f"Cannot delete AssetLocation {self} because they are referenced as `location` in UserDefaultAssetLocation records."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make more sense to create a static errors and make it translatable as well, also keep the errors very human readable like

AssetLocation.objects.filter(facility_id=self.id).update(deleted=True)
Asset.objects.filter(
current_location_id__in=AssetLocation._base_manager.filter( # noqa: SLF001
facility_id=self.id
).values_list("id", flat=True)
).update(deleted=True)
FacilityUser.objects.filter(facility=self).delete()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shifting might also be affected


FacilityUser.objects.filter(user=self).delete()
PatientSample.objects.filter(created_by=self).update(created_by=None)
PatientSample.objects.filter(last_edited_by=self).update(last_edited_by=None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WE can keep these, the whole reason we dont delete users is so that we can show these when the audit comes up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PatientSample.objects.filter(created_by=self).update(created_by=None)
PatientSample.objects.filter(last_edited_by=self).update(last_edited_by=None)

You mean that we don't need to set user null here?
@vigneshhari

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Manually cascade delete M2M models when related model is deleted.
2 participants