Skip to content

Commit 98064d1

Browse files
Exclude deleted Entities from form's manifest data (#2648)
* exclude deleted EntityList in endpoint /api/v1/projects * delete form metadata on deleting EntityList * ignore deleted Entity for EntityList dataset export * index deleted_at field for model EntityList, Entity * update dataset info when hard deleting Entity * delete form metadata when EntityList is hard deleted * add test * resolve cyclic dep * resolve cyclic dep * delete EntityList in an atomic transaction * fix failing test * remove unnecessary .all()
1 parent d7e3254 commit 98064d1

11 files changed

+240
-24
lines changed

onadata/apps/api/tests/viewsets/test_entity_list_viewset.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1119,12 +1119,15 @@ def test_delete(self, mock_now):
11191119
request = self.factory.delete("/", **self.extra)
11201120
response = self.view(request, pk=self.entity_list.pk, entity_pk=self.entity.pk)
11211121
self.entity.refresh_from_db()
1122+
self.entity_list.refresh_from_db()
1123+
11221124
self.assertEqual(response.status_code, 204)
11231125
self.assertEqual(self.entity.deleted_at, date)
11241126
self.assertEqual(self.entity.deleted_by, self.user)
1125-
self.entity_list.refresh_from_db()
11261127
self.assertEqual(self.entity_list.num_entities, 0)
1127-
self.assertEqual(self.entity_list.last_entity_update_time, date)
1128+
self.assertEqual(
1129+
self.entity_list.last_entity_update_time, self.entity.date_modified
1130+
)
11281131

11291132
def test_invalid_entity(self):
11301133
"""Invalid Entity is handled"""

onadata/apps/api/tests/viewsets/test_project_viewset.py

+15
Original file line numberDiff line numberDiff line change
@@ -2796,6 +2796,7 @@ def test_get_project_w_registration_form(self):
27962796
request = self.factory.get("/", **self.extra)
27972797
response = view(request, pk=self.project.pk)
27982798
entity_list = EntityList.objects.first()
2799+
27992800
self.assertEqual(response.status_code, 200)
28002801
self.assertEqual(
28012802
response.data["forms"][0]["contributes_entities_to"],
@@ -2805,6 +2806,13 @@ def test_get_project_w_registration_form(self):
28052806
"is_active": True,
28062807
},
28072808
)
2809+
# Soft delete dataset
2810+
entity_list.soft_delete()
2811+
request = self.factory.get("/", **self.extra)
2812+
response = view(request, pk=self.project.pk)
2813+
2814+
self.assertEqual(response.status_code, 200)
2815+
self.assertIsNone(response.data["forms"][0]["contributes_entities_to"])
28082816

28092817
def test_get_project_w_follow_up_form(self):
28102818
"""Retrieve project with Entity follow up form"""
@@ -2825,6 +2833,13 @@ def test_get_project_w_follow_up_form(self):
28252833
}
28262834
],
28272835
)
2836+
# Soft delete dataset
2837+
entity_list.soft_delete()
2838+
request = self.factory.get("/", **self.extra)
2839+
response = view(request, pk=self.project.pk)
2840+
2841+
self.assertEqual(response.status_code, 200)
2842+
self.assertEqual(response.data["forms"][0]["consumes_entities_from"], [])
28282843

28292844

28302845
class GetProjectInvitationListTestCase(TestAbstractViewSet):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 4.2.13 on 2024-07-23 07:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
atomic = False
8+
dependencies = [
9+
("logger", "0021_alter_attachment_options_alter_xform_options"),
10+
]
11+
12+
operations = [
13+
migrations.SeparateDatabaseAndState(
14+
database_operations=[
15+
migrations.RunSQL(
16+
sql=(
17+
'CREATE INDEX CONCURRENTLY "logger_enti_deleted_66eee5_idx" '
18+
'ON "logger_entity" ("deleted_at");'
19+
),
20+
reverse_sql='DROP INDEX CONCURRENTLY "logger_enti_deleted_66eee5_idx";',
21+
),
22+
migrations.RunSQL(
23+
sql=(
24+
'CREATE INDEX CONCURRENTLY "logger_enti_deleted_476ed6_idx" '
25+
'ON "logger_entitylist" ("deleted_at");'
26+
),
27+
reverse_sql='DROP INDEX CONCURRENTLY "logger_enti_deleted_476ed6_idx";',
28+
),
29+
],
30+
state_operations=[
31+
migrations.AddIndex(
32+
model_name="entity",
33+
index=models.Index(
34+
fields=["deleted_at"], name="logger_enti_deleted_66eee5_idx"
35+
),
36+
),
37+
migrations.AddIndex(
38+
model_name="entitylist",
39+
index=models.Index(
40+
fields=["deleted_at"], name="logger_enti_deleted_476ed6_idx"
41+
),
42+
),
43+
],
44+
)
45+
]

