Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LCFS - Implement Email Notification Triggers in Backend for Subscribed Users #1226 #1402

Merged
merged 20 commits into from
Dec 12, 2024
Merged
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
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a better place to keep this in the backend? Or is it used in enough places to justify having it in base?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Alex for the review. Since this schema will be used across various services, it causes greenlet spawn error if placed inside the notifications schema, hence it's being placed inside the base.

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
Loading