Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion backend/api/grants/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
create_change_admin_log_entry,
)
from grants.models import Grant as GrantModel
from grants.tasks import get_name, notify_new_grant_reply_slack
from grants.tasks import (
create_and_send_voucher_to_grantee,
get_name,
notify_new_grant_reply_slack,
)
from notifications.models import EmailTemplate, EmailTemplateIdentifier
from participants.models import Participant
from privacy_policy.record import record_privacy_policy_acceptance
Expand Down Expand Up @@ -342,9 +346,13 @@ def send_grant_reply(
if grant.status in (GrantModel.Status.pending, GrantModel.Status.rejected):
return SendGrantReplyError(message="You cannot reply to this grant")

old_status = grant.status
grant.status = input.status.to_grant_status()
grant.save()

if old_status != grant.status and grant.status == GrantModel.Status.confirmed:
create_and_send_voucher_to_grantee.delay(grant_id=grant.id)

create_change_admin_log_entry(
request.user, grant, f"Grantee has replied with status {grant.status}."
)
Expand Down
15 changes: 13 additions & 2 deletions backend/api/grants/tests/test_send_grant_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,12 @@ def test_user_cannot_reply_if_status_is_rejected(graphql_client, user):
)


def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
def test_status_is_updated_when_reply_is_confirmed(graphql_client, user, mocker):
graphql_client.force_login(user)
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
mock_voucher = mocker.patch(
"api.grants.mutations.create_and_send_voucher_to_grantee"
)

response = _send_grant_reply(graphql_client, grant, status="confirmed")

Expand All @@ -86,6 +89,8 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
grant.refresh_from_db()
assert grant.status == Grant.Status.confirmed

mock_voucher.delay.assert_called_once_with(grant_id=grant.id)

# Verify audit log entry was created correctly
assert LogEntry.objects.filter(
user=user,
Expand All @@ -94,9 +99,12 @@ def test_status_is_updated_when_reply_is_confirmed(graphql_client, user):
).exists()


def test_status_is_updated_when_reply_is_refused(graphql_client, user):
def test_status_is_updated_when_reply_is_refused(graphql_client, user, mocker):
graphql_client.force_login(user)
grant = GrantFactory(user_id=user.id, status=Grant.Status.waiting_for_confirmation)
mock_voucher = mocker.patch(
"api.grants.mutations.create_and_send_voucher_to_grantee"
)

response = _send_grant_reply(graphql_client, grant, status="refused")

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

# Verify voucher was not sent
mock_voucher.delay.assert_not_called()

# Verify audit log entry was created correctly
assert LogEntry.objects.filter(
user=user,
Expand Down
14 changes: 11 additions & 3 deletions backend/conferences/tasks.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import logging

from django.utils import timezone
from notifications.models import EmailTemplate, EmailTemplateIdentifier
from grants.tasks import get_name
import logging
from pycon.celery import app

logger = logging.getLogger(__name__)


@app.task
def send_conference_voucher_email(conference_voucher_id):
def send_conference_voucher_email(conference_voucher_id: int) -> None:
from conferences.models import ConferenceVoucher

conference_voucher = ConferenceVoucher.objects.get(id=conference_voucher_id)
if conference_voucher.voucher_email_sent_at is not None:
logger.info(
"Voucher email already sent for conference_voucher %s, skipping",
conference_voucher_id,
)
return

conference = conference_voucher.conference

user = conference_voucher.user
Expand All @@ -31,4 +39,4 @@ def send_conference_voucher_email(conference_voucher_id):
)

conference_voucher.voucher_email_sent_at = timezone.now()
conference_voucher.save()
conference_voucher.save(update_fields=["voucher_email_sent_at"])
18 changes: 18 additions & 0 deletions backend/conferences/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,21 @@ def test_send_conference_voucher_email(voucher_type, sent_emails):
assert conference_voucher.voucher_email_sent_at == datetime(
2020, 10, 10, 10, 0, 0, tzinfo=timezone.utc
)


