Skip to content

Commit d026a87

Browse files
Track grant history (#4529)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Ester Beltrami <[email protected]>
1 parent 54adaa0 commit d026a87

File tree

9 files changed

+677
-23
lines changed

9 files changed

+677
-23
lines changed

backend/api/grants/mutations.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
from api.permissions import IsAuthenticated
1111
from api.types import BaseErrorType
1212
from conferences.models.conference import Conference
13+
from custom_admin.audit import (
14+
create_addition_admin_log_entry,
15+
create_change_admin_log_entry,
16+
)
1317
from grants.models import Grant as GrantModel
1418
from grants.tasks import get_name, notify_new_grant_reply_slack
1519
from notifications.models import EmailTemplate, EmailTemplateIdentifier
@@ -279,11 +283,14 @@ def send_grant(self, info: Info, input: SendGrantInput) -> SendGrantResult:
279283
},
280284
)
281285

286+
create_addition_admin_log_entry(request.user, instance, "Grant created.")
287+
282288
# hack because we return django models
283289
instance.__strawberry_definition__ = Grant.__strawberry_definition__
284290
return instance
285291

286292
@strawberry.mutation(permission_classes=[IsAuthenticated])
293+
@transaction.atomic
287294
def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult:
288295
request = info.context.request
289296

@@ -299,8 +306,11 @@ def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult
299306

300307
for attr, value in asdict(input).items():
301308
setattr(instance, attr, value)
309+
302310
instance.save()
303311

