Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ff1e52c
Add log entry for grant creation
estyxx Jan 17, 2026
d99740a
Add log entry for grant update
estyxx Jan 17, 2026
eb9bbb2
Add log entry for reimbursement deletion
estyxx Jan 17, 2026
c44eb8a
Add log entries for reimbursement removals
estyxx Jan 17, 2026
72b24b2
Add audit log entries for reimbursement additions
estyxx Jan 17, 2026
1712a87
Add audit log entries for grant reply emails waiting list
estyxx Jan 17, 2026
ec60476
Add audit log entries for grant reply emails rejected
estyxx Jan 17, 2026
445be64
Add audit log entries for grant reply emails approved
estyxx Jan 17, 2026
9a1aa25
Add audit log entries for grant reminder emails
estyxx Jan 17, 2026
cc297f9
Add audit log entries for grant reply emails waiting list update
estyxx Jan 17, 2026
725b9ec
Add tests for audit log entries for grant voucher creation
estyxx Jan 17, 2026
0b87368
Add audit log entries for grant rejection
estyxx Jan 17, 2026
d0cbbab
Add audit log entries for grant reply mutations
estyxx Jan 17, 2026
59308df
Update audit log entries for grant reimbursements
estyxx Jan 17, 2026
41afff5
Add audit log entries for grant status changes
estyxx Jan 17, 2026
d168bcb
Change message for grant reimbursements in review session
estyxx Jan 17, 2026
d7d361d
Fix grant reimbursement creation when grant is not approved
estyxx Jan 18, 2026
bdf4a5d
Add audit log entries for bulk actions on grants
estyxx Jan 18, 2026
ae42d15
Fix tests for review session admin
estyxx Jan 18, 2026
2af1983
Avoid logging pending_status change when is not changed
estyxx Jan 18, 2026
bd2b2e2
Add transaction to grant update mutation
estyxx Jan 18, 2026
d83b221
Improve audit log message consistency
github-actions[bot] Jan 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/api/grants/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
from api.permissions import IsAuthenticated
from api.types import BaseErrorType
from conferences.models.conference import Conference
from custom_admin.audit import (
create_addition_admin_log_entry,
create_change_admin_log_entry,
)
from grants.models import Grant as GrantModel
from grants.tasks import get_name, notify_new_grant_reply_slack
from notifications.models import EmailTemplate, EmailTemplateIdentifier
Expand Down Expand Up @@ -279,11 +283,14 @@ def send_grant(self, info: Info, input: SendGrantInput) -> SendGrantResult:
},
)

create_addition_admin_log_entry(request.user, instance, "Grant created.")

# hack because we return django models
instance.__strawberry_definition__ = Grant.__strawberry_definition__
return instance

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

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

for attr, value in asdict(input).items():
setattr(instance, attr, value)

instance.save()

create_change_admin_log_entry(request.user, instance, "Grant updated.")

