Skip to content

Commit

Permalink
Merge pull request #1402 from bcgov/feat/prashanth-email-trigger-1226
Browse files Browse the repository at this point in the history
LCFS - Implement Email Notification Triggers in Backend for Subscribed Users #1226
  • Loading branch information
prv-proton authored Dec 12, 2024
2 parents 3c014c2 + c77f4c6 commit a583a79
Show file tree
Hide file tree
Showing 17 changed files with 432 additions and 52 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/docker-auto-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ jobs:
LCFS_REDIS_PORT: 6379
LCFS_REDIS_PASSWORD: development_only
APP_ENVIRONMENT: dev
LCFS_CHES_CLIENT_ID: mock_client_id
LCFS_CHES_CLIENT_SECRET: mock_client_secret
LCFS_CHES_AUTH_URL: http://mock_auth_url
LCFS_CHES_SENDER_EMAIL: [email protected]
LCFS_CHES_SENDER_NAME: Mock Notification System
LCFS_CHES_EMAIL_URL: http://mock_email_url

- name: Upload pytest results
if: always()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import json
from lcfs.web.api.email.repo import CHESEmailRepository
import pytest

from unittest.mock import patch
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from fastapi import FastAPI

Expand All @@ -16,6 +17,20 @@
)
from lcfs.services.s3.client import DocumentService

@pytest.fixture
def mock_email_repo():
return AsyncMock(spec=CHESEmailRepository)

@pytest.fixture
def mock_environment_vars():
with patch("lcfs.web.api.email.services.settings") as mock_settings:
mock_settings.ches_auth_url = "http://mock_auth_url"
mock_settings.ches_email_url = "http://mock_email_url"
mock_settings.ches_client_id = "mock_client_id"
mock_settings.ches_client_secret = "mock_client_secret"
mock_settings.ches_sender_email = "[email protected]"
mock_settings.ches_sender_name = "Mock Notification System"
yield mock_settings

# get_compliance_periods
@pytest.mark.anyio
Expand Down
27 changes: 26 additions & 1 deletion backend/lcfs/tests/compliance_report/test_update_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from fastapi import HTTPException
from lcfs.db.models.user.Role import RoleEnum
from lcfs.web.api.compliance_report.update_service import ComplianceReportUpdateService
from lcfs.web.api.notification.services import NotificationService
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from lcfs.db.models.compliance.ComplianceReport import ComplianceReport
Expand All @@ -24,6 +26,27 @@ def mock_user_has_roles():
yield mock


@pytest.fixture
def mock_notification_service():
mock_service = AsyncMock(spec=NotificationService)
with patch(
"lcfs.web.api.compliance_report.update_service.Depends",
return_value=mock_service
):
yield mock_service


@pytest.fixture
def mock_environment_vars():
with patch("lcfs.web.api.email.services.settings") as mock_settings:
mock_settings.ches_auth_url = "http://mock_auth_url"
mock_settings.ches_email_url = "http://mock_email_url"
mock_settings.ches_client_id = "mock_client_id"
mock_settings.ches_client_secret = "mock_client_secret"
mock_settings.ches_sender_email = "[email protected]"
mock_settings.ches_sender_name = "Mock Notification System"
yield mock_settings

