Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions flask/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ class Config(object):
"http://keycloak:8080/auth/realms/ccm/.well-known/openid-configuration",
)
MSTEAMS_WEBHOOK_URL = os.getenv("MSTEAMS_WEBHOOK_URL")
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
SENDGRID_EMAIL_TEMPLATE_ID = os.getenv("SENDGRID_EMAIL_TEMPLATE_ID")
SENDGRID_TO_EMAIL = os.getenv("SENDGRID_TO_EMAIL")
SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL")
# LOGIN_DISABLED = True
214 changes: 140 additions & 74 deletions flask/app/email.py
Original file line number Diff line number Diff line change
@@ -1,81 +1,147 @@
import json
import math
import os
from datetime import datetime, timedelta
from typing import Any, Dict

from flask import current_app as app
from flask import Flask
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import From, Mail, SendAt, To


# https://github.com/sendgrid/sendgrid-python
sg = SendGridAPIClient(os.getenv("SENDGRID_API_KEY"))


def send_email(
from_email: str, to_emails: str, dynamic_template_object: Dict[str, Any]
) -> None:
"""Sends an email based on a template stored in SendGrid
:param dynamic_template_object: Data for a transactional template.
:type dynamic_template_object: A JSON-serializable structure
:param from: Sender of the email. The sender's email domain needs to have been authenticated and added to SendGrid Dashboard.
:type from: string
:param from: Email of the recipient
:type from: string
"""

emails_stats = get_daily_stats()

scheduled_time = get_send_time(emails_stats)

message = Mail()

message.to = To(to_emails)
message.from_email = From(from_email, "Stager Team")
message.send_at = SendAt(math.ceil(scheduled_time))
message.dynamic_template_data = dynamic_template_object
message.template_id = os.getenv("SENDGRID_EMAIL_TEMPLATE_ID")

try:
sg.send(message)
app.logger.debug(f"Email successfully sent from {from_email} to {to_emails}")
except Exception as e:
app.logger.error("Failed to send email...")
app.logger.error(e)


def get_daily_stats():
today = datetime.now().strftime("%Y-%m-%d")
params = {
"aggregated_by": "day",
"start_date": today,
"end_date": today,
"offset": 1,
}
try:
response = sg.client.stats.get(query_params=params)
return json.loads(response.body.decode("utf-8"))
except Exception as e:
app.logger.error("Failed to get daily email stats")
app.logger.debug(e)
return []


def get_send_time(stats):
limit_per_day = 100
send_at = datetime.now()

for stat in stats:

requests_count = sum(
[r.get("metrics").get("requests") for r in stat.get("stats")]
)

if stat.get("date") == datetime.strftime(send_at, "%Y-%m-%d"):
if requests_count < limit_per_day:
send_at = send_at + timedelta(seconds=15)
else:
send_at = send_at + timedelta(days=1, seconds=15)

return send_at.timestamp()
from sqlalchemy.orm import joinedload

from . import models


class Mailer:
def __init__(self, app: Flask):
self.app = app
# https://github.com/sendgrid/sendgrid-python
self.sg = SendGridAPIClient(app.config["SENDGRID_API_KEY"])

def send(
self, from_email: str, to_emails: str, dynamic_template_object: Dict[str, Any]
) -> None:
"""Sends an email based on a template stored in SendGrid
:param dynamic_template_object: Data for a transactional template.
:type dynamic_template_object: A JSON-serializable structure
:param from: Sender of the email. The sender's email domain needs to have been authenticated and added to SendGrid Dashboard.
:type from: string
:param from: Email of the recipient
:type from: string
"""

emails_stats = self._get_daily_stats()

scheduled_time = self._get_send_time(emails_stats)

message = Mail()

message.to = To(to_emails)
message.from_email = From(from_email, "Stager Team")
message.send_at = SendAt(math.ceil(scheduled_time))
message.dynamic_template_data = dynamic_template_object
message.template_id = self.app.config["SENDGRID_EMAIL_TEMPLATE_ID"]

try:
self.sg.send(message)
self.app.logger.debug(
f"Email successfully sent from {from_email} to {to_emails}"
)
except Exception as e:
self.app.logger.error("Failed to send email...")
self.app.logger.error(e)

def _get_daily_stats(self):
today = datetime.now().strftime("%Y-%m-%d")
params = {
"aggregated_by": "day",
"start_date": today,
"end_date": today,
"offset": 1,
}
try:
response = self.sg.client.stats.get(query_params=params)
return json.loads(response.body.decode("utf-8"))
except Exception as e:
self.app.logger.error("Failed to get daily email stats")
self.app.logger.debug(e)
return []

