Skip to content

Commit 834bc9d

Browse files
authored
Merge pull request #3845 from bcgov/338-2-internal-earned-credit-email
338 2 internal earned credit email
2 parents 340f67d + ed3e95a commit 834bc9d

File tree

6 files changed

+187
-22
lines changed

6 files changed

+187
-22
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 5.1.10 on 2025-09-18 21:57
2+
3+
from django.db import migrations
4+
5+
6+
def create_credits_requested_email_template(apps, schema_editor):
7+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
8+
EmailNotificationTemplate.objects.create(
9+
name='Notice of Credits Requested',
10+
subject='BCIERS Notification – Earned Credits Requested ',
11+
body='''
12+
<p style="text-align: center;">Province of British Columbia</p>
13+
<p style="text-align: center;">B.C. Industrial Emissions Reporting System (BCIERS)</p>
14+
<br>
15+
<p>Hi there,</p>
16+
<br>
17+
<p><b>{{operator_legal_name}}</b> has requested issuance of earned credits on behalf of <b>{{operation_name}}</b>. Please log-in to <a href="https://industrialemissions.gov.bc.ca/onboarding">BCIERS</a> to see the details of this request.
18+
<br>
19+
</p>
20+
<p><em>Please do not reply to this email. This email is auto-generated and sent from an unmonitored address.</em></p>
21+
<br>
22+
<p>Best Regards,</p>
23+
<p>Ministry of Energy and Climate Solutions</p>
24+
<p>Email: <a href="mailto:GHGRegulator@gov.bc.ca">GHGRegulator@gov.bc.ca</a></p>
25+
<p>Website: <a href="https://www2.gov.bc.ca/gov/content/environment/climate-change/industry/bc-output-based-pricing-system">BC
26+
Government Website Link</a>
27+
</p>
28+
''',
29+
)
30+
31+
32+
def reverse_credits_requested_email_template(apps, schema_editor):
33+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
34+
EmailNotificationTemplate.objects.filter(name='Notice of Credits Requested').delete()
35+
36+
37+
class Migration(migrations.Migration):
38+
dependencies = [
39+
('common', '0083_update_dashboard_data'),
40+
]
41+
42+
operations = [
43+
migrations.RunPython(
44+
create_credits_requested_email_template,
45+
reverse_credits_requested_email_template,
46+
elidable=True,
47+
)
48+
]

bc_obps/compliance/emails.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from registration.models.operator import Operator
55
from registration.models.user_operator import UserOperator
66
from reporting.models.report import Report
7-
from service.email.email_service import EmailService
7+
from service.email.email_service import GHG_REGULATOR_EMAIL, EmailService
88
from service.email.utils import Recipient
99
import logging
1010
from django.conf import settings
@@ -17,6 +17,28 @@
1717
email_service = EmailService()
1818

1919

20+
def _send_email_or_raise(
21+
template: EmailNotificationTemplate, email_context: Dict[str, object], recipient_emails: List[str]
22+
) -> None:
23+
try:
24+
25+
response_json = email_service.send_email_by_template(template, email_context, recipient_emails)
26+
# Create an email notification record to store transaction and message IDs
27+
if response_json:
28+
email_service.create_email_notification_record(
29+
response_json['txId'],
30+
response_json['messages'][0]['msgId'],
31+
recipient_emails,
32+
template.pk,
33+
)
34+
except Exception as exc:
35+
logger.error(f'Exception sending {template} email to recipients - {str(exc)}')
36+
# If we're in the local environment, we don't need to send emails, so we shouldn't raise an error if they fail
37+
environment = settings.ENVIRONMENT
38+
if environment != 'local':
39+
raise # raise exception because we want to use this function as a retryable task
40+
41+
2042
def _send_email_to_operators_approved_users_or_raise(
2143
operator: Operator, template: EmailNotificationTemplate, email_context: Dict[str, object]
2244
) -> None:
@@ -30,24 +52,8 @@ def _send_email_to_operators_approved_users_or_raise(
3052
)
3153

3254
if recipients:
33-
try:
34-
recipient_emails = [recipient.email_address for recipient in recipients]
35-
36-
response_json = email_service.send_email_by_template(template, email_context, recipient_emails)
37-
# Create an email notification record to store transaction and message IDs
38-
if response_json:
39-
email_service.create_email_notification_record(
40-
response_json['txId'],
41-
response_json['messages'][0]['msgId'],
42-
recipient_emails,
43-
template.pk,
44-
)
45-
except Exception as exc:
46-
logger.error(f'Exception sending {template} email to recipients - {str(exc)}')
47-
# If we're in the local environment, we don't need to send emails, so we shouldn't raise an error if they fail
48-
environment = settings.ENVIRONMENT
49-
if environment != 'local':
50-
raise # raise exception because we want to use this function as a retryable task
55+
recipient_emails = [recipient.email_address for recipient in recipients]
56+
_send_email_or_raise(template, email_context, recipient_emails)
5157