onadata/apps/logger/models/entity.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ def soft_delete(self, deleted_by=None):
3939
self.deleted_by = deleted_by
4040
self.save(update_fields=["deleted_at", "deleted_by"])
4141
self.entity_list.num_entities = models.F("num_entities") - 1
42-
self.entity_list.last_entity_update_time = deletion_time
4342
self.entity_list.save()
4443

4544
class Meta(BaseModel.Meta):
4645
app_label = "logger"
46+
indexes = [models.Index(fields=["deleted_at"])]
4747

4848

4949
class EntityHistory(BaseModel):

onadata/apps/logger/models/entity_list.py

+24-8
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55
from django.contrib.auth import get_user_model
66
from django.contrib.auth.models import Group, Permission
77
from django.contrib.contenttypes.fields import GenericRelation
8-
from django.db import models
8+
from django.db import models, transaction
99
from django.utils.translation import gettext_lazy as _
1010
from django.utils import timezone
1111

12+
1213
from guardian.models import UserObjectPermissionBase, GroupObjectPermissionBase
1314
from guardian.compat import user_model_label
1415

1516
from onadata.apps.logger.models.project import Project
17+
from onadata.apps.logger.models.xform import clear_project_cache
18+
from onadata.apps.main.models.meta_data import MetaData
1619
from onadata.libs.models import BaseModel
20+
from onadata.libs.utils.model_tools import queryset_iterator
1721

1822
User = get_user_model()
1923

@@ -39,13 +43,6 @@ class EntityList(BaseModel):
3943
deleted_at = models.DateTimeField(null=True, blank=True)
4044
deleted_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
4145

42-
class Meta(BaseModel.Meta):
43-
app_label = "logger"
44-
unique_together = (
45-
"name",
46-
"project",
47-
)
48-
4946
def __str__(self):
5047
return f"{self.name}|{self.project}"
5148

@@ -69,16 +66,35 @@ def properties(self) -> list[str]:
6966

7067
return list(dataset_properties)
7168

69+
@transaction.atomic()
7270
def soft_delete(self, deleted_by=None):
7371
"""Soft delete EntityList"""
7472
if self.deleted_at is None:
7573
deletion_time = timezone.now()
7674
deletion_suffix = deletion_time.strftime("-deleted-at-%s")
7775
self.deleted_at = deletion_time
7876
self.deleted_by = deleted_by
77+
original_name = self.name
7978
self.name += deletion_suffix
8079
self.name = self.name[:255] # Only first 255 characters
8180
self.save()
81+
clear_project_cache(self.project.pk)
82+
# Soft deleted follow up forms MetaData
83+
metadata_qs = MetaData.objects.filter(
84+
data_type="media",
85+
data_value=f"entity_list {self.pk} {original_name}",
86+
)
87+
88+
for datum in queryset_iterator(metadata_qs):
89+
datum.soft_delete()
90+
91+
class Meta(BaseModel.Meta):
92+
app_label = "logger"
93+
unique_together = (
94+
"name",
95+
"project",
96+
)
97+
indexes = [models.Index(fields=["deleted_at"])]
8298

8399

84100
class EntityListUserObjectPermission(UserObjectPermissionBase):

onadata/apps/logger/signals.py

