Skip to content

Commit 67b6f00

Browse files
authored
Merge pull request #6159 from bcgov/feat/6028
feat(5812): implement reminders for private cloud reviews
2 parents 12b15a3 + f3ab1fb commit 67b6f00

File tree

6 files changed

+312
-6
lines changed

6 files changed

+312
-6
lines changed

helm/tools/dags/_keycloak.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,38 @@ def __init__(self, auth_server_url, realm, client_id, client_secret):
88
self.client_id = client_id
99
self.client_secret = client_secret
1010
self.token_url = f"{auth_server_url}/realms/{realm}/protocol/openid-connect/token"
11+
self.base_admin_url = f"{auth_server_url}/admin/realms/{realm}"
12+
self._access_token = None
1113

1214
def _request_token(self):
1315
payload = {"grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret}
14-
1516
response = requests.post(self.token_url, data=payload)
1617
response.raise_for_status()
1718
return response.json()
1819

1920
def get_access_token(self):
20-
token_data = self._request_token()
21-
return token_data.get("access_token", "")
21+
if not self._access_token:
22+
token_data = self._request_token()
23+
self._access_token = token_data.get("access_token", "")
24+
return self._access_token
25+
26+
def _get_headers(self):
27+
return {"Authorization": f"Bearer {self.get_access_token()}", "Content-Type": "application/json"}
28+
29+
def find_client_by_client_id(self, client_id):
30+
url = f"{self.base_admin_url}/clients?clientId={client_id}"
31+
response = requests.get(url, headers=self._get_headers())
32+
response.raise_for_status()
33+
clients = response.json()
34+
return clients[0] if clients else None
35+
36+
def find_users_by_client_role(self, client_id, role_name):
37+
client = self.find_client_by_client_id(client_id)
38+
if not client or "id" not in client:
39+
return []
40+
41+
role_users_url = f"{self.base_admin_url}/clients/{client['id']}/roles/{role_name}/users"
42+
43+
response = requests.get(role_users_url, headers=self._get_headers())
44+
response.raise_for_status()
45+
return response.json()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
2+
<html dir="ltr" lang="en">
3+
<head>
4+
<title>BC Platform Services Product Registry</title>
5+
<meta http-equiv="Content-Type" content="text/html; charset=us-ascii" />
6+
<link rel="preload" as="image" href="https://dev-pltsvc.apps.silver.devops.gov.bc.ca/logo.png" />
7+
<meta name="x-apple-disable-message-reformatting" />
8+
</head>
9+
10+
<body
11+
style="
12+
margin: 0;
13+
padding: 0;
14+
background-color: rgb(255, 255, 255);
15+
font-family:
16+
Inter var,
17+
ui-sans-serif,
18+
system-ui,
19+
sans-serif,
20+
'Apple Color Emoji',
21+
'Segoe UI Emoji',
22+
'Segoe UI Symbol',
23+
'Noto Color Emoji';
24+
font-size: 0.75rem;
25+
line-height: 1rem;
26+
color: rgb(52, 64, 84);
27+
"
28+
>
29+
<div
30+
style="
31+
margin: 1rem auto;
32+
max-width: 36rem;
33+
border-radius: 0.25rem;
34+
border: 1px solid rgb(234, 234, 234);
35+
padding: 1rem;
36+
"
37+
>
38+
<div
39+
style="
40+
display: flex;
41+
flex-direction: row;
42+
border-bottom: 3px solid rgb(252, 186, 25);
43+
background-color: rgb(0, 51, 102);
44+
box-shadow:
45+
0 1px 3px rgb(0 0 0 / 10%),
46+
0 1px 2px -1px rgb(0 0 0 / 10%);
47+
"
48+
>
49+
<img
50+
alt="BC Platform Services Product Registry"
51+
height="41"
52+
src="https://dev-pltsvc.apps.silver.devops.gov.bc.ca/logo.png"
53+
style="margin: auto 1rem"
54+
width="58"
55+
/>
56+
<div style="display: flex; flex-direction: row; color: rgb(255, 255, 255)">
57+
<p
58+
style="
59+
font-size: 1.125rem;
60+
line-height: 1.75rem;
61+
margin: 16px 0;
62+
margin-right: 0.5rem;
63+
font-family: Roboto, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
64+
'Segoe UI Symbol', 'Noto Color Emoji';
65+
font-weight: 100;
66+
"
67+
>
68+
BC Platform Services
69+
</p>
70+
<p
71+
style="
72+
font-size: 1.125rem;
73+
line-height: 1.75rem;
74+
margin: 16px 0;
75+
font-family: Roboto, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
76+
'Segoe UI Symbol', 'Noto Color Emoji';
77+
font-weight: 400;
78+
"
79+
>
80+
Product Registry
81+
</p>
82+
</div>
83+
</div>
84+
85+
<div style="margin: 3rem">
86+
<div style="margin-bottom: 1rem; margin-top: 1rem">
87+
<h1 style="font-size: 1.125rem; line-height: 1.75rem; color: rgb(0, 0, 0)">
88+
Private Cloud Request Review Reminder
89+
</h1>
90+
<p style="font-size: 14px; line-height: 24px; margin: 16px 0">Hi Registry Team,</p>
91+
<p style="font-size: 14px; line-height: 24px; margin: 16px 0">
92+
You have a pending review for the private cloud request with licence plate
93+
<strong>{{ licence_plate }}</strong>. Please complete your review at your earliest convenience.
94+
</p>
95+
<a
96+
href="{{ request_url }}"
97+
style="
98+
text-decoration: none;
99+
display: inline-block;
100+
border-radius: 0.375rem;
101+
background-color: rgb(252, 186, 25);
102+
padding: 0.5rem 1rem;
103+
color: rgb(255, 255, 255);
104+
"
105+
target="_blank"
106+
>
107+
View request
108+
</a>
109+
</div>
110+
</div>
111+
</div>
112+
</body>
113+
</html>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import requests
2+
from requests.exceptions import RequestException
3+
from datetime import date, datetime, timedelta, timezone
4+
from jinja2 import Environment, FileSystemLoader, select_autoescape
5+
from typing import Optional, Set
6+
from _projects import get_mongo_db
7+
from _keycloak import Keycloak
8+
from _ches import Ches
9+
10+
11+
def get_holiday_dates(province: str) -> set:
12+
url = f"https://canada-holidays.ca/api/v1/provinces/{province}"
13+
response = requests.get(url)
14+
response.raise_for_status()
15+
16+
data = response.json()
17+
holiday_dates = {holiday["date"] for holiday in data["province"]["holidays"]}
18+
print(holiday_dates)
19+
return holiday_dates
20+
21+
22+
def get_n_business_days_ago(n: int, province: str, base_date: Optional[date] = None) -> date:
23+
holidays = get_holiday_dates(province)
24+
current_day = base_date or date.today()
25+
count = 0
26+
27+
while count < n:
28+
current_day -= timedelta(days=1)
29+
weekday = current_day.weekday() # Monday is 0, Sunday is 6
30+
31+
if weekday >= 5: # Weekend
32+
continue
33+
if current_day.isoformat() in holidays: # Holiday
34+
continue
35+
36+
count += 1
37+
38+
return current_day
39+
40+
41+
def generate_email_html(app_url: str, licence_plate: str, request_id: str):
42+
env = Environment(
43+
loader=FileSystemLoader("/opt/airflow/dags"),
44+
autoescape=select_autoescape(
45+
enabled_extensions=("html", "xml"),
46+
default_for_string=True,
47+
),
48+
)
49+
50+
template = env.get_template("_request_review_reminder.html")
51+
html_content = template.render(
52+
request_url=f"{app_url}/private-cloud/requests/{request_id}/decision", licence_plate=licence_plate
53+
)
54+
55+
return html_content
56+
57+
58+
def send_request_review_reminders(
59+
app_url,
60+
mongo_conn_id,
61+
kc_auth_url,
62+
kc_realm,
63+
kc_client_id,
64+
kc_client_secret,
65+
ches_api_url,
66+
ches_auth_url,
67+
ches_realm,
68+
ches_client_id,
69+
ches_client_secret,
70+
):
71+
kc = Keycloak(kc_auth_url, kc_realm, kc_client_id, kc_client_secret)
72+
admins = kc.find_users_by_client_role("pltsvc", "private-admin")
73+
adminEmails = [admin["email"] for admin in admins if "email" in admin]
74+
if not adminEmails:
75+
print("No admin emails found.")
76+
return None
77+
78+
ches = Ches(ches_api_url, ches_auth_url, ches_realm, ches_client_id, ches_client_secret)
79+
db = get_mongo_db(mongo_conn_id)
80+
81+
three_business_days_ago = get_n_business_days_ago(3, "BC")
82+
print(f"Three business days ago: {three_business_days_ago}")
83+
84+
three_business_days_ago_dt = datetime.combine(three_business_days_ago, datetime.min.time())
85+
query = {
86+
"type": "REVIEW_PRIVATE_CLOUD_REQUEST",
87+
"status": "ASSIGNED",
88+
"createdAt": {"$lt": three_business_days_ago_dt},
89+
}
90+
projection = {"_id": True, "data": True}
91+
92+
print(f"Querying {query}...")
93+
tasks = db.Task.find(query, projection=projection)
94+
95+
success = 0
96+
failure = 0
97+
for task in tasks:
98+
try:
99+
data = task.get("data")
100+
licence_plate = data["licencePlate"]
101+
request_id = data["requestId"]
102+
print(f"Processing task {task['_id']} with data: {data}")
103+
104+
ches.send_email(
105+
{
106+
"subject": "Private Cloud Request Review Reminder",
107+
"body": generate_email_html(app_url, licence_plate, request_id),
108+
"to": adminEmails,
109+
}
110+
)
111+
success += 1
112+
except Exception as e:
113+
print(f"Failed to process task {task['_id']}: {e}")
114+
failure += 1
115+
116+
return {"success": success, "failure": failure}

helm/tools/dags/_task_failure_callback.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def send_alert(context, dag):
1010
task_id = task_instance.task_id
1111
execution_date = context.get("execution_date")
1212

13-
airflow_dag_logs = f"https://secdash-airflow.apps.silver.devops.gov.bc.ca/dags/{dag}/grid"
13+
airflow_dag_logs = f"https://secdash-airflow.apps.silver.devops.gov.bc.ca/dags/{dag}/runs"
1414

1515
payload = {
1616
"text": f":warning: Airflow: **{dag}**",

helm/tools/dags/_temporary_products_notification.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from datetime import datetime, timedelta, timezone
2-
from jinja2 import Environment, FileSystemLoader
2+
from jinja2 import Environment, FileSystemLoader, select_autoescape
33
from bson.objectid import ObjectId
44
from _projects import get_mongo_db
55
from _ches import Ches
66

77

88
def generate_email_html(app_url: str, licence_plate: str):
9-
env = Environment(loader=FileSystemLoader("/opt/airflow/dags"))
9+
env = Environment(
10+
loader=FileSystemLoader("/opt/airflow/dags"),
11+
autoescape=select_autoescape(
12+
enabled_extensions=("html", "xml"),
13+
default_for_string=True,
14+
),
15+
)
1016

1117
template = env.get_template("_temporary_products_notification.html")
1218
html_content = template.render(
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
from airflow import DAG
3+
from airflow.operators.python import PythonOperator
4+
from datetime import datetime, timedelta
5+
from _request_review_reminder import send_request_review_reminders
6+
from _task_failure_callback import send_alert
7+
8+
APP_URL = "https://dev-pltsvc.apps.silver.devops.gov.bc.ca"
9+
MONGO_CONN_ID = "pltsvc-dev"
10+
11+
KC_AUTH_URL = "https://dev.loginproxy.gov.bc.ca/auth"
12+
KC_REALM = "platform-services"
13+
KC_SA_ID = os.getenv("DEV_KEYCLOAK_ADMIN_CLIENT_ID")
14+
KC_SA_SECRET = os.getenv("DEV_KEYCLOAK_ADMIN_CLIENT_SECRET")
15+
16+
CHES_AUTH_URL = "https://dev.loginproxy.gov.bc.ca/auth"
17+
CHES_REALM = "comsvcauth"
18+
CHES_SA_ID = os.getenv("DEV_CHES_SA_ID")
19+
CHES_SA_SECRET = os.getenv("DEV_CHES_SA_SECRET")
20+
CHES_API_URL = "http://pltsvc-ches-mock.101ed4-dev.svc.cluster.local:3025"
21+
22+
with DAG(
23+
dag_id="request_review_reminder_dev",
24+
description="A DAG to send reminders for review of private cloud requests",
25+
schedule="0 0 * * *",
26+
start_date=datetime.now() - timedelta(weeks=1),
27+
is_paused_upon_creation=False,
28+
catchup=False,
29+
) as dag:
30+
t1 = PythonOperator(
31+
task_id="request-review-reminder",
32+
python_callable=send_request_review_reminders,
33+
op_kwargs={
34+
"app_url": APP_URL,
35+
"mongo_conn_id": MONGO_CONN_ID,
36+
"kc_auth_url": KC_AUTH_URL,
37+
"kc_realm": KC_REALM,
38+
"kc_client_id": KC_SA_ID,
39+
"kc_client_secret": KC_SA_SECRET,
40+
"ches_api_url": CHES_API_URL,
41+
"ches_auth_url": CHES_AUTH_URL,
42+
"ches_realm": CHES_REALM,
43+
"ches_client_id": CHES_SA_ID,
44+
"ches_client_secret": CHES_SA_SECRET,
45+
},
46+
on_failure_callback=lambda context: send_alert(context, context["dag"].dag_id),
47+
)

0 commit comments

Comments
 (0)