5258

5359
def send_notice_of_earned_credits_generated_email(compliance_earned_credit_id: int) -> None:
@@ -107,3 +113,26 @@ def send_notice_of_obligation_generated_email(report_id: int) -> None:
107113
}
108114

109115
_send_email_to_operators_approved_users_or_raise(report.operator, template, email_context)
116+
117+
118+
def send_notice_of_credits_requested_generated_email(compliance_earned_credit_id: int) -> None:
119+
"""
120+
Sends an email to ghg regulator, notifying that an operation has requested earned credits. We only send this email when in prod so we don't confuse internal users.
121+
122+
Args:
123+
compliance_earned_credit_id: The ID of the ComplianceEarnedCredit instance for which to send notification emails.
124+
"""
125+
if settings.ENVIRONMENT != 'prod':
126+
return
127+
earned_credit = ComplianceEarnedCredit.objects.get(id=compliance_earned_credit_id)
128+
report = earned_credit.compliance_report_version.compliance_report.report
129+
template = EmailNotificationTemplateService.get_template_by_name('Notice of Credits Requested')
130+
131+
email_context: Dict[str, object] = {
132+
"operator_legal_name": report.operator.legal_name,
133+
"operation_name": report.operation.name,
134+
}
135+
136+
recipient_emails = [GHG_REGULATOR_EMAIL]
137+
138+
_send_email_or_raise(template, email_context, recipient_emails)

bc_obps/compliance/service/earned_credits_service.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from common.exceptions import UserError
66
from compliance.service.bc_carbon_registry.project_service import BCCarbonRegistryProjectService
77
from compliance.service.bc_carbon_registry.credit_issuance_service import BCCarbonRegistryCreditIssuanceService
8-
from compliance.tasks import retryable_send_notice_of_earned_credits_email
8+
from compliance.tasks import (
9+
retryable_send_notice_of_earned_credits_email,
10+
retryable_send_notice_of_credits_requested_email,
11+
)
912
from registration.models.user import User
1013

1114

