11import logging
2+ from datetime import timedelta
23
4+ from django import forms
5+ from django .conf import settings
36from 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
59from django .core .validators import EMPTY_VALUES
610from django .db .models import QuerySet
711from 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
1015from django .utils .translation import gettext_lazy as _
1116from django .utils .translation import ngettext_lazy
1217from unfold .admin import ModelAdmin
1520
1621from donations .models .donors import Donor
1722from redirectioneaza .common .app_url import build_uri
23+ from redirectioneaza .common .async_wrapper import async_wrapper
1824from redirectioneaza .common .messaging import extend_email_context , send_email
1925from utils .common .admin import HasNgoFilter
26+ from utils .constants .time import YEAR
2027
2128logger = 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
2797def 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
52138class 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