Skip to content

Commit b39452a

Browse files
committed
feature: create a feature to allow easy anonymization of donors
- create a feature to allow the anonymization of all the "old" donations (>1/2 years old) - allow anonymization of a donation in different contexts in the admin
1 parent c49feaf commit b39452a

14 files changed

Lines changed: 770 additions & 250 deletions

File tree

.idea/runConfigurations/compilemessages.xml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/runConfigurations/generate_donations_PDF_only.xml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/runConfigurations/generate_donations.xml renamed to .idea/runConfigurations/generate_donations_noPDF.xml

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/runConfigurations/makemessages.xml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/donations/admin/donors.py

Lines changed: 215 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import logging
2+
from datetime import timedelta
23

4+
from django import forms
5+
from django.conf import settings
36
from django.contrib import admin, messages
4-
from django.core.management import call_command
7+
from django.contrib.messages import DEFAULT_LEVELS
8+
from django.core.management import CommandError, call_command
59
from django.core.validators import EMPTY_VALUES
610
from django.db.models import QuerySet
711
from django.http import HttpRequest
8-
from django.shortcuts import redirect
9-
from django.urls import reverse
12+
from django.shortcuts import redirect, render
13+
from django.urls import reverse, reverse_lazy
14+
from django.utils import timezone
1015
from django.utils.translation import gettext_lazy as _
1116
from django.utils.translation import ngettext_lazy
1217
from unfold.admin import ModelAdmin
@@ -15,13 +20,78 @@
1520

1621
from donations.models.donors import Donor
1722
from redirectioneaza.common.app_url import build_uri
23+
from redirectioneaza.common.async_wrapper import async_wrapper
1824
from redirectioneaza.common.messaging import extend_email_context, send_email
1925
from utils.common.admin import HasNgoFilter
26+
from utils.constants.time import YEAR
2027

2128
logger = logging.getLogger(__name__)
2229

23-
REMOVE_DONATIONS_SUCCESS_FLAG = "success"
24-
REMOVE_DONATIONS_FAILURE_FLAG = "failure"
30+
TASK_SUCCESS_FLAG = "SUCCESS"
31+
TASK_FAILURE_FLAG = "ERROR"
32+
TASK_SCHEDULED_FLAG = "SCHEDULED"
33+
34+
35+
class AnonymizeDonorForm(forms.Form):
36+
confirmation = forms.MultipleChoiceField(
37+
label=_("Please confirm"),
38+
help_text=_(
39+
"This action will PERMANENTLY anonymize the selected donor's personal data, "
40+
"removing any personally identifiable information and the form itself. "
41+
"THIS ACTION CANNOT BE UNDONE."
42+
),
43+
choices=[("yes", _("Yes, I want to anonymize this donor"))],
44+
widget=forms.CheckboxSelectMultiple,
45+
required=True,
46+
)
47+
48+
49+
def _print_task_result(process: str, task_results: dict[str, int], queryset_size: int) -> str:
50+
message_parts: list[str] = [
51+
ngettext_lazy(
52+
singular="Out of %(queryset_size)d donor, the %(process)s results were:",
53+
plural="Out of %(queryset_size)d donors, the %(process)s results were:",
54+
number=queryset_size,
55+
)
56+
% {
57+
"queryset_size": queryset_size,
58+
"process": process.upper(),
59+
}
60+
]
61+
62+
if task_results.get(TASK_SUCCESS_FLAG):
63+
message_parts.append(
64+
ngettext_lazy(
65+
singular="%(success)d was processed",
66+
plural="%(success)d were processed",
67+
number=task_results[TASK_SUCCESS_FLAG],
68+
)
69+
% {"success": task_results[TASK_SUCCESS_FLAG]}
70+
)
71+
72+
if task_results.get(TASK_FAILURE_FLAG):
73+
message_parts.append(
74+
ngettext_lazy(
75+
singular="%(failure)d was not processed",
76+
plural="%(failure)d were not processed",
77+
number=task_results[TASK_FAILURE_FLAG],
78+
)
79+
% {"failure": task_results[TASK_FAILURE_FLAG]}
80+
)
81+
82+
if task_results.get(TASK_SCHEDULED_FLAG):
83+
message_parts.append(
84+
ngettext_lazy(
85+
singular="%(scheduled)d was scheduled for processing",
86+
plural="%(scheduled)d were scheduled for processing",
87+
number=task_results[TASK_SCHEDULED_FLAG],
88+
)
89+
% {"scheduled": task_results[TASK_SCHEDULED_FLAG]}
90+
)
91+
92+
message_parts[-1] += "."
93+
94+
return ", ".join(message_parts)
2595