def _get_send_time(self, stats):
limit_per_day = 100
send_at = datetime.now()

for stat in stats:

requests_count = sum(
[r.get("metrics").get("requests") for r in stat.get("stats")]
)

if stat.get("date") == datetime.strftime(send_at, "%Y-%m-%d"):
if requests_count < limit_per_day:
send_at = send_at + timedelta(seconds=15)
else:
send_at = send_at + timedelta(days=1, seconds=15)

return send_at.timestamp()

def send_notification(self):
"""
This is a scheduled background task. Because it runs in a separate thread,
it pushes an app context for itself.
"""
with self.app.app_context():
yesterday = (datetime.now() - timedelta(1)).strftime("%Y-%m-%d")
analyses = (
models.Analysis.query.options(
joinedload(models.Analysis.datasets).joinedload(
models.Dataset.linked_files
),
joinedload(models.Analysis.requester),
joinedload(models.Analysis.datasets)
.joinedload(models.Dataset.tissue_sample)
.joinedload(models.TissueSample.participant)
.joinedload(models.Participant.family),
)
.filter(models.Analysis.requested >= yesterday)
.all()
)

email_analyses = []

for analysis in analyses:
email_analyses.append(
{
"analysis_id": analysis.analysis_id,
"requested": analysis.requested.strftime("%Y-%m-%d"),
"requester": analysis.requester.username,
"pipeline": analysis.kind,
"priority": analysis.priority.value,
"datasets": [
{
"dataset_id": dataset.dataset_id,
"notes": dataset.notes or "",
"linked_files": ", ".join(
[file.path for file in dataset.linked_files]
),
"participant_codename": dataset.tissue_sample.participant.participant_codename,
"participant_aliases": dataset.tissue_sample.participant.participant_aliases
or "",
"family_codename": dataset.tissue_sample.participant.family.family_codename,
"participant_notes": dataset.tissue_sample.participant.notes
or "",
}
for dataset in analysis.datasets
],
}
)

if len(email_analyses) > 0:
self.send(
to_emails=self.app.config["SENDGRID_TO_EMAIL"],
from_email=self.app.config["SENDGRID_FROM_EMAIL"],
dynamic_template_object={"analyses": email_analyses},
)

self.app.logger.debug(
f"{len(email_analyses)} analysis requests found... {json.dumps(email_analyses)}"
)
21 changes: 16 additions & 5 deletions flask/app/stager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import atexit
from os import getenv
from logging import INFO, Formatter
from logging import Formatter

from apscheduler.schedulers.base import BaseScheduler
from apscheduler.schedulers.background import BackgroundScheduler
Expand All @@ -11,8 +10,8 @@
from prometheus_flask_exporter import PrometheusMetrics
from prometheus_flask_exporter.multiprocess import GunicornPrometheusMetrics

from .email import Mailer
from .login import StagerLoginManager
from .tasks import send_email_notification
from .utils import DateTimeEncoder


Expand All @@ -23,6 +22,7 @@ class Stager(Flask):

json_encoder = DateTimeEncoder
# Only available in master process
mailer: Mailer # Only available if configured
scheduler: BaseScheduler

def __init__(self, config, db: SQLAlchemy, *args, **kwargs):
Expand Down Expand Up @@ -70,9 +70,20 @@ def start_scheduler(self):
# or cumbersome, it can be separated to be started by a completely different
# entrypoint in the same codebase and deployed as a separate container.
self.scheduler = BackgroundScheduler()
if getenv("SENDGRID_API_KEY"):
if self.config["SENDGRID_API_KEY"]:
self.logger.info(
"Configuring with SendGrid [%s] (from: %s) (to: %s)",
self.config["SENDGRID_EMAIL_TEMPLATE_ID"],
self.config["SENDGRID_FROM_EMAIL"],
self.config["SENDGRID_TO_EMAIL"],
)
self.mailer = Mailer(self)
self.scheduler.add_job(
send_email_notification, "cron", [self], day_of_week="mon-fri", hour="9"
self.mailer.send_notification,
"cron",
[self],
Comment thread
kevinlul marked this conversation as resolved.
Outdated
day_of_week="mon-fri",
hour="9",
)
self.scheduler.start()
if self.env == "development":
Expand Down
67 changes: 0 additions & 67 deletions flask/app/tasks.py

This file was deleted.