Skip to content

Commit d485db6

Browse files
authored
SUBMIT-711:System - Submission Awaits Manager Review (to EAO Manager) (#797)
1 parent 3a4b58f commit d485db6

8 files changed

Lines changed: 299 additions & 4 deletions

File tree

submit-api/src/submit_api/services/submission_review.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from submit_api.services.consultation_record_service import ConsultationRecordService
1919
from submit_api.services.iem_service import IEMTermsOfEngagementService
2020
from submit_api.services.management_plan_service import ManagementPlanService
21+
from submit_api.services.package_version_service import PackageVersionService
22+
from submit_api.utils.constants import SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE
2123
from submit_api.utils.token_info import TokenInfo
2224

2325

@@ -132,6 +134,14 @@ def send_recommendation_to_manager(cls, item_id, session):
132134
item = cls._get_submission_item_by_id(item_id)
133135
item.status = cls._get_awaiting_manager_review_status(item)
134136
cls._update_package_status(item.package_id, session)
137+
# Notify MPT Managers when MP or Consultation Record requires Manager's approval
138+
if item.type.name in (
139+
SubmissionItemType.MANAGEMENT_PLAN_FORM.value,
140+
SubmissionItemType.CONSULTATION_RECORD.value,
141+
):
142+
PackageVersionService.create_email_queue(
143+
item.package_id, SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE
144+
)
135145
current_app.logger.info(f"Recommendation sent to manager for item {item_id}.")
136146
return item
137147

submit-api/src/submit_api/utils/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@
2222
MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE = 'management_plan_update_request_created.html'
2323
NEW_USER_INVITATION_EMAIL_TEMPLATE = 'new_user_invitation.html'
2424
MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE = 'management_plan_submission_notify_staff.html'
25+
SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE = 'submission_awaiting_manager_approval.html'
2526
MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE = 'resubmission_request.html'

submit-cron/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ class _Config(): # pylint: disable=too-few-public-methods
9595
# TODO API client wont need user management roles in keycloak.
9696
KEYCLOAK_ADMIN_USERNAME = os.getenv('KEYCLOAK_ADMIN_USERNAME')
9797
KEYCLOAK_ADMIN_SECRET = os.getenv('MET_ADMIN_CLIENT_SECRET')
98+
# Keycloak client/secret for emailer only (EAO_MANAGER group lookup)
99+
KEYCLOAK_EMAILER_CLIENT = os.getenv('KEYCLOAK_EMAILER_CLIENT')
100+
KEYCLOAK_EMAILER_SECRET = os.getenv('KEYCLOAK_EMAILER_SECRET')
101+
102+
CONNECT_TIMEOUT = int(os.getenv('CONNECT_TIMEOUT', 60))
98103

99104
CHES_TOKEN_ENDPOINT = os.getenv('CHES_TOKEN_ENDPOINT')
100105
CHES_CLIENT_ID = os.getenv('CHES_CLIENT_ID')

submit-cron/sample.env

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ WEB_URL=
1717
# Sending email id
1818
SENDER_EMAIL=
1919

20+
# Staff notification
21+
STAFF_SUPPORT_MAIL_ID=
22+
2023
# Condition api url
2124
CONDITION_API_BASE_URL=
2225

2326
# Keycloak
2427
KEYCLOAK_BASE_URL=
2528
KEYCLOAK_REALM_NAME=
2629
KEYCLOAK_SERVICE_ACCOUNT_ID=
27-
KEYCLOAK_SERVICE_ACCOUNT_SECRET=
30+
KEYCLOAK_SERVICE_ACCOUNT_SECRET=
31+
KEYCLOAK_EMAILER_CLIENT=
32+
KEYCLOAK_EMAILER_SECRET=
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright © 2024 Province of British Columbia
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Keycloak admin functions – same pattern as submit-api KeycloakService."""
15+
import requests
16+
from flask import current_app
17+
18+
19+
# Same group path as submit-api (SUBMIT / EAO_MANAGER)
20+
EAO_MANAGER_GROUP_PATH = "SUBMIT/EAO_MANAGER"
21+
22+
23+
class KeycloakService:
24+
"""Keycloak admin API – same token and request pattern as submit-api."""
25+
26+
@staticmethod
27+
def _get_admin_token():
28+
"""Create an admin token (same as submit-api KeycloakService._get_admin_token)."""
29+
config = current_app.config
30+
base_url = config.get("KEYCLOAK_BASE_URL")
31+
realm = config.get("KEYCLOAK_REALM_NAME")
32+
admin_client_id = config.get("KEYCLOAK_EMAILER_CLIENT")
33+
admin_secret = config.get("KEYCLOAK_EMAILER_SECRET")
34+
timeout = int(config.get("CONNECT_TIMEOUT", 60))
35+
token_url = f"{base_url}/auth/realms/{realm}/protocol/openid-connect/token"
36+
37+
if not admin_client_id or not admin_secret:
38+
raise ValueError(
39+
"KEYCLOAK_EMAILER_CLIENT and KEYCLOAK_EMAILER_SECRET must be set in .env"
40+
)
41+
42+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
43+
# Use dict so requests form-encodes correctly (handles special chars in secret)
44+
data = {
45+
"client_id": admin_client_id,
46+
"grant_type": "client_credentials",
47+
"client_secret": admin_secret,
48+
}
49+
response = requests.post(
50+
token_url,
51+
data=data,
52+
headers=headers,
53+
timeout=timeout,
54+
)
55+
response.raise_for_status()
56+
return response.json().get("access_token")
57+
58+
@staticmethod
59+
def _request_keycloak(relative_url: str):
60+
"""GET request to Keycloak admin API (same URL pattern as submit-api)."""
61+
base_url = current_app.config.get("KEYCLOAK_BASE_URL")
62+
realm = current_app.config.get("KEYCLOAK_REALM_NAME")
63+
timeout = int(current_app.config.get("CONNECT_TIMEOUT", 60))
64+
admin_token = KeycloakService._get_admin_token()
65+
headers = {
66+
"Content-Type": "application/json",
67+
"Authorization": f"Bearer {admin_token}",
68+
}
69+
url = f"{base_url}/auth/admin/realms/{realm}/{relative_url}"
70+
response = requests.get(url, headers=headers, timeout=timeout)
71+
response.raise_for_status()
72+
return response
73+
74+
@staticmethod
75+
def get_groups(brief_representation: bool = False):
76+
"""Get all top-level groups."""
77+
response = KeycloakService._request_keycloak(
78+
f"groups?briefRepresentation={brief_representation}"
79+
)
80+
return response.json()
81+
82+
@staticmethod
83+
def get_sub_groups(group_id: str):
84+
"""Return the subgroups of given group."""
85+
response = KeycloakService._request_keycloak(f"groups/{group_id}/children")
86+
return response.json()
87+
88+
@staticmethod
89+
def get_group_id_by_path(group_path: str) -> str:
90+
"""Find a Keycloak group by full path (e.g. 'SUBMIT/EAO_MANAGER') and return its ID."""
91+
segments = group_path.strip("/").split("/")
92+
current_groups = KeycloakService.get_groups(brief_representation=True)
93+
current_group = None
94+
95+
for segment in segments:
96+
matched = next((g for g in current_groups if g["name"] == segment), None)
97+
if not matched:
98+
raise ValueError(f"Group segment '{segment}' not found.")
99+
current_group = matched
100+
current_groups = KeycloakService.get_sub_groups(current_group["id"])
101+
102+
return current_group["id"]
103+
104+
@staticmethod
105+
def get_members_for_group(group_id: str):
106+
"""Get the members of a group (Keycloak user objects with email, etc.)."""
107+
response = KeycloakService._request_keycloak(f"groups/{group_id}/members")
108+
return response.json()
109+
110+
@classmethod
111+
def get_eao_manager_emails(cls) -> list:
112+
"""Return email addresses of SUBMIT/EAO_MANAGER group members (same as submit-api flow)."""
113+
try:
114+
group_id = cls.get_group_id_by_path(EAO_MANAGER_GROUP_PATH)
115+
members = cls.get_members_for_group(group_id)
116+
return [m.get("email") for m in members if m.get("email")]
117+
except (ValueError, requests.RequestException):
118+
return []

submit-cron/src/submit_cron/services/mail_service.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from submit_api.utils.constants import (
1111
MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE,
1212
MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE, MANAGEMENT_PLAN_UPDATE_REQUEST_CREATED_EMAIL_TEMPLATE,
13-
NEW_USER_INVITATION_EMAIL_TEMPLATE)
13+
NEW_USER_INVITATION_EMAIL_TEMPLATE, SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE)
1414

1515
from submit_cron.models import db
1616
from submit_cron.services.ches_service import ChesApiService
@@ -50,7 +50,8 @@ def _get_email_processor(cls, email_entry: EmailQueue) -> callable:
5050
MANAGEMENT_PLAN_RESUBMISSION_REQUEST_EMAIL_TEMPLATE: cls._process_resubmission_request_email,
5151
# staff email uses the same content, but just a different template..so reusing the same method passing template name
5252
MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE: partial(cls._process_package_submission_email, template_name=MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE),
53-
NEW_USER_INVITATION_EMAIL_TEMPLATE: cls._process_new_user_invitation_email
53+
NEW_USER_INVITATION_EMAIL_TEMPLATE: cls._process_new_user_invitation_email,
54+
SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE: cls._process_awaiting_manager_approval_email,
5455
}
5556
template = email_entry.template_name
5657
if template not in email_processors:
@@ -115,6 +116,27 @@ def _process_resubmission_request_email(email_entry: EmailQueue):
115116
email_entry.sent_at = datetime.utcnow()
116117
db.session.commit()
117118

119+
@staticmethod
120+
def _process_awaiting_manager_approval_email(email_entry: EmailQueue):
121+
"""Process email notifying MPT Managers that a package awaits Manager's approval."""
122+
from submit_cron.services.keycloak_service import KeycloakService
123+
package_id = email_entry.entity_id
124+
package: PackageModel = db.session.get(PackageModel, package_id)
125+
if not package:
126+
raise BadRequestError(f"Package with ID {package_id} not found.")
127+
manager_emails = KeycloakService.get_eao_manager_emails()
128+
if not manager_emails:
129+
raise BadRequestError(
130+
"No EAO_MANAGER group members with email found (check Keycloak admin config)"
131+
)
132+
email_details = PackageSubmissionEmailService.prepare_awaiting_manager_approval_email(
133+
package, manager_emails
134+
)
135+
EmailService.send_email(email_details)
136+
email_entry.status = EmailStatus.SENT.value
137+
email_entry.sent_at = datetime.utcnow()
138+
db.session.commit()
139+
118140
@staticmethod
119141
def _process_new_user_invitation_email(email_entry: EmailQueue):
120142
"""Process email entry for new user invitation."""

submit-cron/src/submit_cron/services/package_submission_email_service.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
from submit_api.models.project import Project as ProjectModel
99
from submit_api.models.submission import SubmissionType
1010
from submit_api.models.user import User as UserModel
11+
from submit_api.enums.item_status import ItemStatus
12+
from submit_api.models.submission_review_entry import SubmissionReviewEntryType
1113
from submit_api.utils.constants import (
12-
MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE)
14+
MANAGEMENT_PLAN_SUBMISSION_CONFIRMATION_EMAIL_TEMPLATE, MANAGEMENT_PLAN_SUBMISSION_NOTIFY_STAFF_EMAIL_TEMPLATE,
15+
SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE)
1316