312+
create_change_admin_log_entry(request.user, instance, "Grant updated.")
313+
304314
Participant.objects.update_or_create(
305315
user_id=request.user.id,
306316
conference=instance.conference,
@@ -335,6 +345,10 @@ def send_grant_reply(
335345
grant.status = input.status.to_grant_status()
336346
grant.save()
337347

348+
create_change_admin_log_entry(
349+
request.user, grant, f"Grantee has replied with status {grant.status}."
350+
)
351+
338352
admin_url = request.build_absolute_uri(grant.get_admin_url())
339353
notify_new_grant_reply_slack.delay(grant_id=grant.id, admin_url=admin_url)
340354

backend/api/grants/tests/test_send_grant.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from django.contrib.admin.models import LogEntry
23

34
from conferences.tests.factories import ConferenceFactory
45
from grants.models import Grant
@@ -119,6 +120,14 @@ def test_send_grant(
119120
user=user, conference=conference, privacy_policy="grant"
120121
).exists()
121122

123+
# Verify a log entry is created for the grant creation
124+
assert LogEntry.objects.count() == 1
125+
log_entry = LogEntry.objects.first()
126+
assert log_entry.user_id == user.id
127+
assert log_entry.user == user
128+
assert log_entry.object_id == str(grant.id)
129+
assert log_entry.change_message == "Grant created."
130+
122131
# Verify that the correct email template was used and email was sent
123132
emails_sent = sent_emails()
124133
assert emails_sent.count() == 1

backend/api/grants/tests/test_send_grant_reply.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from unittest.mock import ANY
22

3-
from grants.tests.factories import GrantFactory
43
import pytest
4+
from django.contrib.admin.models import LogEntry
55

66
from grants.models import Grant
7+
from grants.tests.factories import GrantFactory
78
from users.tests.factories import UserFactory
89

910
pytestmark = pytest.mark.django_db
@@ -85,6 +86,13 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
8586
grant.refresh_from_db()
8687
assert grant.status == Grant.Status.confirmed
8788

89+
# Verify audit log entry was created correctly
90+
assert LogEntry.objects.filter(
91+
user=user,
92+
object_id=grant.id,
93+
change_message="Grantee has replied with status confirmed.",
94+
).exists()
95+
8896

8997
def test_status_is_updated_when_reply_is_refused(graphql_client, user):
9098
graphql_client.force_login(user)
@@ -97,6 +105,13 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user):
97105
grant.refresh_from_db()
98106
assert grant.status == Grant.Status.refused
99107

108+
# Verify audit log entry was created correctly
109+
assert LogEntry.objects.filter(
110+
user=user,
111+
object_id=grant.id,
112+
change_message="Grantee has replied with status refused.",
113+
).exists()
114+
100115

101116
def test_call_notify_new_grant_reply(graphql_client, user, mocker):
102117
graphql_client.force_login(user)

backend/api/grants/tests/test_update_grant.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from users.tests.factories import UserFactory
2+
from django.contrib.admin.models import LogEntry
23
from conferences.tests.factories import ConferenceFactory
34
from grants.tests.factories import GrantFactory
45
import pytest
@@ -129,6 +130,13 @@ def test_update_grant(graphql_client, user):
129130
assert participant.facebook_url == "http://facebook.com/pythonpizza"
130131
assert participant.linkedin_url == "http://linkedin.com/company/pythonpizza"
131132

133+
assert LogEntry.objects.count() == 1
134+
log_entry = LogEntry.objects.first()
135+
assert log_entry.user_id == user.id
136+
assert log_entry.user == user
137+
assert log_entry.object_id == str(grant.id)
138+
assert log_entry.change_message == "Grant updated."
139+
132140

133141
def test_cannot_update_a_grant_if_user_is_not_owner(
134142
graphql_client,

backend/custom_admin/admin.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from django.contrib import admin
77
from django.urls import path
88

9+
from custom_admin.audit import create_change_admin_log_entry
10+
911
SITE_NAME = "PyCon Italia"
1012

1113
admin.site.site_header = SITE_NAME
@@ -58,15 +60,50 @@ def wrapper(modeladmin, request, queryset):
5860
@admin.action(description="Confirm pending status change")
5961
@validate_single_conference_selection
6062
def confirm_pending_status(modeladmin, request, queryset):
63+
"""
64+
Efficiently bulk-update status with pending_status, and accurately log the change per object.
65+
"""
66+
# Use values_list to fetch ids and old statuses before updating.
67+
changed_objs_info = list(queryset.values_list("pk", "status", "pending_status"))
68+
69+
# Perform the bulk update.
6170
queryset.update(
6271
status=F("pending_status"),
6372
pending_status=None,
6473
)
6574

75+
model = queryset.model
76+
for pk, old_status, pending_status in changed_objs_info:
77+
obj = model.objects.get(pk=pk)
78+
create_change_admin_log_entry(
79+
request.user,
80+
obj,
81+
change_message=(
82+
f"[Bulk Admin Action] Status changed from '{old_status}' to '{pending_status}'."
83+
),
84+
)
85+
6686

6787
@admin.action(description="Reset pending status to status")
6888
@validate_single_conference_selection
6989
def reset_pending_status_back_to_status(modeladmin, request, queryset):
90+
"""
91+
Efficiently bulk-reset pending_status to None, and accurately log the change per object.
92+
"""
93+
changed_objs_info = list(queryset.values_list("pk", "pending_status"))
94+
7095
queryset.update(
7196
pending_status=None,
7297
)
98+
99+
model = queryset.model
100+
for pk, old_pending_status in changed_objs_info:
101+
if old_pending_status is not None:
102+
obj = model.objects.get(pk=pk)
103+
create_change_admin_log_entry(
104+
request.user,
105+
obj,
106+
change_message=(
107+
f"[Bulk Admin Action] pending_status reset from '{old_pending_status}' to None."
108+
),
109+
)

backend/grants/admin.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,32 @@ def send_reply_emails(modeladmin, request, queryset):
218218
grant.save()
219219
send_grant_reply_approved_email.delay(grant_id=grant.id, is_reminder=False)
220220

221+
create_change_admin_log_entry(
222+
request.user,
223+
grant,
224+
change_message="Sent Approved reply email to applicant.",
225+
)
221226
messages.info(request, f"Sent Approved reply email to {grant.name}")
222227

223228
if (
224229
grant.status == Grant.Status.waiting_list
225230
or grant.status == Grant.Status.waiting_list_maybe
226231
):
227232
send_grant_reply_waiting_list_email.delay(grant_id=grant.id)
233+
create_change_admin_log_entry(
234+
request.user,
235+
grant,
236+
change_message="Sent Waiting List reply email to applicant.",
237+
)
228238
messages.info(request, f"Sent Waiting List reply email to {grant.name}")
229239

230240
if grant.status == Grant.Status.rejected:
231241
send_grant_reply_rejected_email.delay(grant_id=grant.id)
242+
create_change_admin_log_entry(
243+
request.user,
244+
grant,
245+
change_message="Sent Rejected reply email to applicant.",
246+
)
232247
messages.info(request, f"Sent Rejected reply email to {grant.name}")
233248

234249

@@ -252,6 +267,11 @@ def send_grant_reminder_to_waiting_for_confirmation(modeladmin, request, queryse
252267

253268
send_grant_reply_approved_email.delay(grant_id=grant.id, is_reminder=True)
254269

270+
create_change_admin_log_entry(
271+
request.user,
272+
grant,
273+
change_message="Sent Approved reminder email to applicant.",
274+
)
255275
messages.info(request, f"Grant reminder sent to {grant.name}")
256276

257277

@@ -267,6 +287,11 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
267287

268288
for grant in queryset:
269289
send_grant_reply_waiting_list_update_email.delay(grant_id=grant.id)
290+
create_change_admin_log_entry(
291+
request.user,
292+
grant,
293+
change_message="Sent Waiting List update reply email to applicant.",
294+
)
270295
messages.info(request, f"Sent Waiting List update reply email to {grant.name}")
271296

272297

@@ -300,7 +325,7 @@ def create_grant_vouchers(modeladmin, request, queryset):
300325
create_addition_admin_log_entry(
301326
request.user,
302327
grant,
303-
change_message="Created voucher for this grant",
328+
change_message="Created voucher for this grant.",
304329
)
305330

306331
vouchers_to_create.append(
@@ -321,12 +346,12 @@ def create_grant_vouchers(modeladmin, request, queryset):
321346
create_change_admin_log_entry(
322347
request.user,
323348
existing_voucher,
324-
change_message="Upgraded Co-Speaker voucher to Grant voucher",
349+
change_message="Upgraded Co-Speaker voucher to Grant voucher.",
325350
)
326351
create_change_admin_log_entry(
327352
request.user,
328353
grant,
329-
change_message="Updated existing Co-Speaker voucher to grant",
354+
change_message="Updated existing Co-Speaker voucher to grant.",
330355
)
331356
existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
332357
vouchers_to_update.append(existing_voucher)
@@ -348,9 +373,16 @@ def mark_rejected_and_send_email(modeladmin, request, queryset):
348373
)
349374

350375
for grant in queryset:
376+
old_status = grant.status
351377
grant.status = Grant.Status.rejected
352378
grant.save()
353379

380+
create_change_admin_log_entry(
381+
request.user,
382+
grant,
383+
change_message=f"Status changed from '{old_status}' to 'rejected' and rejection email sent.",
384+
)
385+
354386
send_grant_reply_rejected_email.delay(grant_id=grant.id)
355387
messages.info(request, f"Sent Rejected reply email to {grant.name}")
356388

@@ -405,13 +437,29 @@ class GrantReimbursementAdmin(ConferencePermissionMixin, admin.ModelAdmin):
405437
search_fields = ("grant__full_name", "grant__email")
406438
autocomplete_fields = ("grant",)
407439

440+
def delete_model(self, request, obj):
441+
create_change_admin_log_entry(
442+
request.user,
443+
obj.grant,
444+
change_message=f"Reimbursement removed: {obj.category.name}.",
445+
)
446+
super().delete_model(request, obj)
447+
408448

409449
class GrantReimbursementInline(admin.TabularInline):
410450
model = GrantReimbursement
411451
extra = 0
412452
autocomplete_fields = ["category"]
413453
fields = ["category", "granted_amount"]
414454

455+
def delete_model(self, request, obj):
456+
create_change_admin_log_entry(
457+
request.user,
458+
obj.grant,
459+
change_message=f"Reimbursement removed: {obj.category.name}.",
460+
)
461+
super().delete_model(request, obj)
462+
415463

416464
@admin.register(Grant)
417465
class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
@@ -516,6 +564,31 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
516564
),
517565
)
518566

567+
def save_model(self, request, obj, form, change):
568+
"""
569+
Override to log admin actions when status is changed.
570+
"""
571+
if change:
572+
if obj.status != obj._original_status:
573+
create_change_admin_log_entry(
574+
request.user,
575+
obj,
576+
change_message=f"Status changed from '{obj._original_status}' to '{obj.status}'.",
577+
)
578+
if obj.pending_status != obj._original_pending_status:
579+
create_change_admin_log_entry(
580+
request.user,
581+
obj,
582+
change_message=f"Pending status changed from '{obj._original_pending_status}' to '{obj.pending_status}'.",
583+
)
584+
else:
585+
create_addition_admin_log_entry(
586+
request.user,
587+
obj,
588+
change_message="Grant created.",
589+
)
590+
super().save_model(request, obj, form, change)
591+
519592
def change_view(self, request, object_id, form_url="", extra_context=None):
520593
extra_context = extra_context or {}
521594
grant = self.model.objects.get(id=object_id)

0 commit comments

Comments
 (0)