# Mock for adjust_balance method within the OrganizationsService
@pytest.fixture
def mock_org_service():
Expand All @@ -35,7 +58,7 @@ def mock_org_service():
# update_compliance_report
@pytest.mark.anyio
async def test_update_compliance_report_status_change(
compliance_report_update_service, mock_repo
compliance_report_update_service, mock_repo, mock_notification_service
):
# Mock data
report_id = 1
Expand All @@ -55,6 +78,7 @@ async def test_update_compliance_report_status_change(
mock_repo.get_compliance_report_by_id.return_value = mock_report
mock_repo.get_compliance_report_status_by_desc.return_value = new_status
compliance_report_update_service.handle_status_change = AsyncMock()
compliance_report_update_service.notfn_service = mock_notification_service
mock_repo.update_compliance_report.return_value = mock_report

# Call the method
Expand All @@ -80,6 +104,7 @@ async def test_update_compliance_report_status_change(

assert mock_report.current_status == new_status
assert mock_report.supplemental_note == report_data.supplemental_note
mock_notification_service.send_notification.assert_called_once()


@pytest.mark.anyio
Expand Down
39 changes: 24 additions & 15 deletions backend/lcfs/tests/email/test_email_service.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from lcfs.web.api.base import NotificationTypeEnum
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from lcfs.web.api.email.repo import CHESEmailRepository
from lcfs.web.api.email.services import CHESEmailService
import os


@pytest.fixture
def mock_email_repo():
return AsyncMock(spec=CHESEmailRepository)


@pytest.fixture
def mock_environment_vars():
with patch("lcfs.web.api.email.services.settings") as mock_settings:
Expand All @@ -19,14 +22,15 @@ def mock_environment_vars():
mock_settings.ches_sender_name = "Mock Notification System"
yield mock_settings


@pytest.mark.anyio
async def test_send_notification_email_success(mock_email_repo, mock_environment_vars):
# Arrange
notification_type = "INITIATIVE_APPROVED"
notification_type = NotificationTypeEnum.BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT
notification_context = {
"subject": "Test Notification",
"user_name": "John Doe",
"message_body": "Test message content"
"message_body": "Test message content",
}
organization_id = 1

Expand All @@ -46,21 +50,24 @@ async def test_send_notification_email_success(mock_email_repo, mock_environment
# Assert
assert result is True
mock_email_repo.get_subscribed_user_emails.assert_called_once_with(
notification_type, organization_id
notification_type.value, organization_id # Ensure value is passed
)
service._render_email_template.assert_called_once_with(
notification_type, notification_context
notification_type.value, notification_context
)
service.send_email.assert_called_once()


@pytest.mark.anyio
async def test_send_notification_email_no_recipients(mock_email_repo, mock_environment_vars):
async def test_send_notification_email_no_recipients(
mock_email_repo, mock_environment_vars
):
# Arrange
notification_type = "INITIATIVE_APPROVED"
notification_type = NotificationTypeEnum.BCEID__TRANSFER__PARTNER_ACTIONS
notification_context = {
"subject": "Test Notification",
"user_name": "John Doe",
"message_body": "Test message content"
"message_body": "Test message content",
}
organization_id = 1

Expand All @@ -76,18 +83,19 @@ async def test_send_notification_email_no_recipients(mock_email_repo, mock_envir
# Assert
assert result is False
mock_email_repo.get_subscribed_user_emails.assert_called_once_with(
notification_type, organization_id
notification_type.value, organization_id # Ensure value is passed
)


@pytest.mark.anyio
async def test_get_ches_token_success(mock_environment_vars):
# Arrange
mock_token = "mock_access_token"
with patch('requests.post') as mock_post:
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": mock_token,
"expires_in": 3600
"access_token": mock_token,
"expires_in": 3600,
}
mock_post.return_value = mock_response

Expand All @@ -100,14 +108,15 @@ async def test_get_ches_token_success(mock_environment_vars):
assert token == mock_token
mock_post.assert_called_once()


@pytest.mark.anyio
async def test_get_ches_token_cached(mock_environment_vars):
# Arrange
with patch('requests.post') as mock_post:
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "initial_token",
"expires_in": 3600
"access_token": "initial_token",
"expires_in": 3600,
}
mock_post.return_value = mock_response

Expand All @@ -124,4 +133,4 @@ async def test_get_ches_token_cached(mock_environment_vars):

# Assert
assert first_token == second_token
mock_post.assert_not_called()
mock_post.assert_not_called()
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ async def test_update_initiative_agreement(service, mock_repo, mock_request):
mock_repo.get_initiative_agreement_by_id.return_value = mock_agreement
mock_repo.get_initiative_agreement_status_by_name.return_value = mock_status
mock_repo.update_initiative_agreement.return_value = mock_agreement
service.notfn_service = AsyncMock()

update_data = InitiativeAgreementUpdateSchema(
initiative_agreement_id=1,
Expand Down
18 changes: 11 additions & 7 deletions backend/lcfs/tests/transfer/test_transfer_services.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from lcfs.web.api.transfer.schema import TransferSchema
from datetime import date
from lcfs.db.models.transfer import Transfer
Expand Down Expand Up @@ -63,22 +63,26 @@ async def test_get_transfer_success(transfer_service, mock_transfer_repo):
@pytest.mark.anyio
async def test_create_transfer_success(transfer_service, mock_transfer_repo):
mock_transfer_repo.get_transfer_status_by_name.return_value = TransferStatus(
transfer_status_id=1, status="status"
transfer_status_id=1, status="Sent"
)
mock_transfer_repo.add_transfer_history.return_value = True

transfer_id = 1
transfer = TransferCreateSchema(
transfer_data = TransferCreateSchema(
transfer_id=transfer_id,
from_organization_id=1,
to_organization_id=2,
price_per_unit=5.75,
)
mock_transfer_repo.create_transfer.return_value = transfer
mock_transfer_repo.create_transfer.return_value = transfer_data

result = await transfer_service.create_transfer(transfer)
# Patch the _perform_notificaiton_call method
with patch.object(transfer_service, "_perform_notificaiton_call", AsyncMock()):
result = await transfer_service.create_transfer(transfer_data)

assert result.transfer_id == transfer_id
assert isinstance(result, TransferCreateSchema)
assert result.transfer_id == transfer_id
assert isinstance(result, TransferCreateSchema)
transfer_service._perform_notificaiton_call.assert_called_once()


@pytest.mark.anyio
Expand Down
22 changes: 22 additions & 0 deletions backend/lcfs/web/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,25 @@ async def lcfs_cache_key_builder(

# Return the cache key
return cache_key

class NotificationTypeEnum(Enum):
BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT"
BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL = "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL"
BCEID__TRANSFER__DIRECTOR_DECISION = "BCEID__TRANSFER__DIRECTOR_DECISION"
BCEID__TRANSFER__PARTNER_ACTIONS = "BCEID__TRANSFER__PARTNER_ACTIONS"
IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION = "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION"
IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION"
IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW"
IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST = "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST"
IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED = "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED"
IDIR_ANALYST__TRANSFER__RESCINDED_ACTION = "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION"
IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW = "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW"
IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION"
IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT"
IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW = "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW"
IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION = "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION"
IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION = "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION"
IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION = "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION"

def __str__(self):
return self.value
52 changes: 44 additions & 8 deletions backend/lcfs/web/api/compliance_report/update_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from fastapi import Depends, HTTPException, Request
from sqlalchemy.exc import InvalidRequestError
from lcfs.web.api.notification.schema import (
COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER,
NotificationMessageSchema,
NotificationRequestSchema,
)
from lcfs.web.api.notification.services import NotificationService

from lcfs.db.models.compliance.ComplianceReport import ComplianceReport
from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum
Expand All @@ -24,12 +29,14 @@ def __init__(
summary_service: ComplianceReportSummaryService = Depends(),
org_service: OrganizationsService = Depends(OrganizationsService),
trx_service: TransactionsService = Depends(TransactionsService),
notfn_service: NotificationService = Depends(NotificationService),
):
self.repo = repo
self.request = request
self.summary_service = summary_service
self.org_service = org_service
self.trx_service = trx_service
self.notfn_service = notfn_service

async def update_compliance_report(
self, report_id: int, report_data: ComplianceReportUpdateSchema
Expand All @@ -42,15 +49,34 @@ async def update_compliance_report(
f"Compliance report with ID {report_id} not found"
)

notifications = None
notification_data: NotificationMessageSchema = NotificationMessageSchema(
message=f"Compliance report {report.compliance_report_id} has been updated",
related_organization_id=report.organization_id,
origin_user_profile_id=self.request.user.user_profile_id,
)
# if we're just returning the compliance report back to either compliance manager or analyst,
# then neither history nor any updates to summary is required.
if report_data.status in RETURN_STATUSES:
status_has_changed = False
report_data.status = (
"Submitted"
if report_data.status == "Return to analyst"
else ComplianceReportStatusEnum.Recommended_by_analyst.value
notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get(
report_data.status
)
if report_data.status == "Return to analyst":
report_data.status = ComplianceReportStatusEnum.Submitted.value
notification_data.message = f"Compliance report {report.compliance_report_id} has been returned to analyst"
else:
report_data.status = (
ComplianceReportStatusEnum.Recommended_by_analyst.value
)

notification_data.message = f"Compliance report {report.compliance_report_id} has been returned by director"
notification_data.related_user_profile_id = [
h.user_profile.user_profile_id
for h in report.history
if h.status.status
== ComplianceReportStatusEnum.Recommended_by_analyst
][0]
else:
status_has_changed = report.current_status.status != getattr(
ComplianceReportStatusEnum, report_data.status.replace(" ", "_")
Expand All @@ -65,10 +91,21 @@ async def update_compliance_report(
updated_report = await self.repo.update_compliance_report(report)
if status_has_changed:
await self.handle_status_change(report, new_status.status)

notification_data.message = (
f"Compliance report {report.compliance_report_id} has been updated"
)
notifications = COMPLIANCE_REPORT_STATUS_NOTIFICATION_MAPPER.get(
new_status.status
)
# Add history record
await self.repo.add_compliance_report_history(report, self.request.user)

if notifications and isinstance(notifications, list):
await self.notfn_service.send_notification(
NotificationRequestSchema(
notification_types=notifications,
notification_data=notification_data,
)
)
return updated_report

async def handle_status_change(
Expand Down Expand Up @@ -182,7 +219,6 @@ async def handle_submitted_status(self, report: ComplianceReport):
# Update the report with the new summary
report.summary = new_summary


if report.summary.line_20_surplus_deficit_units != 0:
# Create a new reserved transaction for receiving organization
report.transaction = await self.org_service.adjust_balance(
Expand Down
Loading

0 comments on commit a583a79

Please sign in to comment.