2696

2797
def soft_delete_donor(donor_pk: int):
@@ -43,10 +113,26 @@ def soft_delete_donor(donor_pk: int):
43113
html_template="emails/donor/removed-redirection/main.html",
44114
context=mail_context,
45115
)
46-
return REMOVE_DONATIONS_SUCCESS_FLAG
116+
return TASK_SUCCESS_FLAG
47117
except Exception as e:
48118
logger.error(f"Failed to delete donor {donor_pk}: {e}")
49-
return REMOVE_DONATIONS_FAILURE_FLAG
119+
return TASK_FAILURE_FLAG
120+
121+
122+
def anonymize_donor(donor: Donor):
123+
logger.info(f"Anonymizing donor {donor.pk}")
124+
125+
try:
126+
if settings.USER_ANONIMIZATION_METHOD == "async":
127+
async_wrapper(donor.anonymize)
128+
return TASK_SCHEDULED_FLAG
129+
130+
donor.anonymize()
131+
except Exception as e:
132+
logger.error(f"Failed to anonymize donor {donor.pk}: {e}")
133+
return TASK_FAILURE_FLAG
134+
135+
return TASK_SUCCESS_FLAG
50136

51137

52138
class CommonNumericFilter(TextFilter):
@@ -140,8 +226,13 @@ class DonorAdmin(ModelAdmin):
140226
),
141227
)
142228

143-
actions = ("remove_donations",)
144-
actions_list = ("run_redirections_stats_generator", "run_redirections_stats_generator_force")
229+
actions = ("remove_donations", "anonymize_donations")
230+
actions_list = (
231+
"run_redirections_stats_generator",
232+
"run_redirections_stats_generator_force",
233+
"anonymize_old_donations",
234+
)
235+
actions_detail = ("anonymize_donation",)
145236

146237
def has_change_permission(self, request, obj=None):
147238
return False
@@ -155,37 +246,131 @@ def get_form_url(self, obj: Donor):
155246

156247
@action(description=_("Remove donations and notify donors"), url_path="remove-donations")
157248
def remove_donations(self, request, queryset: QuerySet[Donor]):
158-
task_results = {REMOVE_DONATIONS_SUCCESS_FLAG: 0, REMOVE_DONATIONS_FAILURE_FLAG: 0}
249+
task_results = {TASK_SUCCESS_FLAG: 0, TASK_FAILURE_FLAG: 0}
159250

160251
queryset_size = queryset.count()
161252
for donor in queryset:
162253
task_result = soft_delete_donor(donor.pk)
163254
task_results[task_result] += 1
164255

165-
part_1 = ngettext_lazy(
166-
singular="Out of %(queryset_size)d donor",
167-
plural="Out of %(queryset_size)d donors",
168-
number=queryset_size,
169-
) % {"queryset_size": queryset_size}
256+
message: str = _print_task_result(
257+
process="removal",
258+
task_results=task_results,
259+
queryset_size=queryset_size,
260+
)
261+
262+
self.message_user(request, message)
170263

171-
part_2 = ngettext_lazy(
172-
singular="%(success)d was deleted",
173-
plural="%(success)d were deleted",
174-
number=task_results[REMOVE_DONATIONS_SUCCESS_FLAG],
175-
) % {"success": task_results[REMOVE_DONATIONS_SUCCESS_FLAG]}
264+
@action(description=_("Anonymize public data of donations"), url_path="anonymize-donations")
265+
def anonymize_donations(self, request, queryset: QuerySet[Donor]):
266+
task_results = {TASK_SUCCESS_FLAG: 0, TASK_FAILURE_FLAG: 0, TASK_SCHEDULED_FLAG: 0}
176267

177-
part_3 = ngettext_lazy(
178-
singular="%(failure)d was not deleted",
179-
plural="%(failure)d were not deleted",
180-
number=task_results[REMOVE_DONATIONS_FAILURE_FLAG],
181-
) % {"failure": task_results[REMOVE_DONATIONS_FAILURE_FLAG]}
268+
queryset_size = queryset.count()
269+
for donor in queryset:
270+
task_result = anonymize_donor(donor)
271+
task_results[task_result] += 1
182272