def test_send_conference_voucher_email_skips_when_already_sent(sent_emails):
user = UserFactory()
conference_voucher = ConferenceVoucherFactory(
user=user,
voucher_type=ConferenceVoucher.VoucherType.GRANT,
voucher_code="GRANT99",
voucher_email_sent_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
EmailTemplateFactory(
conference=conference_voucher.conference,
identifier=EmailTemplateIdentifier.voucher_code,
)

send_conference_voucher_email(conference_voucher_id=conference_voucher.id)

assert sent_emails().count() == 0
68 changes: 40 additions & 28 deletions backend/grants/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from import_export.resources import ModelResource

from conferences.models.conference_voucher import ConferenceVoucher
from conferences.vouchers import create_conference_voucher
from countries import countries
from countries.filters import CountryFilter
from custom_admin.admin import (
Expand Down Expand Up @@ -49,6 +50,7 @@

logger = logging.getLogger(__name__)


EXPORT_GRANTS_FIELDS = (
"name",
"full_name",
Expand Down Expand Up @@ -299,18 +301,19 @@ def send_reply_email_waiting_list_update(modeladmin, request, queryset):
@validate_single_conference_selection
@transaction.atomic
def create_grant_vouchers(modeladmin, request, queryset):
conference = queryset.first().conference
existing_vouchers_by_user_id = {
voucher.user_id: voucher
for voucher in ConferenceVoucher.objects.for_conference(conference).filter(
user_id__in=queryset.values_list("user_id", flat=True),
)
}
grants_ordered = list(queryset.order_by("id").select_related("user", "conference"))
conference_id = grants_ordered[0].conference_id if grants_ordered else None
user_ids = {g.user_id for g in grants_ordered if g.user_id}

vouchers_to_create = []
vouchers_to_update = []
existing_by_user: Dict[int, ConferenceVoucher] = {}
if conference_id is not None and user_ids:
for voucher in ConferenceVoucher.objects.filter(
conference_id=conference_id,
user_id__in=user_ids,
):
existing_by_user[voucher.user_id] = voucher

for grant in queryset.order_by("id"):
for grant in grants_ordered:
if grant.status != Grant.Status.confirmed:
messages.error(
request,
Expand All @@ -319,45 +322,54 @@ def create_grant_vouchers(modeladmin, request, queryset):
)
continue

existing_voucher = existing_vouchers_by_user_id.get(grant.user_id)
if not grant.user_id:
messages.error(
request,
f"Grant for {grant.name} has no user linked; can't create a voucher.",
)
continue

if not existing_voucher:
existing = existing_by_user.get(grant.user_id)

if not existing:
new_voucher = create_conference_voucher(
conference=grant.conference,
user=grant.user,
voucher_type=ConferenceVoucher.VoucherType.GRANT,
)
existing_by_user[grant.user_id] = new_voucher
create_addition_admin_log_entry(
request.user,
grant,
change_message="Created voucher for this grant.",
)
continue

vouchers_to_create.append(
ConferenceVoucher(
conference_id=grant.conference_id,
user_id=grant.user_id,
voucher_code=ConferenceVoucher.generate_code(),
voucher_type=ConferenceVoucher.VoucherType.GRANT,
)
)
if existing.voucher_type in (
ConferenceVoucher.VoucherType.GRANT,
ConferenceVoucher.VoucherType.SPEAKER,
):
continue

if existing_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
if existing.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
messages.warning(
request,
f"Grant for {grant.name} already has a Co-Speaker voucher. Upgrading to a Grant voucher.",
f"Grant for {grant.name} already has a Co-Speaker voucher. "
"Upgrading to a Grant voucher.",
)
create_change_admin_log_entry(
request.user,
existing_voucher,
existing,
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.",
)
existing_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
vouchers_to_update.append(existing_voucher)

ConferenceVoucher.objects.bulk_create(vouchers_to_create, ignore_conflicts=True)
ConferenceVoucher.objects.bulk_update(vouchers_to_update, ["voucher_type"])
existing.voucher_type = ConferenceVoucher.VoucherType.GRANT
existing.voucher_email_sent_at = None
existing.save(update_fields=["voucher_type", "voucher_email_sent_at"])

messages.success(request, "Vouchers created!")

Expand Down
48 changes: 48 additions & 0 deletions backend/grants/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from django.conf import settings
from django.utils import timezone

from conferences.models.conference_voucher import ConferenceVoucher
from conferences.vouchers import create_conference_voucher
from grants.models import Grant
from integrations import slack
from notifications.models import EmailTemplate, EmailTemplateIdentifier
Expand All @@ -21,6 +23,52 @@ def get_name(user: User | None, fallback: str = "<no name specified>") -> str:
return user.full_name or user.name or user.username or fallback


@app.task
def create_and_send_voucher_to_grantee(*, grant_id: int) -> None:
from conferences.tasks import send_conference_voucher_email

grant = Grant.objects.select_related("user", "conference").get(pk=grant_id)
if grant.status != Grant.Status.confirmed:
return
if not grant.user_id:
return

user = grant.user
conference = grant.conference
conference_voucher = (
ConferenceVoucher.objects.for_conference(conference).for_user(user).first()
)

if conference_voucher:
if conference_voucher.voucher_type in (
ConferenceVoucher.VoucherType.GRANT,
ConferenceVoucher.VoucherType.SPEAKER,
):
logger.info(
"User %s already has a voucher for conference %s, not creating a new one",
user.id,
conference.id,
)
return
if conference_voucher.voucher_type == ConferenceVoucher.VoucherType.CO_SPEAKER:
conference_voucher.voucher_type = ConferenceVoucher.VoucherType.GRANT
conference_voucher.voucher_email_sent_at = None
conference_voucher.save(
update_fields=["voucher_type", "voucher_email_sent_at"]
)
send_conference_voucher_email.delay(
conference_voucher_id=conference_voucher.id
)
return

new_voucher = create_conference_voucher(
conference=conference,
user=user,
voucher_type=ConferenceVoucher.VoucherType.GRANT,
)
send_conference_voucher_email.delay(conference_voucher_id=new_voucher.id)


@app.task
def send_grant_reply_approved_email(*, grant_id: int, is_reminder: bool) -> None:
logger.info("Sending Reply APPROVED email for Grant %s", grant_id)
Expand Down
7 changes: 6 additions & 1 deletion backend/grants/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from conferences.models.conference_voucher import ConferenceVoucher
from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory
from grants.admin import (
confirm_pending_status,
GrantAdmin,
GrantReimbursementAdmin,
confirm_pending_status,
create_grant_vouchers,
mark_rejected_and_send_email,
reset_pending_status_back_to_status,
Expand Down Expand Up @@ -305,6 +305,7 @@ def test_send_reply_email_waiting_list_update(rf, mocker, admin_user):

def test_create_grant_vouchers(rf, mocker, admin_user):
mock_messages = mocker.patch("grants.admin.messages")
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})

conference = ConferenceFactory()

Expand Down Expand Up @@ -357,6 +358,7 @@ def test_create_grant_vouchers_with_existing_voucher_is_reused(
rf, mocker, admin_user, type
):
mock_messages = mocker.patch("grants.admin.messages")
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})

conference = ConferenceFactory()

Expand Down Expand Up @@ -407,6 +409,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored(
rf, mocker, type, admin_user
):
mock_messages = mocker.patch("grants.admin.messages")
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})

conference = ConferenceFactory()
other_conference = ConferenceFactory()
Expand Down Expand Up @@ -461,6 +464,7 @@ def test_create_grant_vouchers_with_voucher_from_other_conf_is_ignored(

def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_user):
mock_messages = mocker.patch("grants.admin.messages")
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})

conference = ConferenceFactory()

Expand Down Expand Up @@ -506,6 +510,7 @@ def test_create_grant_vouchers_co_speaker_voucher_is_upgraded(rf, mocker, admin_

def test_create_grant_vouchers_only_for_confirmed_grants(rf, mocker, admin_user):
mock_messages = mocker.patch("grants.admin.messages")
mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999})
conference = ConferenceFactory()
grant_1 = GrantFactory(
status=Grant.Status.refused,
Expand Down
Loading
Loading