Skip to content

Commit d8dca8a

Browse files
authored
Merge pull request #3688 from bcgov/hotfix/alex-fuel-code-expiry-251230
Hotfix: Fuel code expiry - 3682
2 parents 841c9fa + cd5b3a3 commit d8dca8a

File tree

8 files changed

+263
-10
lines changed

8 files changed

+263
-10
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Add expiry_notification_sent_at column to fuel_code table
2+
3+
Revision ID: 5897848af3c9
4+
Revises: 7ca5289dff90
5+
Create Date: 2025-12-30 09:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "5897848af3c9"
14+
down_revision = "7ca5289dff90"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# Add column to track when expiry notification was sent
21+
# This prevents duplicate notifications from being sent on subsequent scheduler runs
22+
op.add_column(
23+
"fuel_code",
24+
sa.Column(
25+
"expiry_notification_sent_at",
26+
sa.DateTime(timezone=True),
27+
nullable=True,
28+
comment="Timestamp when expiry notification email was sent. Used to prevent duplicate notifications.",
29+
),
30+
)
31+
32+
33+
def downgrade() -> None:
34+
op.drop_column("fuel_code", "expiry_notification_sent_at")

backend/lcfs/db/models/fuel/FuelCode.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515

1616

1717
class FuelCode(BaseModel, Auditable, EffectiveDates, Versioning):
18+
"""
19+
Contains a list of all fuel codes.
20+
21+
The expiry_notification_sent_at field tracks when an expiry notification
22+
email was sent for this fuel code. This prevents duplicate notifications
23+
from being sent on subsequent scheduler runs.
24+
"""
1825
__tablename__ = "fuel_code"
1926
__table_args__ = {"comment": "Contains a list of all of fuel codes"}
2027

@@ -87,6 +94,11 @@ class FuelCode(BaseModel, Auditable, EffectiveDates, Versioning):
8794
)
8895
former_company = Column(String(500), nullable=True, comment="Former company")
8996
notes = Column(String(1000), nullable=True, comment="Notes")
97+
expiry_notification_sent_at = Column(
98+
DateTime(timezone=True),
99+
nullable=True,
100+
comment="Timestamp when expiry notification email was sent. Used to prevent duplicate notifications.",
101+
)
90102