Participant.objects.update_or_create(
user_id=request.user.id,
conference=instance.conference,
Expand Down Expand Up @@ -335,6 +345,10 @@ def send_grant_reply(
grant.status = input.status.to_grant_status()
grant.save()

create_change_admin_log_entry(
request.user, grant, f"Grantee has replied with status {grant.status}."
)

admin_url = request.build_absolute_uri(grant.get_admin_url())
notify_new_grant_reply_slack.delay(grant_id=grant.id, admin_url=admin_url)

Expand Down
9 changes: 9 additions & 0 deletions backend/api/grants/tests/test_send_grant.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from django.contrib.admin.models import LogEntry

from conferences.tests.factories import ConferenceFactory
from grants.models import Grant
Expand Down Expand Up @@ -119,6 +120,14 @@ def test_send_grant(
user=user, conference=conference, privacy_policy="grant"
).exists()

# Verify a log entry is created for the grant creation
assert LogEntry.objects.count() == 1
log_entry = LogEntry.objects.first()
assert log_entry.user_id == user.id
assert log_entry.user == user
assert log_entry.object_id == str(grant.id)
assert log_entry.change_message == "Grant created."

# Verify that the correct email template was used and email was sent
emails_sent = sent_emails()
assert emails_sent.count() == 1
Expand Down
17 changes: 16 additions & 1 deletion backend/api/grants/tests/test_send_grant_reply.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from unittest.mock import ANY

from grants.tests.factories import GrantFactory
import pytest
from django.contrib.admin.models import LogEntry

from grants.models import Grant
from grants.tests.factories import GrantFactory
from users.tests.factories import UserFactory

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

# Verify audit log entry was created correctly
assert LogEntry.objects.filter(
user=user,
object_id=grant.id,
change_message="Grantee has replied with status confirmed.",
).exists()


def test_status_is_updated_when_reply_is_refused(graphql_client, user):
graphql_client.force_login(user)
Expand All @@ -97,6 +105,13 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user):
grant.refresh_from_db()
assert grant.status == Grant.Status.refused

# Verify audit log entry was created correctly
assert LogEntry.objects.filter(
user=user,
object_id=grant.id,
change_message="Grantee has replied with status refused.",
).exists()


def test_call_notify_new_grant_reply(graphql_client, user, mocker):
graphql_client.force_login(user)
Expand Down
8 changes: 8 additions & 0 deletions backend/api/grants/tests/test_update_grant.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from users.tests.factories import UserFactory
from django.contrib.admin.models import LogEntry
from conferences.tests.factories import ConferenceFactory
from grants.tests.factories import GrantFactory
import pytest
Expand Down Expand Up @@ -129,6 +130,13 @@ def test_update_grant(graphql_client, user):
assert participant.facebook_url == "http://facebook.com/pythonpizza"
assert participant.linkedin_url == "http://linkedin.com/company/pythonpizza"

assert LogEntry.objects.count() == 1
log_entry = LogEntry.objects.first()
assert log_entry.user_id == user.id
assert log_entry.user == user
assert log_entry.object_id == str(grant.id)
assert log_entry.change_message == "Grant updated."


def test_cannot_update_a_grant_if_user_is_not_owner(
graphql_client,
Expand Down
37 changes: 37 additions & 0 deletions backend/custom_admin/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from django.contrib import admin
from django.urls import path

from custom_admin.audit import create_change_admin_log_entry

SITE_NAME = "PyCon Italia"

admin.site.site_header = SITE_NAME
Expand Down Expand Up @@ -58,15 +60,50 @@ def wrapper(modeladmin, request, queryset):
@admin.action(description="Confirm pending status change")
@validate_single_conference_selection
def confirm_pending_status(modeladmin, request, queryset):
"""
Efficiently bulk-update status with pending_status, and accurately log the change per object.
"""
# Use values_list to fetch ids and old statuses before updating.
changed_objs_info = list(queryset.values_list("pk", "status", "pending_status"))

# Perform the bulk update.
queryset.update(
status=F("pending_status"),
pending_status=None,
)

model = queryset.model
for pk, old_status, pending_status in changed_objs_info:
obj = model.objects.get(pk=pk)
create_change_admin_log_entry(
request.user,
obj,
change_message=(
f"[Bulk Admin Action] Status changed from '{old_status}' to '{pending_status}'."
),
)


@admin.action(description="Reset pending status to status")
@validate_single_conference_selection
def reset_pending_status_back_to_status(modeladmin, request, queryset):
"""
Efficiently bulk-reset pending_status to None, and accurately log the change per object.
"""
changed_objs_info = list(queryset.values_list("pk", "pending_status"))

queryset.update(
pending_status=None,
)

model = queryset.model
for pk, old_pending_status in changed_objs_info:
if old_pending_status is not None:
obj = model.objects.get(pk=pk)
create_change_admin_log_entry(
request.user,
obj,
change_message=(
f"[Bulk Admin Action] pending_status reset from '{old_pending_status}' to None."
),
)
79 changes: 76 additions & 3 deletions backend/grants/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,17 +218,32 @@ def send_reply_emails(modeladmin, request, queryset):
grant.save()
send_grant_reply_approved_email.delay(grant_id=grant.id, is_reminder=False)

create_change_admin_log_entry(
request.user,
grant,
change_message="Sent Approved reply email to applicant.",
)
messages.info(request, f"Sent Approved reply email to {grant.name}")

if (
grant.status == Grant.Status.waiting_list
or grant.status == Grant.Status.waiting_list_maybe
):
send_grant_reply_waiting_list_email.delay(grant_id=grant.id)
create_change_admin_log_entry(
request.user,
grant,
change_message="Sent Waiting List reply email to applicant.",
)
messages.info(request, f"Sent Waiting List reply email to {grant.name}")

if grant.status == Grant.Status.rejected:
send_grant_reply_rejected_email.delay(grant_id=grant.id)
create_change_admin_log_entry(
request.user,
grant,
change_message="Sent Rejected reply email to applicant.",
)
messages.info(request, f"Sent Rejected reply email to {grant.name}")


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

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

create_change_admin_log_entry(
request.user,
grant,
change_message="Sent Approved reminder email to applicant.",
)
messages.info(request, f"Grant reminder sent to {grant.name}")


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

for grant in queryset:
send_grant_reply_waiting_list_update_email.delay(grant_id=grant.id)
create_change_admin_log_entry(
request.user,
grant,
change_message="Sent Waiting List update reply email to applicant.",
)
messages.info(request, f"Sent Waiting List update reply email to {grant.name}")


Expand Down Expand Up @@ -300,7 +325,7 @@ def create_grant_vouchers(modeladmin, request, queryset):
create_addition_admin_log_entry(
request.user,
grant,
change_message="Created voucher for this grant",
change_message="Created voucher for this grant.",
)

vouchers_to_create.append(
Expand All @@ -321,12 +346,12 @@ def create_grant_vouchers(modeladmin, request, queryset):
create_change_admin_log_entry(
request.user,
existing_voucher,
change_message="Upgraded Co-Speaker voucher to Grant voucher",
change_message="Upgraded Co-Speaker voucher to Grant voucher.",
)
create_change_admin_log_entry(
request.user,
grant,
change_message="Updated existing Co-Speaker voucher to grant",
change_message="Updated existing Co-Speaker voucher to grant.",
)
existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
vouchers_to_update.append(existing_voucher)
Expand All @@ -348,9 +373,16 @@ def mark_rejected_and_send_email(modeladmin, request, queryset):
)

for grant in queryset:
old_status = grant.status
grant.status = Grant.Status.rejected
grant.save()

create_change_admin_log_entry(
request.user,
grant,
change_message=f"Status changed from '{old_status}' to 'rejected' and rejection email sent.",
)

send_grant_reply_rejected_email.delay(grant_id=grant.id)
messages.info(request, f"Sent Rejected reply email to {grant.name}")

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

def delete_model(self, request, obj):
create_change_admin_log_entry(
request.user,
obj.grant,
change_message=f"Reimbursement removed: {obj.category.name}.",
)
super().delete_model(request, obj)


class GrantReimbursementInline(admin.TabularInline):
model = GrantReimbursement
extra = 0
autocomplete_fields = ["category"]
fields = ["category", "granted_amount"]

def delete_model(self, request, obj):
create_change_admin_log_entry(
request.user,
obj.grant,
change_message=f"Reimbursement removed: {obj.category.name}.",
)
super().delete_model(request, obj)


@admin.register(Grant)
class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
Expand Down Expand Up @@ -516,6 +564,31 @@ class GrantAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
),
)

def save_model(self, request, obj, form, change):
"""
Override to log admin actions when status is changed.
"""
if change:
if obj.status != obj._original_status:
create_change_admin_log_entry(
request.user,
obj,
change_message=f"Status changed from '{obj._original_status}' to '{obj.status}'.",
)
if obj.pending_status != obj._original_pending_status:
create_change_admin_log_entry(
request.user,
obj,
change_message=f"Pending status changed from '{obj._original_pending_status}' to '{obj.pending_status}'.",
)
else:
create_addition_admin_log_entry(
request.user,
obj,
change_message="Grant created.",
)
super().save_model(request, obj, form, change)

def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
grant = self.model.objects.get(id=object_id)
Expand Down
Loading
Loading