From ff1e52c38d5edc4bc27db165c2849a3205b69039 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 15:57:24 +0000 Subject: [PATCH 01/22] Add log entry for grant creation --- backend/api/grants/mutations.py | 3 +++ backend/api/grants/tests/test_send_grant.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 171f3ea8a2..4a22bfba25 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -10,6 +10,7 @@ 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 from grants.models import Grant as GrantModel from grants.tasks import get_name, notify_new_grant_reply_slack from notifications.models import EmailTemplate, EmailTemplateIdentifier @@ -279,6 +280,8 @@ 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 diff --git a/backend/api/grants/tests/test_send_grant.py b/backend/api/grants/tests/test_send_grant.py index 9b75f639c3..1a2a362151 100644 --- a/backend/api/grants/tests/test_send_grant.py +++ b/backend/api/grants/tests/test_send_grant.py @@ -1,4 +1,5 @@ import pytest +from django.contrib.admin.models import LogEntry from conferences.tests.factories import ConferenceFactory from grants.models import Grant @@ -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 From d99740af7b18927b6a197af231363efffda02955 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:00:40 +0000 Subject: [PATCH 02/22] Add log entry for grant update --- backend/api/grants/mutations.py | 3 +++ backend/api/grants/tests/test_update_grant.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 4a22bfba25..cd2fa9fe75 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -302,6 +302,9 @@ def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult for attr, value in asdict(input).items(): setattr(instance, attr, value) + + create_change_admin_log_entry(request.user, instance, "Grant updated") + instance.save() Participant.objects.update_or_create( diff --git a/backend/api/grants/tests/test_update_grant.py b/backend/api/grants/tests/test_update_grant.py index c96fe6807f..c87c13dcb0 100644 --- a/backend/api/grants/tests/test_update_grant.py +++ b/backend/api/grants/tests/test_update_grant.py @@ -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 @@ -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, From eb9bbb2feff0b2342acd76d45dda327bd338cf65 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:12:30 +0000 Subject: [PATCH 03/22] Add log entry for reimbursement deletion --- backend/reviews/admin.py | 10 +++++++++- backend/reviews/tests/test_admin.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 94013430ef..3fbf5d0a3e 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -24,6 +24,7 @@ from django.urls import path, reverse from django.utils.safestring import mark_safe +from custom_admin.audit import create_deletion_admin_log_entry from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory from participants.models import Participant from reviews.models import AvailableScoreOption, ReviewSession, UserReview @@ -308,7 +309,14 @@ def _review_grants_recap_view(self, request, review_session): ) # If decision is not approved, delete all; else, filter and delete missing reimbursements if decision != Grant.Status.approved: - grant.reimbursements.all().delete() + # Log deletions before deleting + for reimbursement in grant.reimbursements.all(): + create_deletion_admin_log_entry( + request.user, + grant, + change_message=f"Reimbursement {reimbursement.category.name} removed.", + ) + reimbursement.delete() else: # Only keep those in current approved_reimbursement_categories grant.reimbursements.exclude( diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 8ab73fa578..a13f714b7c 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -2,6 +2,7 @@ import pytest from django.contrib.admin import AdminSite +from django.contrib.admin.models import LogEntry from conferences.tests.factories import ConferenceFactory from grants.models import Grant @@ -370,7 +371,7 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) def test_save_review_grants_update_grants_status_to_rejected_removes_reimbursements( rf, mocker ): - mock_messages = mocker.patch("reviews.admin.messages") + mocker.patch("reviews.admin.messages") user = UserFactory(is_staff=True, is_superuser=True) conference = ConferenceFactory() @@ -443,6 +444,14 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert grant_1.reimbursements.count() == 0 + assert LogEntry.objects.count() == 3 + for reimbursement in grant_1.reimbursements.all(): + assert LogEntry.objects.filter( + user=user, + object_id=str(reimbursement.id), + change_message=f"Reimbursement {reimbursement.category.name} removed.", + ).exists() + def test_save_review_grants_modify_reimbursements(rf, mocker): mock_messages = mocker.patch("reviews.admin.messages") From c44eb8a0c6920603b9f087727423cd7ff4fa8762 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:18:24 +0000 Subject: [PATCH 04/22] Add log entries for reimbursement removals --- backend/reviews/admin.py | 12 ++++++++++-- backend/reviews/tests/test_admin.py | 21 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 3fbf5d0a3e..1d88cfc4c0 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -319,9 +319,17 @@ def _review_grants_recap_view(self, request, review_session): reimbursement.delete() else: # Only keep those in current approved_reimbursement_categories - grant.reimbursements.exclude( + # Log deletions before deleting + to_delete = grant.reimbursements.exclude( category_id__in=approved_reimbursement_categories - ).delete() + ) + for reimbursement in to_delete: + create_deletion_admin_log_entry( + request.user, + grant, + change_message=f"Reimbursement {reimbursement.category.name} removed.", + ) + to_delete.delete() for grant in grants: # save each to make sure we re-calculate the grants amounts diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index a13f714b7c..1d8105533a 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -454,7 +454,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme def test_save_review_grants_modify_reimbursements(rf, mocker): - mock_messages = mocker.patch("reviews.admin.messages") + mocker.patch("reviews.admin.messages") user = UserFactory(is_staff=True, is_superuser=True) conference = ConferenceFactory() @@ -515,9 +515,28 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): admin = ReviewSessionAdmin(ReviewSession, AdminSite()) response = admin._review_grants_recap_view(request, review_session) + # Should redirect after successful save + assert response.status_code == 302 + assert ( + response.url + == f"/admin/reviews/reviewsession/{review_session.id}/review/recap/" + ) + grant_1.refresh_from_db() assert grant_1.reimbursements.count() == 1 assert { reimbursement.category for reimbursement in grant_1.reimbursements.all() } == {ticket_category} + + assert LogEntry.objects.count() == 2 + assert LogEntry.objects.filter( + user=user, + object_id=str(travel_category.id), + change_message=f"Reimbursement {travel_category.name} removed.", + ).exists() + assert LogEntry.objects.filter( + user=user, + object_id=str(accommodation_category.id), + change_message=f"Reimbursement {accommodation_category.name} removed.", + ).exists() From 72b24b27ea6ff6b45734ea7598bf9de8d56fc1ac Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:33:06 +0000 Subject: [PATCH 05/22] Add audit log entries for reimbursement additions --- backend/reviews/admin.py | 44 +++++++++++++++++++++++------ backend/reviews/tests/test_admin.py | 28 ++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 1d88cfc4c0..489311f430 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -24,7 +24,11 @@ from django.urls import path, reverse from django.utils.safestring import mark_safe -from custom_admin.audit import create_deletion_admin_log_entry +from custom_admin.audit import ( + create_addition_admin_log_entry, + create_change_admin_log_entry, + create_deletion_admin_log_entry, +) from grants.models import Grant, GrantReimbursement, GrantReimbursementCategory from participants.models import Participant from reviews.models import AvailableScoreOption, ReviewSession, UserReview @@ -339,23 +343,45 @@ def _review_grants_recap_view(self, request, review_session): "pending_status", ] ) + create_change_admin_log_entry( + request.user, + grant, + change_message=f"Grant pending_status changed from '{grant.status}' to '{grant.pending_status}'.", + ) + approved_reimbursement_categories = ( approved_reimbursement_categories_decisions.get(grant.id, []) ) + for reimbursement_category_id in approved_reimbursement_categories: # Check if category exists to avoid KeyError if reimbursement_category_id not in reimbursement_categories: continue - GrantReimbursement.objects.update_or_create( - grant=grant, - category_id=reimbursement_category_id, - defaults={ - "granted_amount": reimbursement_categories[ - reimbursement_category_id - ].max_amount - }, + reimbursement, created = ( + GrantReimbursement.objects.update_or_create( + grant=grant, + category_id=reimbursement_category_id, + defaults={ + "granted_amount": reimbursement_categories[ + reimbursement_category_id + ].max_amount + }, + ) ) + if created: + create_addition_admin_log_entry( + request.user, + grant, + change_message=f"Reimbursement {reimbursement.category.name} added.", + ) + else: + create_change_admin_log_entry( + request.user, + grant, + change_message=f"Reimbursement {reimbursement.category.name} updated.", + ) + messages.success( request, "Decisions saved. Check the Grants Summary for more info." ) diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 1d8105533a..e8a8f7a455 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -365,6 +365,34 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) reimbursement.category for reimbursement in grant_2.reimbursements.all() } == {ticket_category, travel_category, accommodation_category} + # Verify log entries were created + assert ( + LogEntry.objects.filter(object_id=grant_1.id).count() == 3 + ) # 1 pending_status change, 2 reimbursement additions + assert ( + LogEntry.objects.filter(object_id=grant_2.id).count() == 4 + ) # 1 pending_status change, 3 reimbursement additions + assert LogEntry.objects.filter( + user=user, + object_id__in=[str(grant_1.id), str(grant_2.id)], + change_message=f"Grant pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", + ).exists() + assert LogEntry.objects.filter( + user=user, + object_id__in=[str(grant_1.id), str(grant_2.id)], + change_message=f"Reimbursement {ticket_category.name} added.", + ).exists() + assert LogEntry.objects.filter( + user=user, + object_id__in=[str(grant_1.id), str(grant_2.id)], + change_message=f"Reimbursement {travel_category.name} added.", + ).exists() + assert LogEntry.objects.filter( + user=user, + object_id=str(grant_2.id), + change_message=f"Reimbursement {accommodation_category.name} added.", + ).exists() + mock_messages.success.assert_called_once() From 1712a8722ea308a155fe1b7d3c32c722cab68a87 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:47:24 +0000 Subject: [PATCH 06/22] Add audit log entries for grant reply emails waiting list --- backend/grants/admin.py | 5 +++++ backend/grants/tests/test_admin.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 4a73c6fb0f..452777feac 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -225,6 +225,11 @@ def send_reply_emails(modeladmin, request, queryset): 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: diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 14a15183d8..24f129fc47 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -3,6 +3,7 @@ from unittest.mock import call import pytest +from django.contrib.admin.models import LogEntry from django.utils import timezone from conferences.models.conference_voucher import ConferenceVoucher @@ -61,6 +62,7 @@ def test_send_reply_emails_with_grants_from_multiple_conferences_fails( mock_send_approved_email.assert_not_called() mock_send_waiting_list_email.assert_not_called() mock_send_rejected_email.assert_not_called() + assert not LogEntry.objects.exists() def test_send_reply_emails_approved_grant_missing_reimbursements( @@ -81,6 +83,7 @@ def test_send_reply_emails_approved_grant_missing_reimbursements( f"Grant for {grant.name} is missing reimbursement categories!", ) mock_send_approved_email.assert_not_called() + assert not LogEntry.objects.exists() def test_send_reply_emails_approved_missing_amount(rf, mocker, admin_user): @@ -106,6 +109,7 @@ def test_send_reply_emails_approved_missing_amount(rf, mocker, admin_user): f"Grant for {grant.name} is missing 'Total Amount'!", ) mock_send_approved_email.assert_not_called() + assert not LogEntry.objects.exists() def test_send_reply_emails_approved_set_deadline_in_fourteen_days( @@ -166,6 +170,11 @@ def test_send_reply_emails_waiting_list(rf, mocker, admin_user): request, f"Sent Waiting List reply email to {grant.name}" ) mock_send_waiting_list_email.assert_called_once_with(grant_id=grant.id) + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Waiting List reply email to applicant", + ).exists() def test_send_reply_emails_waiting_list_maybe(rf, mocker, admin_user): @@ -185,6 +194,11 @@ def test_send_reply_emails_waiting_list_maybe(rf, mocker, admin_user): request, f"Sent Waiting List reply email to {grant.name}" ) mock_send_waiting_list_email.assert_called_once_with(grant_id=grant.id) + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Waiting List reply email to applicant", + ).exists() def test_send_reply_emails_rejected(rf, mocker, admin_user): From ec60476db8bc615ed704873e9090ee898130a3a3 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:50:52 +0000 Subject: [PATCH 07/22] Add audit log entries for grant reply emails rejected --- backend/grants/admin.py | 5 +++++ backend/grants/tests/test_admin.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 452777feac..2d4c48eaa4 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -234,6 +234,11 @@ def send_reply_emails(modeladmin, request, queryset): 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}") diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 24f129fc47..b894027303 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -218,6 +218,11 @@ def test_send_reply_emails_rejected(rf, mocker, admin_user): request, f"Sent Rejected reply email to {grant.name}" ) mock_send_rejected_email.assert_called_once_with(grant_id=grant.id) + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Rejected reply email to applicant", + ).exists() def test_create_grant_vouchers(rf, mocker, admin_user): From 445be641266874d1f5e16c04f9f83372bff18e45 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 16:54:19 +0000 Subject: [PATCH 08/22] Add audit log entries for grant reply emails approved --- backend/grants/admin.py | 5 +++++ backend/grants/tests/test_admin.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 2d4c48eaa4..865a540878 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -218,6 +218,11 @@ 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 ( diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index b894027303..f27518b701 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -138,20 +138,31 @@ def test_send_reply_emails_approved_set_deadline_in_fourteen_days( send_reply_emails(None, request=request, queryset=Grant.objects.all()) + # Verify admin action was called correctly mock_messages.info.assert_called_once_with( request, f"Sent Approved reply email to {grant.name}", ) - mock_send_approved_email.assert_called_once_with( - grant_id=grant.id, is_reminder=False - ) + # Verify deadline was set correctly grant.refresh_from_db() assert ( f"{grant.applicant_reply_deadline:%Y-%m-%d}" == f"{(timezone.now().date() + timedelta(days=14)):%Y-%m-%d}" ) + # Verify task was queued correctly + mock_send_approved_email.assert_called_once_with( + grant_id=grant.id, is_reminder=False + ) + + # Verify audit log entry was created correctly + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Approved reply email to applicant", + ).exists() + def test_send_reply_emails_waiting_list(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") From 9a1aa259badb85954935b4bfcaa13c25d554cb13 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:00:31 +0000 Subject: [PATCH 09/22] Add audit log entries for grant reminder emails --- backend/grants/admin.py | 5 +++++ backend/grants/tests/test_admin.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 865a540878..458d150d2b 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -267,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}") diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index f27518b701..07d96ed203 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -13,6 +13,7 @@ create_grant_vouchers, mark_rejected_and_send_email, reset_pending_status_back_to_status, + send_grant_reminder_to_waiting_for_confirmation, send_reply_emails, ) from grants.models import Grant @@ -236,6 +237,38 @@ def test_send_reply_emails_rejected(rf, mocker, admin_user): ).exists() +def test_send_grant_reminder_to_waiting_for_confirmation(rf, mocker, admin_user): + mock_messages = mocker.patch("grants.admin.messages") + grant = GrantFactory(status=Grant.Status.waiting_for_confirmation) + request = rf.get("/") + request.user = admin_user + mock_send_approved_reminder_email = mocker.patch( + "grants.admin.send_grant_reply_approved_email.delay" + ) + + send_grant_reminder_to_waiting_for_confirmation( + None, request=request, queryset=Grant.objects.all() + ) + + # Verify admin action was called correctly + mock_messages.info.assert_called_once_with( + request, + f"Grant reminder sent to {grant.name}", + ) + + # Verify task was queued correctly + mock_send_approved_reminder_email.assert_called_once_with( + grant_id=grant.id, is_reminder=True + ) + + # Verify audit log entry was created correctly + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Approved reminder email to applicant", + ).exists() + + def test_create_grant_vouchers(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") From cc297f9d289e55a09da392ac845cd7359666ec80 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:02:35 +0000 Subject: [PATCH 10/22] Add audit log entries for grant reply emails waiting list update --- backend/grants/admin.py | 5 +++++ backend/grants/tests/test_admin.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 458d150d2b..0eef6be22d 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -287,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}") diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 07d96ed203..035a3ca260 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -14,6 +14,7 @@ mark_rejected_and_send_email, reset_pending_status_back_to_status, send_grant_reminder_to_waiting_for_confirmation, + send_reply_email_waiting_list_update, send_reply_emails, ) from grants.models import Grant @@ -269,6 +270,36 @@ def test_send_grant_reminder_to_waiting_for_confirmation(rf, mocker, admin_user) ).exists() +def test_send_reply_email_waiting_list_update(rf, mocker, admin_user): + mock_messages = mocker.patch("grants.admin.messages") + grant = GrantFactory(status=Grant.Status.waiting_list) + request = rf.get("/") + request.user = admin_user + mock_send_waiting_list_update_email = mocker.patch( + "grants.admin.send_grant_reply_waiting_list_update_email.delay" + ) + + send_reply_email_waiting_list_update( + None, request=request, queryset=Grant.objects.all() + ) + + # Verify admin action was called correctly + mock_messages.info.assert_called_once_with( + request, + f"Sent Waiting List update reply email to {grant.name}", + ) + + # Verify task was queued correctly + mock_send_waiting_list_update_email.assert_called_once_with(grant_id=grant.id) + + # Verify audit log entry was created correctly + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Sent Waiting List update reply email to applicant", + ).exists() + + def test_create_grant_vouchers(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") From 725b9ec30f07744f1805e1c2b5856665f10dd5f4 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:04:39 +0000 Subject: [PATCH 11/22] Add tests for audit log entries for grant voucher creation --- backend/grants/tests/test_admin.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 035a3ca260..25491145f1 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -335,6 +335,16 @@ def test_create_grant_vouchers(rf, mocker, admin_user): request, "Vouchers created!", ) + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant_1.id, + change_message="Created voucher for this grant", + ).exists() + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant_2.id, + change_message="Created voucher for this grant", + ).exists() @pytest.mark.parametrize( From 0b87368e0ef5a18e534b4957259f0b5484de181c Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:07:46 +0000 Subject: [PATCH 12/22] Add audit log entries for grant rejection --- backend/grants/admin.py | 7 +++++++ backend/grants/tests/test_admin.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 0eef6be22d..362ceee9c5 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -373,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}") diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 25491145f1..f0f029ee94 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -555,6 +555,8 @@ def test_mark_rejected_and_send_email(rf, mocker, admin_user): grant2.refresh_from_db() assert grant1.status == Grant.Status.rejected assert grant2.status == Grant.Status.rejected + + # Verify admin action was called correctly mock_messages.info.assert_has_calls( [ call(request, f"Sent Rejected reply email to {grant1.name}"), @@ -562,10 +564,24 @@ def test_mark_rejected_and_send_email(rf, mocker, admin_user): ], any_order=True, ) + + # Verify task was queued correctly mock_send_rejected_email.assert_has_calls( [call(grant_id=grant1.id), call(grant_id=grant2.id)], any_order=True ) + # Verify audit log entries were created correctly + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant1.id, + change_message="Status changed from 'waiting_list' to 'rejected' and rejection email sent", + ).exists() + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant2.id, + change_message="Status changed from 'waiting_list_maybe' to 'rejected' and rejection email sent", + ).exists() + def test_confirm_pending_status_action(rf): grant_1 = GrantFactory( From d0cbbab6395c089d07997bcc09a365d082e57604 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:14:05 +0000 Subject: [PATCH 13/22] Add audit log entries for grant reply mutations --- backend/api/grants/mutations.py | 9 ++++++++- .../api/grants/tests/test_send_grant_reply.py | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index cd2fa9fe75..8b0451a45c 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -10,7 +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 +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 @@ -341,6 +344,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) diff --git a/backend/api/grants/tests/test_send_grant_reply.py b/backend/api/grants/tests/test_send_grant_reply.py index 22e2fede44..3b1dd09b9d 100644 --- a/backend/api/grants/tests/test_send_grant_reply.py +++ b/backend/api/grants/tests/test_send_grant_reply.py @@ -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 @@ -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) @@ -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) From 59308df54d8d5c5689e09adc984b14afa5d21567 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:26:10 +0000 Subject: [PATCH 14/22] Update audit log entries for grant reimbursements --- backend/grants/admin.py | 16 +++++++++++++++ backend/grants/tests/test_admin.py | 30 ++++++++++++++++++++++++++++- backend/reviews/admin.py | 4 ++-- backend/reviews/tests/test_admin.py | 4 ++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 362ceee9c5..ec72b11bc8 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -437,6 +437,14 @@ 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 @@ -444,6 +452,14 @@ class GrantReimbursementInline(admin.TabularInline): 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): diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index f0f029ee94..6ae5d423a6 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -4,12 +4,14 @@ import pytest from django.contrib.admin.models import LogEntry +from django.contrib.admin.sites import AdminSite from django.utils import timezone from conferences.models.conference_voucher import ConferenceVoucher from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory from grants.admin import ( confirm_pending_status, + GrantReimbursementAdmin, create_grant_vouchers, mark_rejected_and_send_email, reset_pending_status_back_to_status, @@ -17,11 +19,11 @@ send_reply_email_waiting_list_update, send_reply_emails, ) -from grants.models import Grant from grants.tests.factories import ( GrantFactory, GrantReimbursementFactory, ) +from grants.models import Grant, GrantReimbursement pytestmark = pytest.mark.django_db @@ -675,3 +677,29 @@ def test_reset_pending_status_back_to_status_action(rf): # Left out from the action assert grant_4.status == Grant.Status.waiting_list_maybe assert grant_4.pending_status == Grant.Status.confirmed + + +def test_delete_reimbursement_from_admin_logs_audit_log_entry(rf, admin_user): + grant = GrantFactory() + reimbursement = GrantReimbursementFactory( + grant=grant, + category__conference=grant.conference, + category__ticket=True, + granted_amount=Decimal("100"), + ) + + request = rf.get("/") + request.user = admin_user + + admin = GrantReimbursementAdmin(GrantReimbursement, AdminSite()) + admin.delete_model(request, reimbursement) + + # Verify reimbursement was deleted + assert not GrantReimbursement.objects.filter(id=reimbursement.id).exists() + + # Verify audit log entry was created correctly + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message=f"Reimbursement removed: {reimbursement.category.name}", + ).exists() diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 489311f430..40fe81caea 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -318,7 +318,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"Reimbursement {reimbursement.category.name} removed.", + change_message=f"Reimbursement removed: {reimbursement.category.name}", ) reimbursement.delete() else: @@ -331,7 +331,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"Reimbursement {reimbursement.category.name} removed.", + change_message=f"Reimbursement removed: {reimbursement.category.name}", ) to_delete.delete() diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index e8a8f7a455..8d6bef58a0 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -477,7 +477,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert LogEntry.objects.filter( user=user, object_id=str(reimbursement.id), - change_message=f"Reimbursement {reimbursement.category.name} removed.", + change_message=f"Reimbursement removed: {reimbursement.category.name}", ).exists() @@ -561,7 +561,7 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): assert LogEntry.objects.filter( user=user, object_id=str(travel_category.id), - change_message=f"Reimbursement {travel_category.name} removed.", + change_message=f"Reimbursement removed: {travel_category.name}", ).exists() assert LogEntry.objects.filter( user=user, From 41afff5b958288aa5c1362f3b125cbb9289cc6d1 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 17:35:18 +0000 Subject: [PATCH 15/22] Add audit log entries for grant status changes --- backend/grants/admin.py | 25 +++++++++++++++ backend/grants/tests/test_admin.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index ec72b11bc8..7e87623623 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -564,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) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 6ae5d423a6..748f8d9efe 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -11,6 +11,7 @@ from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory from grants.admin import ( confirm_pending_status, + GrantAdmin, GrantReimbursementAdmin, create_grant_vouchers, mark_rejected_and_send_email, @@ -703,3 +704,52 @@ def test_delete_reimbursement_from_admin_logs_audit_log_entry(rf, admin_user): object_id=grant.id, change_message=f"Reimbursement removed: {reimbursement.category.name}", ).exists() + + +def test_save_grant_in_admin_logs_audit_log_entry(rf, admin_user): + grant = GrantFactory() + request = rf.get("/") + request.user = admin_user + + admin = GrantAdmin(Grant, AdminSite()) + admin.save_model(request, grant, None, False) + + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Grant created", + ).exists() + + +def test_save_grant_in_admin_logs_audit_log_entry_for_status_change(rf, admin_user): + grant = GrantFactory(status=Grant.Status.pending) + request = rf.get("/") + request.user = admin_user + + admin = GrantAdmin(Grant, AdminSite()) + grant.status = Grant.Status.confirmed + admin.save_model(request, grant, None, True) + + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Status changed from 'pending' to 'confirmed'", + ).exists() + + +def test_save_grant_in_admin_logs_audit_log_entry_for_pending_status_change( + rf, admin_user +): + grant = GrantFactory(pending_status=Grant.Status.pending) + request = rf.get("/") + request.user = admin_user + + admin = GrantAdmin(Grant, AdminSite()) + grant.pending_status = Grant.Status.confirmed + admin.save_model(request, grant, None, True) + + assert LogEntry.objects.filter( + user=admin_user, + object_id=grant.id, + change_message="Pending status changed from 'pending' to 'confirmed'", + ).exists() From d168bcb0109c18d5b22cbd3c365b82455dec0377 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 17 Jan 2026 18:02:16 +0000 Subject: [PATCH 16/22] Change message for grant reimbursements in review session --- backend/reviews/admin.py | 10 +++++----- backend/reviews/tests/test_admin.py | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 40fe81caea..436777e7ec 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -318,7 +318,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", ) reimbursement.delete() else: @@ -331,7 +331,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", ) to_delete.delete() @@ -346,7 +346,7 @@ def _review_grants_recap_view(self, request, review_session): create_change_admin_log_entry( request.user, grant, - change_message=f"Grant pending_status changed from '{grant.status}' to '{grant.pending_status}'.", + change_message=f"[Review Session] Grant status updated: pending_status changed from '{grant.status}' to '{grant.pending_status}'.", ) approved_reimbursement_categories = ( @@ -373,13 +373,13 @@ def _review_grants_recap_view(self, request, review_session): create_addition_admin_log_entry( request.user, grant, - change_message=f"Reimbursement {reimbursement.category.name} added.", + change_message=f"[Review Session] Reimbursement {reimbursement.category.name} added.", ) else: create_change_admin_log_entry( request.user, grant, - change_message=f"Reimbursement {reimbursement.category.name} updated.", + change_message=f"[Review Session] Reimbursement {reimbursement.category.name} updated.", ) messages.success( diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 8d6bef58a0..949cdc841c 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -375,22 +375,22 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) assert LogEntry.objects.filter( user=user, object_id__in=[str(grant_1.id), str(grant_2.id)], - change_message=f"Grant pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", + change_message=f"[Review Session] Grant status updated: pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", ).exists() assert LogEntry.objects.filter( user=user, object_id__in=[str(grant_1.id), str(grant_2.id)], - change_message=f"Reimbursement {ticket_category.name} added.", + change_message=f"[Review Session] Reimbursement {ticket_category.name} added.", ).exists() assert LogEntry.objects.filter( user=user, object_id__in=[str(grant_1.id), str(grant_2.id)], - change_message=f"Reimbursement {travel_category.name} added.", + change_message=f"[Review Session] Reimbursement {travel_category.name} added.", ).exists() assert LogEntry.objects.filter( user=user, object_id=str(grant_2.id), - change_message=f"Reimbursement {accommodation_category.name} added.", + change_message=f"[Review Session] Reimbursement {accommodation_category.name} added.", ).exists() mock_messages.success.assert_called_once() @@ -477,7 +477,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert LogEntry.objects.filter( user=user, object_id=str(reimbursement.id), - change_message=f"Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", ).exists() @@ -557,14 +557,19 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): reimbursement.category for reimbursement in grant_1.reimbursements.all() } == {ticket_category} - assert LogEntry.objects.count() == 2 + assert LogEntry.objects.count() == 4 + assert LogEntry.objects.filter( + user=user, + object_id__in=[str(grant_1.id)], + change_message=f"[Review Session] Grant status updated: pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", + ).exists() assert LogEntry.objects.filter( user=user, object_id=str(travel_category.id), - change_message=f"Reimbursement removed: {travel_category.name}", + change_message=f"[Review Session] Reimbursement removed: {travel_category.name}", ).exists() assert LogEntry.objects.filter( user=user, object_id=str(accommodation_category.id), - change_message=f"Reimbursement {accommodation_category.name} removed.", + change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", ).exists() From d7d361da87164eb7aecebfb8a6b7d187b6d457db Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 18 Jan 2026 15:08:07 +0000 Subject: [PATCH 17/22] Fix grant reimbursement creation when grant is not approved --- backend/reviews/admin.py | 6 +++ backend/reviews/tests/test_admin.py | 84 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 436777e7ec..c7d2d27fb5 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -349,6 +349,12 @@ def _review_grants_recap_view(self, request, review_session): change_message=f"[Review Session] Grant status updated: pending_status changed from '{grant.status}' to '{grant.pending_status}'.", ) + # The frontend may send reimbursement categories as checked by default, + # so they're always passed to the backend. However, if the grant is not approved, + # we don't need to consider reimbursements at all and can skip all reimbursement logic. + if grant.pending_status != Grant.Status.approved: + continue + approved_reimbursement_categories = ( approved_reimbursement_categories_decisions.get(grant.id, []) ) diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 949cdc841c..18c61f5c8c 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -573,3 +573,87 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): object_id=str(accommodation_category.id), change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", ).exists() + + +def test_save_review_grants_waiting_list_does_not_create_reimbursments(rf, mocker): + mock_messages = mocker.patch("reviews.admin.messages") + + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + # Create reimbursement categories + travel_category = GrantReimbursementCategoryFactory( + conference=conference, + travel=True, + max_amount=Decimal("500"), + ) + ticket_category = GrantReimbursementCategoryFactory( + conference=conference, + ticket=True, + max_amount=Decimal("100"), + ) + accommodation_category = GrantReimbursementCategoryFactory( + conference=conference, + accommodation=True, + max_amount=Decimal("200"), + ) + + # Create review session for grants + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=0) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=1) + + grant_1 = GrantFactory(conference=conference, status=Grant.Status.pending) + grant_2 = GrantFactory(conference=conference, status=Grant.Status.pending) + + post_data = { + f"decision-{grant_1.id}": Grant.Status.waiting_list, + f"reimbursementcategory-{grant_1.id}": [ + str(ticket_category.id), + str(travel_category.id), + ], + f"decision-{grant_2.id}": Grant.Status.waiting_list_maybe, + f"reimbursementcategory-{grant_2.id}": [ + str(ticket_category.id), + str(travel_category.id), + str(accommodation_category.id), + ], + } + + request = rf.post("/", data=post_data) + request.user = user + + admin = ReviewSessionAdmin(ReviewSession, AdminSite()) + response = admin._review_grants_recap_view(request, review_session) + + # Should redirect after successful save + assert response.status_code == 302 + assert ( + response.url + == f"/admin/reviews/reviewsession/{review_session.id}/review/recap/" + ) + + # Refresh grants from database + grant_1.refresh_from_db() + grant_2.refresh_from_db() + + # Verify grants were updated with pending_status + assert grant_1.pending_status == Grant.Status.waiting_list + assert grant_2.pending_status == Grant.Status.waiting_list_maybe + + # Verify GrantReimbursement objects were created + assert grant_1.reimbursements.count() == 0 + assert grant_2.reimbursements.count() == 0 + + # Verify log entries were created + assert ( + LogEntry.objects.filter(object_id=grant_1.id).count() == 1 + ) # 1 pending_status change, 2 reimbursement additions + assert ( + LogEntry.objects.filter(object_id=grant_2.id).count() == 1 + ) # 1 pending_status change, 3 reimbursement additions + mock_messages.success.assert_called_once() From bdf4a5d6f9e7c37e44e174f33434da11383cf4ff Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 18 Jan 2026 15:22:49 +0000 Subject: [PATCH 18/22] Add audit log entries for bulk actions on grants --- backend/custom_admin/admin.py | 37 +++++++++++++++++++++++++++ backend/grants/tests/test_admin.py | 40 ++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/backend/custom_admin/admin.py b/backend/custom_admin/admin.py index a1fe84fb7c..a832dc40ad 100644 --- a/backend/custom_admin/admin.py +++ b/backend/custom_admin/admin.py @@ -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 @@ -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." + ), + ) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 748f8d9efe..6bff5d6dca 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -586,7 +586,7 @@ def test_mark_rejected_and_send_email(rf, mocker, admin_user): ).exists() -def test_confirm_pending_status_action(rf): +def test_confirm_pending_status_action(rf, admin_user): grant_1 = GrantFactory( status=Grant.Status.pending, pending_status=Grant.Status.confirmed, @@ -611,6 +611,7 @@ def test_confirm_pending_status_action(rf): ) request = rf.get("/") + request.user = admin_user confirm_pending_status( None, request, Grant.objects.filter(id__in=[grant_1.id, grant_2.id, grant_3.id]) ) @@ -628,11 +629,28 @@ def test_confirm_pending_status_action(rf): assert grant_2.pending_status is None assert grant_3.pending_status is None + # Verify audit log entries were created correctly + assert LogEntry.objects.filter( + object_id=grant_1.id, + change_message="[Bulk Admin Action] Status changed from 'pending' to 'confirmed'.", + ).exists() + assert LogEntry.objects.filter( + object_id=grant_2.id, + change_message="[Bulk Admin Action] Status changed from 'rejected' to 'waiting_list'.", + ).exists() + assert LogEntry.objects.filter( + object_id=grant_3.id, + change_message="[Bulk Admin Action] Status changed from 'waiting_list' to 'waiting_list_maybe'.", + ).exists() + # Left out from the action assert grant_4.status == Grant.Status.waiting_list_maybe + assert not LogEntry.objects.filter( + object_id=grant_4.id, + ).exists() -def test_reset_pending_status_back_to_status_action(rf): +def test_reset_pending_status_back_to_status_action(rf, admin_user): grant_1 = GrantFactory( status=Grant.Status.pending, pending_status=Grant.Status.confirmed, @@ -657,6 +675,7 @@ def test_reset_pending_status_back_to_status_action(rf): ) request = rf.get("/") + request.user = admin_user reset_pending_status_back_to_status( None, request, Grant.objects.filter(id__in=[grant_1.id, grant_2.id, grant_3.id]) ) @@ -675,9 +694,26 @@ def test_reset_pending_status_back_to_status_action(rf): assert grant_3.status == Grant.Status.waiting_list assert grant_3.pending_status is None + # Verify audit log entries were created correctly + assert LogEntry.objects.filter( + object_id=grant_1.id, + change_message="[Bulk Admin Action] pending_status reset from 'confirmed' to None.", + ).exists() + assert LogEntry.objects.filter( + object_id=grant_2.id, + change_message="[Bulk Admin Action] pending_status reset from 'waiting_list' to None.", + ).exists() + assert LogEntry.objects.filter( + object_id=grant_3.id, + change_message="[Bulk Admin Action] pending_status reset from 'waiting_list_maybe' to None.", + ).exists() + # Left out from the action assert grant_4.status == Grant.Status.waiting_list_maybe assert grant_4.pending_status == Grant.Status.confirmed + assert not LogEntry.objects.filter( + object_id=grant_4.id, + ).exists() def test_delete_reimbursement_from_admin_logs_audit_log_entry(rf, admin_user): From ae42d15457cdcf91e7d937b8dcb96b02c6bec516 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 18 Jan 2026 15:47:34 +0000 Subject: [PATCH 19/22] Fix tests for review session admin --- backend/reviews/tests/test_admin.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 18c61f5c8c..15deda0051 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -472,7 +472,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert grant_1.reimbursements.count() == 0 - assert LogEntry.objects.count() == 3 + assert LogEntry.objects.count() == 4 for reimbursement in grant_1.reimbursements.all(): assert LogEntry.objects.filter( user=user, @@ -557,20 +557,20 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): reimbursement.category for reimbursement in grant_1.reimbursements.all() } == {ticket_category} - assert LogEntry.objects.count() == 4 + assert LogEntry.objects.count() == 3 assert LogEntry.objects.filter( user=user, - object_id__in=[str(grant_1.id)], - change_message=f"[Review Session] Grant status updated: pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", + object_id=grant_1.id, + change_message="[Review Session] Grant status updated: pending_status changed from 'approved' to 'None'.", ).exists() assert LogEntry.objects.filter( user=user, - object_id=str(travel_category.id), + object_id=grant_1.id, change_message=f"[Review Session] Reimbursement removed: {travel_category.name}", ).exists() assert LogEntry.objects.filter( user=user, - object_id=str(accommodation_category.id), + object_id=grant_1.id, change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", ).exists() From 2af19832bba8f53605b416614bcd37bf1f5ab9cb Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 18 Jan 2026 16:14:15 +0000 Subject: [PATCH 20/22] Avoid logging pending_status change when is not changed --- backend/reviews/admin.py | 27 ++++++---- backend/reviews/tests/test_admin.py | 80 ++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index c7d2d27fb5..2713f55f79 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -296,16 +296,23 @@ def _review_grants_recap_view(self, request, review_session): review_session.conference.grants.filter(id__in=decisions.keys()).all() ) + grants_with_pending_status_changes = {} for grant in grants: decision = decisions[grant.id] if decision not in Grant.REVIEW_SESSION_STATUSES_OPTIONS: continue + original_pending_status = grant.pending_status if decision != grant.status: grant.pending_status = decision elif decision == grant.status: grant.pending_status = None + if grant.pending_status != original_pending_status: + grants_with_pending_status_changes[grant.id] = ( + original_pending_status + ) + # if there are grant reimbursements and the decision is not approved, delete them all if grant.reimbursements.exists(): approved_reimbursement_categories = ( @@ -343,11 +350,15 @@ def _review_grants_recap_view(self, request, review_session): "pending_status", ] ) - create_change_admin_log_entry( - request.user, - grant, - change_message=f"[Review Session] Grant status updated: pending_status changed from '{grant.status}' to '{grant.pending_status}'.", - ) + if grant.id in grants_with_pending_status_changes: + original_pending_status = grants_with_pending_status_changes[ + grant.id + ] + create_change_admin_log_entry( + request.user, + grant, + change_message=f"[Review Session] Grant status updated: pending_status changed from '{original_pending_status}' to '{grant.pending_status}'.", + ) # The frontend may send reimbursement categories as checked by default, # so they're always passed to the backend. However, if the grant is not approved, @@ -381,12 +392,6 @@ def _review_grants_recap_view(self, request, review_session): grant, change_message=f"[Review Session] Reimbursement {reimbursement.category.name} added.", ) - else: - create_change_admin_log_entry( - request.user, - grant, - change_message=f"[Review Session] Reimbursement {reimbursement.category.name} updated.", - ) messages.success( request, "Decisions saved. Check the Grants Summary for more info." diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index 15deda0051..f29703e5f9 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -375,7 +375,7 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) assert LogEntry.objects.filter( user=user, object_id__in=[str(grant_1.id), str(grant_2.id)], - change_message=f"[Review Session] Grant status updated: pending_status changed from '{Grant.Status.pending}' to '{Grant.Status.approved}'.", + change_message="[Review Session] Grant status updated: pending_status changed from 'None' to 'approved'.", ).exists() assert LogEntry.objects.filter( user=user, @@ -557,21 +557,24 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): reimbursement.category for reimbursement in grant_1.reimbursements.all() } == {ticket_category} - assert LogEntry.objects.count() == 3 + # Verify log entries were created + assert LogEntry.objects.count() == 2 assert LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message="[Review Session] Grant status updated: pending_status changed from 'approved' to 'None'.", + change_message=f"[Review Session] Reimbursement removed: {travel_category.name}", ).exists() assert LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message=f"[Review Session] Reimbursement removed: {travel_category.name}", + change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", ).exists() - assert LogEntry.objects.filter( + + # pending_status change should not be logged because the grant status is not changed + assert not LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", + change_message="[Review Session] Grant status updated: pending_status changed from 'approved' to 'None'.", ).exists() @@ -657,3 +660,68 @@ def test_save_review_grants_waiting_list_does_not_create_reimbursments(rf, mocke LogEntry.objects.filter(object_id=grant_2.id).count() == 1 ) # 1 pending_status change, 3 reimbursement additions mock_messages.success.assert_called_once() + + +def test_save_review_grants_two_times_does_not_create_duplicate_log_entries(rf, mocker): + mocker.patch("reviews.admin.messages") + + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + # Create reimbursement categories + travel_category = GrantReimbursementCategoryFactory( + conference=conference, + travel=True, + max_amount=Decimal("500"), + ) + ticket_category = GrantReimbursementCategoryFactory( + conference=conference, + ticket=True, + max_amount=Decimal("100"), + ) + accommodation_category = GrantReimbursementCategoryFactory( + conference=conference, + accommodation=True, + max_amount=Decimal("200"), + ) + + # Create review session for grants + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + status=ReviewSession.Status.COMPLETED, + ) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=0) + AvailableScoreOptionFactory(review_session=review_session, numeric_value=1) + + grant_1 = GrantFactory(conference=conference, status=Grant.Status.pending) + post_data = { + f"decision-{grant_1.id}": Grant.Status.approved, + f"reimbursementcategory-{grant_1.id}": [ + str(ticket_category.id), + str(travel_category.id), + str(accommodation_category.id), + ], + } + request = rf.post("/", data=post_data) + request.user = user + + admin = ReviewSessionAdmin(ReviewSession, AdminSite()) + admin._review_grants_recap_view(request, review_session) # First save + admin._review_grants_recap_view(request, review_session) # Second save + + grant_1.refresh_from_db() + + assert grant_1.reimbursements.count() == 3 + assert { + reimbursement.category for reimbursement in grant_1.reimbursements.all() + } == {ticket_category, travel_category, accommodation_category} + + for e in LogEntry.objects.all(): + print(e.change_message) + assert LogEntry.objects.count() == 4 + assert LogEntry.objects.filter( + user=user, + object_id=grant_1.id, + change_message="[Review Session] Grant status updated: pending_status changed from 'None' to 'approved'.", + ).exists() From bd2b2e28c2a5b8e4bb9ff7eea5314147bfe1b8d9 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sun, 18 Jan 2026 16:46:59 +0000 Subject: [PATCH 21/22] Add transaction to grant update mutation --- backend/api/grants/mutations.py | 5 +++-- backend/reviews/tests/test_admin.py | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 8b0451a45c..74ce54bc08 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -290,6 +290,7 @@ def send_grant(self, info: Info, input: SendGrantInput) -> SendGrantResult: return instance @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult: request = info.context.request @@ -306,10 +307,10 @@ def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult for attr, value in asdict(input).items(): setattr(instance, attr, value) - create_change_admin_log_entry(request.user, instance, "Grant updated") - 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, diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index f29703e5f9..eb4257e6f4 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -717,8 +717,6 @@ def test_save_review_grants_two_times_does_not_create_duplicate_log_entries(rf, reimbursement.category for reimbursement in grant_1.reimbursements.all() } == {ticket_category, travel_category, accommodation_category} - for e in LogEntry.objects.all(): - print(e.change_message) assert LogEntry.objects.count() == 4 assert LogEntry.objects.filter( user=user, From d83b221a54627ba5581ab2077dff4190e3485652 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:57:08 +0000 Subject: [PATCH 22/22] Improve audit log message consistency - Add periods at the end of all audit log messages for consistency - Simplify [Review Session] pending status message to match other patterns - Update all related tests to reflect the new message formats Co-authored-by: Ester Beltrami --- backend/api/grants/mutations.py | 6 ++-- backend/api/grants/tests/test_send_grant.py | 2 +- .../api/grants/tests/test_send_grant_reply.py | 4 +-- backend/api/grants/tests/test_update_grant.py | 2 +- backend/grants/admin.py | 28 +++++++++---------- backend/grants/tests/test_admin.py | 28 +++++++++---------- backend/reviews/admin.py | 6 ++-- backend/reviews/tests/test_admin.py | 12 ++++---- 8 files changed, 44 insertions(+), 44 deletions(-) diff --git a/backend/api/grants/mutations.py b/backend/api/grants/mutations.py index 74ce54bc08..4ff0d1cccb 100644 --- a/backend/api/grants/mutations.py +++ b/backend/api/grants/mutations.py @@ -283,7 +283,7 @@ def send_grant(self, info: Info, input: SendGrantInput) -> SendGrantResult: }, ) - create_addition_admin_log_entry(request.user, instance, "Grant created") + create_addition_admin_log_entry(request.user, instance, "Grant created.") # hack because we return django models instance.__strawberry_definition__ = Grant.__strawberry_definition__ @@ -309,7 +309,7 @@ def update_grant(self, info: Info, input: UpdateGrantInput) -> UpdateGrantResult instance.save() - create_change_admin_log_entry(request.user, instance, "Grant updated") + create_change_admin_log_entry(request.user, instance, "Grant updated.") Participant.objects.update_or_create( user_id=request.user.id, @@ -346,7 +346,7 @@ def send_grant_reply( grant.save() create_change_admin_log_entry( - request.user, grant, f"Grantee has replied with status {grant.status}" + request.user, grant, f"Grantee has replied with status {grant.status}." ) admin_url = request.build_absolute_uri(grant.get_admin_url()) diff --git a/backend/api/grants/tests/test_send_grant.py b/backend/api/grants/tests/test_send_grant.py index 1a2a362151..b09bed14f4 100644 --- a/backend/api/grants/tests/test_send_grant.py +++ b/backend/api/grants/tests/test_send_grant.py @@ -126,7 +126,7 @@ def test_send_grant( 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" + assert log_entry.change_message == "Grant created." # Verify that the correct email template was used and email was sent emails_sent = sent_emails() diff --git a/backend/api/grants/tests/test_send_grant_reply.py b/backend/api/grants/tests/test_send_grant_reply.py index 3b1dd09b9d..44c637b9b6 100644 --- a/backend/api/grants/tests/test_send_grant_reply.py +++ b/backend/api/grants/tests/test_send_grant_reply.py @@ -90,7 +90,7 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user): assert LogEntry.objects.filter( user=user, object_id=grant.id, - change_message="Grantee has replied with status confirmed", + change_message="Grantee has replied with status confirmed.", ).exists() @@ -109,7 +109,7 @@ def test_status_is_updated_when_reply_is_refused(graphql_client, user): assert LogEntry.objects.filter( user=user, object_id=grant.id, - change_message="Grantee has replied with status refused", + change_message="Grantee has replied with status refused.", ).exists() diff --git a/backend/api/grants/tests/test_update_grant.py b/backend/api/grants/tests/test_update_grant.py index c87c13dcb0..cc8b2efacb 100644 --- a/backend/api/grants/tests/test_update_grant.py +++ b/backend/api/grants/tests/test_update_grant.py @@ -135,7 +135,7 @@ def test_update_grant(graphql_client, user): 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" + assert log_entry.change_message == "Grant updated." def test_cannot_update_a_grant_if_user_is_not_owner( diff --git a/backend/grants/admin.py b/backend/grants/admin.py index 7e87623623..8e9f732941 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -221,7 +221,7 @@ def send_reply_emails(modeladmin, request, queryset): create_change_admin_log_entry( request.user, grant, - change_message="Sent Approved reply email to applicant", + change_message="Sent Approved reply email to applicant.", ) messages.info(request, f"Sent Approved reply email to {grant.name}") @@ -233,7 +233,7 @@ def send_reply_emails(modeladmin, request, queryset): create_change_admin_log_entry( request.user, grant, - change_message="Sent Waiting List reply email to applicant", + change_message="Sent Waiting List reply email to applicant.", ) messages.info(request, f"Sent Waiting List reply email to {grant.name}") @@ -242,7 +242,7 @@ def send_reply_emails(modeladmin, request, queryset): create_change_admin_log_entry( request.user, grant, - change_message="Sent Rejected reply email to applicant", + change_message="Sent Rejected reply email to applicant.", ) messages.info(request, f"Sent Rejected reply email to {grant.name}") @@ -270,7 +270,7 @@ def send_grant_reminder_to_waiting_for_confirmation(modeladmin, request, queryse create_change_admin_log_entry( request.user, grant, - change_message="Sent Approved reminder email to applicant", + change_message="Sent Approved reminder email to applicant.", ) messages.info(request, f"Grant reminder sent to {grant.name}") @@ -290,7 +290,7 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset): create_change_admin_log_entry( request.user, grant, - change_message="Sent Waiting List update reply email to applicant", + change_message="Sent Waiting List update reply email to applicant.", ) messages.info(request, f"Sent Waiting List update reply email to {grant.name}") @@ -325,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( @@ -346,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) @@ -380,7 +380,7 @@ def mark_rejected_and_send_email(modeladmin, request, queryset): create_change_admin_log_entry( request.user, grant, - change_message=f"Status changed from '{old_status}' to 'rejected' and rejection email sent", + change_message=f"Status changed from '{old_status}' to 'rejected' and rejection email sent.", ) send_grant_reply_rejected_email.delay(grant_id=grant.id) @@ -441,7 +441,7 @@ def delete_model(self, request, obj): create_change_admin_log_entry( request.user, obj.grant, - change_message=f"Reimbursement removed: {obj.category.name}", + change_message=f"Reimbursement removed: {obj.category.name}.", ) super().delete_model(request, obj) @@ -456,7 +456,7 @@ def delete_model(self, request, obj): create_change_admin_log_entry( request.user, obj.grant, - change_message=f"Reimbursement removed: {obj.category.name}", + change_message=f"Reimbursement removed: {obj.category.name}.", ) super().delete_model(request, obj) @@ -573,19 +573,19 @@ def save_model(self, request, obj, form, change): create_change_admin_log_entry( request.user, obj, - change_message=f"Status changed from '{obj._original_status}' to '{obj.status}'", + 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}'", + 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", + change_message="Grant created.", ) super().save_model(request, obj, form, change) diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 6bff5d6dca..e5662e3c27 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -165,7 +165,7 @@ def test_send_reply_emails_approved_set_deadline_in_fourteen_days( assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Approved reply email to applicant", + change_message="Sent Approved reply email to applicant.", ).exists() @@ -189,7 +189,7 @@ def test_send_reply_emails_waiting_list(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Waiting List reply email to applicant", + change_message="Sent Waiting List reply email to applicant.", ).exists() @@ -213,7 +213,7 @@ def test_send_reply_emails_waiting_list_maybe(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Waiting List reply email to applicant", + change_message="Sent Waiting List reply email to applicant.", ).exists() @@ -237,7 +237,7 @@ def test_send_reply_emails_rejected(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Rejected reply email to applicant", + change_message="Sent Rejected reply email to applicant.", ).exists() @@ -269,7 +269,7 @@ def test_send_grant_reminder_to_waiting_for_confirmation(rf, mocker, admin_user) assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Approved reminder email to applicant", + change_message="Sent Approved reminder email to applicant.", ).exists() @@ -299,7 +299,7 @@ def test_send_reply_email_waiting_list_update(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Sent Waiting List update reply email to applicant", + change_message="Sent Waiting List update reply email to applicant.", ).exists() @@ -341,12 +341,12 @@ def test_create_grant_vouchers(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant_1.id, - change_message="Created voucher for this grant", + change_message="Created voucher for this grant.", ).exists() assert LogEntry.objects.filter( user=admin_user, object_id=grant_2.id, - change_message="Created voucher for this grant", + change_message="Created voucher for this grant.", ).exists() @@ -577,12 +577,12 @@ def test_mark_rejected_and_send_email(rf, mocker, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant1.id, - change_message="Status changed from 'waiting_list' to 'rejected' and rejection email sent", + change_message="Status changed from 'waiting_list' to 'rejected' and rejection email sent.", ).exists() assert LogEntry.objects.filter( user=admin_user, object_id=grant2.id, - change_message="Status changed from 'waiting_list_maybe' to 'rejected' and rejection email sent", + change_message="Status changed from 'waiting_list_maybe' to 'rejected' and rejection email sent.", ).exists() @@ -738,7 +738,7 @@ def test_delete_reimbursement_from_admin_logs_audit_log_entry(rf, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message=f"Reimbursement removed: {reimbursement.category.name}", + change_message=f"Reimbursement removed: {reimbursement.category.name}.", ).exists() @@ -753,7 +753,7 @@ def test_save_grant_in_admin_logs_audit_log_entry(rf, admin_user): assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Grant created", + change_message="Grant created.", ).exists() @@ -769,7 +769,7 @@ def test_save_grant_in_admin_logs_audit_log_entry_for_status_change(rf, admin_us assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Status changed from 'pending' to 'confirmed'", + change_message="Status changed from 'pending' to 'confirmed'.", ).exists() @@ -787,5 +787,5 @@ def test_save_grant_in_admin_logs_audit_log_entry_for_pending_status_change( assert LogEntry.objects.filter( user=admin_user, object_id=grant.id, - change_message="Pending status changed from 'pending' to 'confirmed'", + change_message="Pending status changed from 'pending' to 'confirmed'.", ).exists() diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 2713f55f79..e388423bdd 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -325,7 +325,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}.", ) reimbursement.delete() else: @@ -338,7 +338,7 @@ def _review_grants_recap_view(self, request, review_session): create_deletion_admin_log_entry( request.user, grant, - change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}.", ) to_delete.delete() @@ -357,7 +357,7 @@ def _review_grants_recap_view(self, request, review_session): create_change_admin_log_entry( request.user, grant, - change_message=f"[Review Session] Grant status updated: pending_status changed from '{original_pending_status}' to '{grant.pending_status}'.", + change_message=f"[Review Session] Pending status changed from '{original_pending_status}' to '{grant.pending_status}'.", ) # The frontend may send reimbursement categories as checked by default, diff --git a/backend/reviews/tests/test_admin.py b/backend/reviews/tests/test_admin.py index eb4257e6f4..3b151a5fc2 100644 --- a/backend/reviews/tests/test_admin.py +++ b/backend/reviews/tests/test_admin.py @@ -375,7 +375,7 @@ def test_save_review_grants_updates_grant_and_creates_reimbursements(rf, mocker) assert LogEntry.objects.filter( user=user, object_id__in=[str(grant_1.id), str(grant_2.id)], - change_message="[Review Session] Grant status updated: pending_status changed from 'None' to 'approved'.", + change_message="[Review Session] Pending status changed from 'None' to 'approved'.", ).exists() assert LogEntry.objects.filter( user=user, @@ -477,7 +477,7 @@ def test_save_review_grants_update_grants_status_to_rejected_removes_reimburseme assert LogEntry.objects.filter( user=user, object_id=str(reimbursement.id), - change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}", + change_message=f"[Review Session] Reimbursement removed: {reimbursement.category.name}.", ).exists() @@ -562,19 +562,19 @@ def test_save_review_grants_modify_reimbursements(rf, mocker): assert LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message=f"[Review Session] Reimbursement removed: {travel_category.name}", + change_message=f"[Review Session] Reimbursement removed: {travel_category.name}.", ).exists() assert LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}", + change_message=f"[Review Session] Reimbursement removed: {accommodation_category.name}.", ).exists() # pending_status change should not be logged because the grant status is not changed assert not LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message="[Review Session] Grant status updated: pending_status changed from 'approved' to 'None'.", + change_message="[Review Session] Pending status changed from 'approved' to 'None'.", ).exists() @@ -721,5 +721,5 @@ def test_save_review_grants_two_times_does_not_create_duplicate_log_entries(rf, assert LogEntry.objects.filter( user=user, object_id=grant_1.id, - change_message="[Review Session] Grant status updated: pending_status changed from 'None' to 'approved'.", + change_message="[Review Session] Pending status changed from 'None' to 'approved'.", ).exists()