@@ -216,6 +219,6 @@ def update_earned_credit(
216219
cls._handle_cas_director_update(earned_credit, update_payload)
217220
else:
218221
raise UserError("This user is not authorized to update earned credit")
219-
220222
earned_credit.refresh_from_db()
223+
retryable_send_notice_of_credits_requested_email.execute(earned_credit.id)
221224
return earned_credit

bc_obps/compliance/tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from compliance.service.elicensing.elicensing_obligation_service import ElicensingObligationService
33
from compliance.service.automated_process.automated_process_service import AutomatedProcessService
44
from compliance.emails import (
5+
send_notice_of_credits_requested_generated_email,
56
send_notice_of_earned_credits_generated_email,
67
send_notice_of_no_obligation_no_credits_generated_email,
78
send_notice_of_obligation_generated_email,
@@ -45,6 +46,12 @@
4546
max_retries=5,
4647
retry_delay_minutes=10,
4748
)
49+
retryable_send_notice_of_credits_requested_email = create_retryable(
50+
func=send_notice_of_credits_requested_generated_email,
51+
tag="credits_requested_email_notifications",
52+
max_retries=5,
53+
retry_delay_minutes=10,
54+
)
4855

4956
###################
5057
# Scheduled tasks #

bc_obps/compliance/tests/service/test_compliance_earned_credits_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ def test_create_earned_credits_record(self, mock_send_email):
5757
assert result.earned_credits_amount == 100
5858
mock_send_email.execute.assert_called_once_with(result.pk)
5959

60-
def test_update_earned_credit_industry_user_success(self):
60+
@patch('compliance.service.earned_credits_service.retryable_send_notice_of_credits_requested_email')
61+
def test_update_earned_credit_industry_user_success(self, mock_send_email):
6162
# Arrange
6263
earned_credit = self.earned_credit_no_bccr_fields
6364
earned_credit.issuance_status = ComplianceEarnedCredit.IssuanceStatus.CREDITS_NOT_ISSUED
@@ -76,6 +77,7 @@ def test_update_earned_credit_industry_user_success(self):
7677
assert earned_credit.bccr_holding_account_id == self.bccr_holding_account_id
7778
assert earned_credit.issuance_status == ComplianceEarnedCredit.IssuanceStatus.ISSUANCE_REQUESTED
7879
assert result == earned_credit
80+
mock_send_email.execute.assert_called_once_with(result.pk)
7981

8082
def test_update_earned_credit_industry_user_invalid_status(self):
8183
# Arrange

bc_obps/compliance/tests/test_emails.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from service.data_access_service.email_template_service import EmailNotificationTemplateService
1010
from model_bakery import baker
1111
from compliance.emails import (
12+
_send_email_or_raise,
1213
_send_email_to_operators_approved_users_or_raise,
14+
send_notice_of_credits_requested_generated_email,
1315
send_notice_of_earned_credits_generated_email,
1416
send_notice_of_no_obligation_no_credits_generated_email,
1517
send_notice_of_obligation_generated_email,
@@ -18,6 +20,7 @@
1820
pytestmark = pytest.mark.django_db
1921
email_service = EmailService()
2022
SEND_EMAIL_TO_OPERATORS_USERS_PATH = 'compliance.emails._send_email_to_operators_approved_users_or_raise'
23+
SEND_EMAIL_OR_RAISE_PATH = 'compliance.emails._send_email_or_raise'
2124

2225

2326
def mock_email_service(mocker):
@@ -29,6 +32,48 @@ def mock_email_service(mocker):
2932

3033

3134
class TestComplianceEmailHelpers:
35+
def test_send_email_or_raise_success(self, mocker):
36+
mocked_send_email = mock_email_service(mocker)
37+
template_instance = EmailNotificationTemplateService.get_template_by_name('Notice of Earned Credits Generated')
38+
recipients = ['email@email.com', 'email2@email.com']
39+
context = {"foo": "bar"}
40+
_send_email_or_raise(template_instance, context, recipients)
41+
# Assert that send_email_by_template is called exactly once
42+
mocked_send_email.assert_called_once_with(
43+
template_instance,
44+
context,
45+
recipients,
46+
)
47+
48+
# Assert that only one email notification record is created
49+
assert EmailNotification.objects.filter(template=template_instance).count() == 1
50+
51+
# Get the created email notification record
52+
email_notification = EmailNotification.objects.get(template=template_instance)
53+
54+
# Assert the notification record has the correct recipients
55+
assert set(email_notification.recipients_email) == set(recipients)
56+
57+
# Assert the notification record has the correct template
58+
assert email_notification.template == template_instance
59+
60+
# Assert that the notification record has transaction and message IDs
61+
assert email_notification.transaction_id is not None
62+
assert email_notification.message_id is not None
63+
64+
# Assert that all expected recipients are included in the single email
65+
assert len(email_notification.recipients_email) == 2
66+
67+
def test_send_email_or_raise_fail(self, mocker):
68+
69+
template_instance = EmailNotificationTemplateService.get_template_by_name('Notice of Earned Credits Generated')
70+
mocked_send_email = mock_email_service(mocker)
71+
72+
mocked_send_email.side_effect = Exception("Whoops")
73+
with patch('django.conf.settings.ENVIRONMENT', 'test'):
74+
with pytest.raises(Exception, match="Whoops"):
75+
_send_email_or_raise(template_instance, {"foo": "bar"}, ['email@email.com'])
76+
3277
def test_send_email_to_operators_approved_users_or_raise_success(self, mocker):
3378
# admin user - should receive email
3479
approved_user_operator = baker.make_recipe(
@@ -226,3 +271,34 @@ def test_obligation_email(self, mock_send_email_to_operators_approved_users_or_r
226271
template_instance,
227272
expected_context,
228273
)
274+
275+
@patch(SEND_EMAIL_OR_RAISE_PATH)
276+
def test_credits_requested_email(
277+
self,
278+
mock_send_email_or_raise,
279+
settings,
280+
):
281+
settings.ENVIRONMENT = 'prod'
282+
# Create a report with earned credits
283+
report = baker.make_recipe('reporting.tests.utils.report')
284+
compliance_report = baker.make_recipe('compliance.tests.utils.compliance_report', report=report)
285+
compliance_report_version = baker.make_recipe(
286+
'compliance.tests.utils.compliance_report_version', compliance_report=compliance_report
287+
)
288+
earned_credit = baker.make_recipe(
289+
'compliance.tests.utils.compliance_earned_credit',
290+
compliance_report_version=compliance_report_version,
291+
earned_credits_amount=100,
292+
)
293+
294+
template_instance = EmailNotificationTemplateService.get_template_by_name('Notice of Credits Requested')
295+
expected_context = {
296+
"operator_legal_name": report.operator.legal_name,
297+
"operation_name": report.operation.name,
298+
}
299+
300+
# Call the function with the earned credit id
301+
send_notice_of_credits_requested_generated_email(earned_credit.id)
302+
mock_send_email_or_raise.assert_called_once_with(
303+
template_instance, expected_context, ['GHGRegulator@gov.bc.ca']
304+
)

0 commit comments

Comments
 (0)