+57-10
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
"""
55
from django.db import transaction
66
from django.db.models import F
7-
from django.db.models.signals import post_save
7+
from django.db.models.signals import post_save, post_delete
88
from django.dispatch import receiver
9+
from django.utils import timezone
910

1011
from onadata.apps.logger.models import Entity, EntityList, Instance, RegistrationForm
12+
from onadata.apps.logger.models.xform import clear_project_cache
1113
from onadata.apps.logger.xform_instance_parser import get_meta_from_xml
1214
from onadata.apps.logger.tasks import set_entity_list_perms_async
15+
from onadata.apps.main.models.meta_data import MetaData
1316
from onadata.libs.utils.logger_tools import (
1417
create_entity_from_instance,
1518
update_entity_from_instance,
@@ -47,23 +50,67 @@ def create_or_update_entity(sender, instance, created=False, **kwargs):
4750
create_entity_from_instance(instance, registration_form)
4851

4952

50-
@receiver(post_save, sender=Entity, dispatch_uid="update_entity_dataset")
51-
def update_entity_dataset(sender, instance, created=False, **kwargs):
52-
"""Update EntityList when Entity is created or updated"""
53-
if not instance:
54-
return
55-
53+
@receiver(post_save, sender=Entity, dispatch_uid="update_enti_el_inc_num_entities")
54+
def increment_entity_list_num_entities(sender, instance, created=False, **kwargs):
55+
"""Increment EntityList `num_entities`"""
5656
entity_list = instance.entity_list
5757

5858
if created:
59-
entity_list.num_entities = F("num_entities") + 1
59+
# Using Queryset.update ensures we do not call the model's save method and
60+
# signals
61+
EntityList.objects.filter(pk=entity_list.pk).update(
62+
num_entities=F("num_entities") + 1
63+
)
64+
65+
66+
@receiver(post_delete, sender=Entity, dispatch_uid="update_enti_el_dec_num_entities")
67+
def decrement_entity_list_num_entities(sender, instance, **kwargs):
68+
"""Decrement EntityList `num_entities`"""
69+
entity_list = instance.entity_list
70+
# Using Queryset.update ensures we do not call the model's save method and
71+
# signals
72+
EntityList.objects.filter(pk=entity_list.pk).update(
73+
num_entities=F("num_entities") - 1
74+
)
75+
6076

61-
entity_list.last_entity_update_time = instance.date_modified
62-
entity_list.save()
77+
@receiver(post_delete, sender=Entity, dispatch_uid="delete_enti_el_last_update_time")
78+
def update_last_entity_update_time_now(sender, instance, **kwargs):
79+
"""Update EntityList `last_entity_update_time`"""
80+
entity_list = instance.entity_list
81+
# Using Queryset.update ensures we do not call the model's save method and
82+
# signals
83+
EntityList.objects.filter(pk=entity_list.pk).update(
84+
last_entity_update_time=timezone.now()
85+
)
86+
87+
88+
@receiver(post_save, sender=Entity, dispatch_uid="update_enti_el_last_update_time")
89+
def update_last_entity_update_time(sender, instance, **kwargs):
90+
"""Update EntityList `last_entity_update_time`"""
91+
entity_list = instance.entity_list
92+
# Using Queryset.update ensures we do not call the model's save method and
93+
# signals
94+
EntityList.objects.filter(pk=entity_list.pk).update(
95+
last_entity_update_time=instance.date_modified
96+
)
6397

6498

6599
@receiver(post_save, sender=EntityList, dispatch_uid="set_entity_list_perms")
66100
def set_entity_list_perms(sender, instance, created=False, **kwargs):
67101
"""Set project permissions to EntityList"""
68102
if created:
69103
transaction.on_commit(lambda: set_entity_list_perms_async.delay(instance.pk))
104+
105+
106+
@receiver(post_delete, sender=EntityList, dispatch_uid="delete_entity_list_metadata")
107+
def delete_entity_list_metadata(sender, instance, **kwargs):
108+
"""Delete EntityList related data on delete"""
109+
clear_project_cache(instance.project.pk)
110+
# We get original name incase name has been modified in the case where
111+
# EntityList was first soft deleted
112+
entity_list_name = instance.name.split("-")[0]
113+
MetaData.objects.filter(
114+
data_type="media",
115+
data_value=f"entity_list {instance.pk} {entity_list_name}",
116+
).delete()

onadata/apps/logger/tests/models/test_entity.py

+15
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ def test_soft_delete(self, mock_now):
9393
self.assertEqual(entity3.deleted_at, self.mocked_now)
9494
self.assertIsNone(entity3.deleted_by)
9595

96+
def test_hard_delete(self):
97+
"""Hard deleting updates dataset info"""
98+
entity = Entity.objects.create(entity_list=self.entity_list)
99+
self.entity_list.refresh_from_db()
100+
old_last_entity_update_time = self.entity_list.last_entity_update_time
101+
102+
self.assertEqual(self.entity_list.num_entities, 1)
103+
104+
entity.delete()
105+
self.entity_list.refresh_from_db()
106+
new_last_entity_update_time = self.entity_list.last_entity_update_time
107+
108+
self.assertEqual(self.entity_list.num_entities, 0)
109+
self.assertTrue(old_last_entity_update_time < new_last_entity_update_time)
110+
96111

97112
class EntityHistoryTestCase(TestBase):
98113
"""Tests for model EntityHistory"""

onadata/apps/logger/tests/models/test_entity_list.py

+36
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,19 @@ def test_soft_delete(self):
132132
with patch("django.utils.timezone.now") as mock_now:
133133
mock_now.return_value = self.mocked_now
134134
entity_list = EntityList.objects.create(name="trees", project=self.project)
135+
follow_up_form = self._publish_follow_up_form(self.user)
135136
entity_list.soft_delete(self.user)
136137
entity_list.refresh_from_db()
138+
follow_up_form_meta_datum = follow_up_form.metadata_set.get(
139+
data_value=f"entity_list {entity_list.pk} trees"
140+
)
141+
137142
self.assertEqual(entity_list.deleted_at, self.mocked_now)
138143
self.assertEqual(entity_list.deleted_by, self.user)
139144
self.assertEqual(
140145
entity_list.name, f'trees{self.mocked_now.strftime("-deleted-at-%s")}'
141146
)
147+
self.assertIsNotNone(follow_up_form_meta_datum.deleted_at)
142148

143149
# Try soft deleting soft deleted dataset
144150
entity_list.soft_delete(self.user)
@@ -155,3 +161,33 @@ def test_soft_delete(self):
155161
entity_list.soft_delete()
156162
entity_list.refresh_from_db()
157163
self.assertEqual(entity_list.name, dataset_name)
164+
165+
def test_hard_delete(self):
166+
"""Hard delete removes consumers' metadata"""
167+
entity_list = EntityList.objects.create(name="trees", project=self.project)
168+
follow_up_form = self._publish_follow_up_form(self.user)
169+
data_value = f"entity_list {entity_list.pk} trees"
170+
self.assertTrue(
171+
follow_up_form.metadata_set.filter(data_value=data_value).exists()
172+
)
173+
174+
entity_list.delete()
175+
176+
self.assertFalse(
177+
follow_up_form.metadata_set.filter(data_value=data_value).exists()
178+
)
179+
# Hard deleted previously soft deleted dataset works
180+
follow_up_form.delete()
181+
entity_list = EntityList.objects.create(name="trees", project=self.project)
182+
follow_up_form = self._publish_follow_up_form(self.user)
183+
data_value = f"entity_list {entity_list.pk} trees"
184+
185+
self.assertTrue(
186+
follow_up_form.metadata_set.filter(data_value=data_value).exists()
187+
)
188+
entity_list.soft_delete()
189+
entity_list.delete()
190+
191+
self.assertFalse(
192+
follow_up_form.metadata_set.filter(data_value=data_value).exists()
193+
)

onadata/libs/serializers/project_serializer.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,9 @@ class BaseProjectXFormSerializer(serializers.HyperlinkedModelSerializer):
223223

224224
def get_contributes_entities_to(self, obj: XForm):
225225
"""Return the EntityList that the form contributes Entities to"""
226-
registration_form = obj.registration_forms.first()
226+
registration_form = obj.registration_forms.filter(
227+
entity_list__deleted_at__isnull=True
228+
).first()
227229

228230
if registration_form is None:
229231
return None
@@ -236,7 +238,7 @@ def get_contributes_entities_to(self, obj: XForm):
236238

237239
def get_consumes_entities_from(self, obj: XForm):
238240
"""Return the EntityLIst that the form consumes Entities"""
239-
queryset = obj.follow_up_forms.all()
241+
queryset = obj.follow_up_forms.filter(entity_list__deleted_at__isnull=True)
240242

241243
if not queryset:
242244
return []

0 commit comments

Comments
 (0)