1417
from submit_cron.models import db
1518
from submit_cron.utils import constants
@@ -69,6 +72,58 @@ def prepare_package_submission_email_confirmation(cls, package: PackageModel, te
6972

7073
return email_details
7174

75+
@classmethod
76+
def prepare_awaiting_manager_approval_email(cls, package: PackageModel, manager_emails: list) -> EmailDetails:
77+
"""Prepare email notifying MPT Managers that a package has been reviewed and awaits Manager's approval."""
78+
sender_email = cls.get_email_sender_for_package_type(package.type.name)
79+
if not sender_email:
80+
sender_email = current_app.config.get('SENDER_EMAIL', '')
81+
if not sender_email:
82+
raise BadRequestError(f"Sender email not found for package type: {package.type.name}")
83+
account_project = cls._get_account_project_by_id(package.account_project_id)
84+
project = cls._get_project_by_id(account_project.project_id)
85+
if not project:
86+
raise BadRequestError(f"Project not found for account project ID: {account_project.id}")
87+
team_member_name = cls._get_reviewer_name_for_awaiting_manager_package(package)
88+
subject = f"Submission awaiting Manager approval - {project.name} - {package.name}"
89+
email_details = EmailDetails(
90+
template_name=SUBMISSION_AWAITING_MANAGER_APPROVAL_EMAIL_TEMPLATE,
91+
body_args={
92+
'package_name': package.name,
93+
'project_name': project.name,
94+
'team_member_name': team_member_name,
95+
},
96+
subject=subject,
97+
sender=sender_email,
98+
recipients=manager_emails,
99+
)
100+
return email_details
101+
102+
@staticmethod
103+
def _get_reviewer_name_for_awaiting_manager_package(package: PackageModel) -> str:
104+
"""Get the name of the team member who sent the package to Manager (from review STAFF_RECOMMENDATION)."""
105+
from submit_api.models.submission_review_entry import SubmissionReviewEntry
106+
awaiting_statuses = (
107+
ItemStatus.MP_AWAITING_MANAGER_APPROVAL,
108+
ItemStatus.CC_AWAITING_MANAGER_APPROVAL,
109+
)
110+
for item in package.items:
111+
if item.status not in awaiting_statuses:
112+
continue
113+
review = getattr(item, 'review', None) or next((r for r in item.reviews if r.active), None)
114+
if not review:
115+
continue
116+
entry = SubmissionReviewEntry.get_review_entry_by_id_and_type(
117+
review.id, SubmissionReviewEntryType.STAFF_RECOMMENDATION
118+
)
119+
if not entry or not entry.updated_by:
120+
continue
121+
user = entry.updated_by_user
122+
if user and getattr(user, 'staff_user', None) and user.staff_user:
123+
return user.staff_user.full_name or entry.updated_by
124+
return entry.updated_by
125+
return 'A team member'
126+
72127
@staticmethod
73128
def get_email_sender_for_package_type(package_type: str) -> str:
74129
"""Get the email sender for the package type."""
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Submission Awaiting Manager's Approval</title>
6+
<style>
7+
body {
8+
font-family: Arial, sans-serif;
9+
color: #003366;
10+
background-color: #ffffff;
11+
padding: 20px;
12+
}
13+
.container {
14+
max-width: 600px;
15+
margin: auto;
16+
padding: 20px;
17+
}
18+
.logo {
19+
text-align: left;
20+
margin-bottom: 20px;
21+
}
22+
.logo img {
23+
max-width: 220px;
24+
height: auto;
25+
}
26+
.content {
27+
font-size: 16px;
28+
line-height: 1.6;
29+
color: #333;
30+
text-align: left;
31+
}
32+
.grey-box {
33+
background-color: #FAF9F8;
34+
padding: 16px;
35+
border-left: 4px solid #1a5a96;
36+
margin: 20px 0;
37+
}
38+
.grey-box p {
39+
margin: 0 0 10px 0;
40+
}
41+
.grey-box a {
42+
color: #1a5a96;
43+
font-weight: bold;
44+
text-decoration: none;
45+
}
46+
.footer {
47+
font-size: 12px;
48+
color: #666;
49+
margin-top: 30px;
50+
text-align: left;
51+
}
52+
</style>
53+
</head>
54+
<body>
55+
<div class="container">
56+
<div class="logo">
57+
<img src={{ logo_url }} alt="EAO Logo"/>
58+
</div>
59+
60+
<div class="content">
61+
<p>Hello,</p>
62+
63+
<p>We want to inform you that <strong>{{ package_name }}</strong> for <strong>{{ project_name }}</strong> has been reviewed by <strong>{{ team_member_name }}</strong> and is awaiting Manager's approval.</p>
64+
65+
<div class="grey-box">
66+
<p>Log in to <a href="https://submit.eao.gov.bc.ca">EPIC.submit</a> to view the submission package.</p>
67+
</div>
68+
69+
<p>Best regards,</p>
70+
71+
<p>
72+
<strong>EAO Management Plan Operations Team</strong><br>
73+
B.C. Environmental Assessment Office<br>
74+
Government of British Columbia
75+
</p>
76+
</div>
77+
</div>
78+
</body>
79+
</html>

0 commit comments

Comments
 (0)