91103
# Define the relationships
92104
fuel_code_status = relationship(

backend/lcfs/scripts/tasks/fuel_code_expiry.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ async def notify_expiring_fuel_code(db_session: AsyncSession):
6363
# Send emails to each contact
6464
success_count = 0
6565
total_emails = len(email_groups)
66+
# Track fuel codes that were successfully notified
67+
notified_fuel_code_ids: list[int] = []
6668

6769
base_context = {
6870
"subject": "Important Notice from the Deputy Director: Upcoming Expiry of BC LCFS Fuel Codes",
@@ -93,6 +95,9 @@ async def notify_expiring_fuel_code(db_session: AsyncSession):
9395
)
9496
if sent:
9597
success_count += 1
98+
# Collect fuel code IDs that were successfully notified
99+
for code in comp_data["codes"]:
100+
notified_fuel_code_ids.append(code.fuel_code_id)
96101
logger.info(
97102
f"Successfully sent to {contact_email} | {company_name}"
98103
)
@@ -105,6 +110,14 @@ async def notify_expiring_fuel_code(db_session: AsyncSession):
105110
logger.error(
106111
f"Error sending to {contact_email} | {company_name}: {e}"
107112
)
113+
114+
# Mark successfully notified fuel codes to prevent duplicate notifications
115+
if notified_fuel_code_ids:
116+
logger.info(
117+
f"Marking {len(notified_fuel_code_ids)} fuel codes as notified"
118+
)
119+
await fuel_code_repo.mark_fuel_codes_notified(notified_fuel_code_ids)
120+
108121
logger.info(
109122
f"Sent fuel code expiry notifications to {success_count}/{total_emails} contacts"
110123
)

backend/lcfs/scripts/tests/test_fuel_code_expiry.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
# Mock fuel code data structure
1515
MockFuelCode = namedtuple(
16-
"MockFuelCode", ["fuel_code", "contact_email", "expiry_date", "company"]
16+
"MockFuelCode", ["fuel_code_id", "fuel_code", "contact_email", "expiry_date", "company"]
1717
)
1818

1919

@@ -28,18 +28,21 @@ def mock_fuel_codes():
2828
"""Mock fuel code data"""
2929
return [
3030
MockFuelCode(
31+
fuel_code_id=1,
3132
fuel_code="FC001",
3233
contact_email="user1@example.com",
3334
expiry_date=date.today() + timedelta(days=30),
3435
company="Company A",
3536
),
3637
MockFuelCode(
38+
fuel_code_id=2,
3739
fuel_code="FC002",
3840
contact_email="user2@example.com",
3941
expiry_date=date.today() + timedelta(days=60),
4042
company="Company B",
4143
),
4244
MockFuelCode(
45+
fuel_code_id=3,
4346
fuel_code="FC003",
4447
contact_email="user1@example.com", # Same email as FC001
4548
expiry_date=date.today() + timedelta(days=45),
@@ -53,18 +56,21 @@ def mock_fuel_codes_invalid_emails():
5356
"""Mock fuel code data with invalid emails"""
5457
return [
5558
MockFuelCode(
59+
fuel_code_id=1,
5660
fuel_code="FC001",
5761
contact_email="invalid-email",
5862
expiry_date=date.today() + timedelta(days=30),
5963
company="Company A",
6064
),
6165
MockFuelCode(
66+
fuel_code_id=2,
6267
fuel_code="FC002",
6368
contact_email="user@valid.com",
6469
expiry_date=date.today() + timedelta(days=60),
6570
company="Company B",
6671
),
6772
MockFuelCode(
73+
fuel_code_id=3,
6874
fuel_code="FC003",
6975
contact_email="",
7076
expiry_date=date.today() + timedelta(days=45),
@@ -96,6 +102,7 @@ async def test_notify_expiring_fuel_code_success(
96102
# Setup mocks
97103
mock_repo = AsyncMock()
98104
mock_repo.get_expiring_fuel_codes.return_value = mock_fuel_codes
105+
mock_repo.mark_fuel_codes_notified = AsyncMock()
99106
mock_repo_class.return_value = mock_repo
100107

101108
mock_email_repo = AsyncMock()
@@ -117,6 +124,12 @@ async def test_notify_expiring_fuel_code_success(
117124
mock_email_service.send_fuel_code_expiry_notifications.call_count == 2
118125
)
119126

127+
# Verify that fuel codes are marked as notified after successful email sending
128+
mock_repo.mark_fuel_codes_notified.assert_called_once()
129+
# All 3 fuel code IDs should be marked (1, 2, 3)
130+
notified_ids = mock_repo.mark_fuel_codes_notified.call_args[0][0]
131+
assert sorted(notified_ids) == [1, 2, 3]
132+
120133
@pytest.mark.anyio
121134
@patch(
122135
"lcfs.scripts.tasks.fuel_code_expiry.settings.feature_fuel_code_expiry_email",
@@ -194,6 +207,7 @@ async def test_notify_expiring_fuel_code_email_service_failure(
194207

195208
mock_repo = AsyncMock()
196209
mock_repo.get_expiring_fuel_codes.return_value = mock_fuel_codes
210+
mock_repo.mark_fuel_codes_notified = AsyncMock()
197211
mock_repo_class.return_value = mock_repo
198212

199213
mock_email_repo = AsyncMock()
@@ -206,6 +220,8 @@ async def test_notify_expiring_fuel_code_email_service_failure(
206220
result = await notify_expiring_fuel_code(mock_db_session)
207221

208222
assert result is False
223+
# No fuel codes should be marked as notified when all emails fail
224+
mock_repo.mark_fuel_codes_notified.assert_not_called()
209225

210226
@pytest.mark.anyio
211227
async def test_notify_expiring_fuel_code_partial_success(
@@ -222,13 +238,14 @@ async def test_notify_expiring_fuel_code_partial_success(
222238

223239
mock_repo = AsyncMock()
224240
mock_repo.get_expiring_fuel_codes.return_value = mock_fuel_codes
241+
mock_repo.mark_fuel_codes_notified = AsyncMock()
225242
mock_repo_class.return_value = mock_repo
226243

227244
mock_email_repo = AsyncMock()
228245
mock_email_repo_class.return_value = mock_email_repo
229246

230247
mock_email_service = AsyncMock()
231-
# First call succeeds, second fails
248+
# First call succeeds (Company A with codes 1,3), second fails (Company B with code 2)
232249
mock_email_service.send_fuel_code_expiry_notifications.side_effect = [
233250
True,
234251
False,
@@ -239,6 +256,12 @@ async def test_notify_expiring_fuel_code_partial_success(
239256

240257
assert result is True # Should return True if at least one email succeeded
241258

259+
# Only fuel codes from successful email should be marked as notified
260+
mock_repo.mark_fuel_codes_notified.assert_called_once()
261+
notified_ids = mock_repo.mark_fuel_codes_notified.call_args[0][0]
262+
# Only codes 1 and 3 (Company A) should be marked since that email succeeded
263+
assert sorted(notified_ids) == [1, 3]
264+
242265
@pytest.mark.anyio
243266
@patch(
244267
"lcfs.scripts.tasks.fuel_code_expiry.settings.feature_fuel_code_expiry_email",
@@ -304,10 +327,10 @@ def test_group_codes_invalid_emails_fallback(self, mock_fuel_codes_invalid_email
304327
# Should have 2 email addresses (valid one + fallback)
305328
assert len(result) == 2
306329
assert "user@valid.com" in result
307-
assert "tfrs@gov.bc.ca" in result # fallback email
330+
assert "lcfs@gov.bc.ca" in result # fallback email
308331

309332
# Fallback should have 2 companies (invalid-email and empty email cases)
310-
fallback_data = result["tfrs@gov.bc.ca"]
333+
fallback_data = result["lcfs@gov.bc.ca"]
311334
assert len(fallback_data["companies"]) == 2
312335
assert "Company A" in fallback_data["companies"]
313336
assert "Company C" in fallback_data["companies"]
@@ -373,6 +396,7 @@ async def test_notify_expiring_fuel_code_invalid_emails(
373396
mock_repo.get_expiring_fuel_codes.return_value = (
374397
mock_fuel_codes_invalid_emails
375398
)
399+
mock_repo.mark_fuel_codes_notified = AsyncMock()
376400
mock_repo_class.return_value = mock_repo
377401

378402
mock_email_repo = AsyncMock()
@@ -385,11 +409,16 @@ async def test_notify_expiring_fuel_code_invalid_emails(
385409
result = await notify_expiring_fuel_code(mock_db_session)
386410

387411
assert result is True
388-
# Should have 3 email groups: valid email + tfrs@gov.bc.ca for 2 invalid companies
412+
# Should have 3 email groups: valid email + lcfs@gov.bc.ca for 2 invalid companies
389413
assert (
390414
mock_email_service.send_fuel_code_expiry_notifications.call_count == 3
391415
)
392416

417+
# Verify all fuel codes are marked as notified
418+
mock_repo.mark_fuel_codes_notified.assert_called_once()
419+
notified_ids = mock_repo.mark_fuel_codes_notified.call_args[0][0]
420+
assert sorted(notified_ids) == [1, 2, 3]
421+
393422
def test_edge_cases(self):
394423
"""Test edge cases for email validation"""
395424
edge_cases = [
@@ -435,9 +464,9 @@ async def test_end_to_end_workflow(self, mock_db_session):
435464
# Create test data
436465
test_codes = [
437466
MockFuelCode(
438-
"FC001", "valid@example.com", date.today() + timedelta(days=30), "Company A"
467+
1, "FC001", "valid@example.com", date.today() + timedelta(days=30), "Company A"
439468
),
440-
MockFuelCode("FC002", "invalid-email", date.today() + timedelta(days=60), "Company B"),
469+
MockFuelCode(2, "FC002", "invalid-email", date.today() + timedelta(days=60), "Company B"),
441470
]
442471

443472
with patch(
@@ -451,6 +480,7 @@ async def test_end_to_end_workflow(self, mock_db_session):
451480
# Setup mocks
452481
mock_repo = AsyncMock()
453482
mock_repo.get_expiring_fuel_codes.return_value = test_codes
483+
mock_repo.mark_fuel_codes_notified = AsyncMock()
454484
mock_repo_class.return_value = mock_repo
455485

456486
mock_email_repo = AsyncMock()
@@ -488,6 +518,11 @@ async def test_end_to_end_workflow(self, mock_db_session):
488518
assert "contact_email" in context
489519
assert "expiry_count" in context
490520

521+
# Verify fuel codes are marked as notified
522+
mock_repo.mark_fuel_codes_notified.assert_called_once()
523+
notified_ids = mock_repo.mark_fuel_codes_notified.call_args[0][0]
524+
assert sorted(notified_ids) == [1, 2]
525+
491526
@pytest.mark.anyio
492527
@patch(
493528
"lcfs.scripts.tasks.fuel_code_expiry.settings.feature_fuel_code_expiry_email",
@@ -507,6 +542,7 @@ async def test_logging_behavior(self, mock_db_session, mock_fuel_codes):
507542

508543
mock_repo = AsyncMock()
509544
mock_repo.get_expiring_fuel_codes.return_value = mock_fuel_codes
545+
mock_repo.mark_fuel_codes_notified = AsyncMock()
510546
mock_repo_class.return_value = mock_repo
511547

512548
mock_email_repo = AsyncMock()
@@ -520,5 +556,5 @@ async def test_logging_behavior(self, mock_db_session, mock_fuel_codes):
520556

521557
# Verify that info logging occurred
522558
assert (
523-
mock_logger.info.call_count >= 3
524-
) # At least start, found codes, and completion messages
559+
mock_logger.info.call_count >= 4
560+
) # At least start, found codes, marking notified, and completion messages

backend/lcfs/tests/fuel_code/test_fuel_code_repo.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,3 +887,21 @@ async def test_get_fuel_code_history(fuel_code_repo, mock_db):
887887

888888
result = await fuel_code_repo.get_fuel_code_history(1, version=0)
889889
assert result == history
890+
891+
892+
@pytest.mark.anyio
893+
async def test_mark_fuel_codes_notified(fuel_code_repo, mock_db):
894+
"""Test marking fuel codes as notified"""
895+
fuel_code_ids = [1, 2, 3]
896+
897+
await fuel_code_repo.mark_fuel_codes_notified(fuel_code_ids)
898+
899+
mock_db.execute.assert_called_once()
900+
901+
902+
@pytest.mark.anyio
903+
async def test_mark_fuel_codes_notified_empty_list(fuel_code_repo, mock_db):
904+
"""Test marking fuel codes as notified with empty list does nothing"""
905+
await fuel_code_repo.mark_fuel_codes_notified([])
906+
907+
mock_db.execute.assert_not_called()

0 commit comments

Comments
 (0)