183-
self.message_user(request, ", ".join([part_1, part_2, part_3 + "."]))
273+
message: str = _print_task_result(
274+
process="anonymization",
275+
task_results=task_results,
276+
queryset_size=queryset_size,
277+
)
278+
279+
self.message_user(request, message)
280+
281+
def _anonymize_donation(self, donor: Donor) -> dict[str, str]:
282+
task_result = anonymize_donor(donor)
283+
284+
if task_result == TASK_SUCCESS_FLAG:
285+
message = _("The donor was successfully anonymized.")
286+
elif task_result == TASK_SCHEDULED_FLAG:
287+
message = _("The donor anonymization was scheduled for processing.")
288+
else:
289+
message = _("Failed to anonymize the donor. Check the logs for more details.")
290+
291+
return {"status": task_result, "message": message}
292+
293+
@action(description=_("Anonymize public data of the donation"), url_path="anonymize-donation")
294+
def anonymize_donation(self, request: HttpRequest, object_id: int):
295+
donor = Donor.objects.get(pk=object_id)
296+
297+
if request.method == "POST":
298+
form = AnonymizeDonorForm(request.POST)
299+
if form.is_valid():
300+
result: dict = self._anonymize_donation(donor)
301+
302+
self.message_user(request, result["message"], level=DEFAULT_LEVELS.get(result["status"], messages.INFO))
303+
304+
return redirect(reverse_lazy("admin:donations_donor_change", args=[donor.pk]))
305+
else:
306+
messages.error(request, _("Please confirm the anonymization by checking the box."))
307+
else:
308+
form = AnonymizeDonorForm()
309+
310+
return render(
311+
request,
312+
"admin/forms/action.html",
313+
context={
314+
"form": form,
315+
"object": donor,
316+
"title": _("Anonymize donor"),
317+
**self.admin_site.each_context(request),
318+
},
319+
)
320+
321+
return redirect(reverse_lazy("admin:donations_donor_change", args=[donor.pk]))
184322

185323
@action(description=_("Schedule redirections stats"), url_path="schedule-redirections-stats-generator")
186-
def run_redirections_stats_generator(self, request, queryset: QuerySet[Donor]):
187-
call_command("generate_redirections_stats")
324+
def run_redirections_stats_generator(self, request):
325+
try:
326+
call_command("generate_redirections_stats")
327+
except CommandError:
328+
self.message_user(request, _("Error calling the command"))
329+
330+
self.message_user(request, _("Succesfully scheduled"))
188331

189332
@action(description=_("Schedule redirections stats [FORCE]"), url_path="schedule-redirections-stats-generator-f")
190-
def run_redirections_stats_generator_force(self, request, queryset: QuerySet[Donor]):
191-
call_command("generate_redirections_stats", "--force")
333+
def run_redirections_stats_generator_force(self, request):
334+
try:
335+
call_command("generate_redirections_stats", "--force")
336+
except CommandError:
337+
self.message_user(request, _("Error calling the command"))
338+
339+
self.message_user(request, _("Succesfully scheduled"))
340+
341+
def _anonymize_old_donations(self):
342+
try:
343+
now = timezone.now()
344+
one_year_ago_date = now - timedelta(days=YEAR)
345+
two_years_ago_date = now - timedelta(days=2 * YEAR)
346+
347+
base_donor_qs = Donor.objects.exclude(
348+
l_name="",
349+
f_name="",
350+
)
351+
352+
one_year_donations_qs: QuerySet[Donor] = base_donor_qs.filter(
353+
two_years=False,
354+
date_created__lte=one_year_ago_date,
355+
)
356+
two_year_donations_qs: QuerySet[Donor] = base_donor_qs.filter(
357+
date_created__lte=two_years_ago_date,
358+
)
359+
360+
for donor in two_year_donations_qs:
361+
anonymize_donor(donor)
362+
for donor in one_year_donations_qs:
363+
anonymize_donor(donor)
364+
365+
except CommandError:
366+
logger.error("Error calling the anonymize_old_donations command")
367+
raise
368+
369+
@action(description=_("Anonymize old donations"), url_path="anonymize-old-donations")
370+
def anonymize_old_donations(self, request):
371+
try:
372+
self._anonymize_old_donations()
373+
except CommandError:
374+
self.message_user(request, _("Error calling the command"))
375+
376+
self.message_user(request, _("Succesfully scheduled"))

0 commit comments

Comments
 (0)