Skip to content

Add MS Graph API integration for email notifications in GeoNode #12907

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

Open
wants to merge 1 commit into
base: 4.3.x
Choose a base branch
from
Open
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
16 changes: 9 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
version: '3.9'
#version: '3.9'

# Common Django template for GeoNode and Celery services below
x-common-django:
&default-common-django
image: geonode/geonode:4.3.0
#build:
# context: ./
# dockerfile: Dockerfile
build:
context: ./
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
volumes:
- statics:/mnt/volumes/statics
- geoserver-data-dir:/geoserver_data/data
- backup-restore:/backup_restore
- data:/data
- /opt/data:/data
- tmp:/tmp
- /mnt/blockstorage/thomas/:/share
depends_on:
db:
condition: service_healthy
Expand Down Expand Up @@ -91,7 +92,7 @@ services:

# Geoserver backend
geoserver:
image: geonode/geoserver:2.24.3-v1
image: geonode/geoserver:2.24.4-v1
container_name: geoserver4${COMPOSE_PROJECT_NAME}
healthcheck:
test: "curl -m 10 --fail --silent --write-out 'HTTP CODE : %{http_code}\n' --output /dev/null http://geoserver:8080/geoserver/ows"
Expand All @@ -109,6 +110,7 @@ services:
- backup-restore:/backup_restore
- data:/data
- tmp:/tmp
- /mnt/blockstorage/thomas/:/share
restart: unless-stopped
depends_on:
data-dir-conf:
Expand All @@ -117,7 +119,7 @@ services:
condition: service_healthy

data-dir-conf:
image: geonode/geoserver_data:2.24.3-v1
image: geonode/geoserver_data:2.24.4-v1
container_name: gsconf4${COMPOSE_PROJECT_NAME}
entrypoint: sleep infinity
volumes:
Expand Down
Empty file.
130 changes: 130 additions & 0 deletions geonode/email_backends/ms_graph_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import msal
import requests
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail import EmailMessage
from django.conf import settings
import logging

# Configure logging
logger = logging.getLogger(__name__)

class MicrosoftGraphEmailBackend(BaseEmailBackend):
"""
A Django email backend that sends emails using the Microsoft Graph API.
"""
def __init__(self, fail_silently=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fail_silently = fail_silently
logger.debug("MicrosoftGraphEmailBackend initialized with fail_silently=%s", self.fail_silently)

def get_access_token(self):
"""
Authenticate and retrieve an access token from Microsoft Graph API.
"""
try:
authority = f"https://login.microsoftonline.com/{settings.GRAPH_API_CREDENTIALS['tenant_id']}"

# MSAL client application with optional token caching
app = msal.ConfidentialClientApplication(
client_id=settings.GRAPH_API_CREDENTIALS['client_id'],
client_credential=settings.GRAPH_API_CREDENTIALS['client_secret'],
authority=authority,
token_cache=msal.SerializableTokenCache() # Enable token caching for better performance
)
scopes = ["https://graph.microsoft.com/.default"]

logger.debug("Attempting to acquire token silently.")
# Attempt silent token acquisition
result = app.acquire_token_silent(scopes, account=None)
if not result:
logger.info("Silent token acquisition failed. Attempting to acquire a new token.")
result = app.acquire_token_for_client(scopes=scopes)

if "access_token" in result:
logger.debug("Access token retrieved successfully.")
return result["access_token"]

# Log failure
logger.error(f"Failed to retrieve access token: {result}")
if not self.fail_silently:
raise Exception("Unable to retrieve access token for Microsoft Graph API.")
except Exception as e:
logger.exception("An error occurred while retrieving the access token.")
if not self.fail_silently:
raise e
return None

def send_messages(self, email_messages):
"""
Send multiple email messages using Microsoft Graph API.
"""
logger.debug("Fetching access token to send emails.")
access_token = self.get_access_token()
if not access_token:
logger.error("Access token is missing. Emails cannot be sent.")
return 0

sent_count = 0
for email in email_messages:
logger.debug("Sending email to: %s", email.to)
if self._send_email(email, access_token):
sent_count += 1
logger.debug("Total emails sent: %d", sent_count)
return sent_count

def _send_email(self, email: EmailMessage, access_token: str):
"""
Send a single email using Microsoft Graph API.
"""
endpoint = f"https://graph.microsoft.com/v1.0/users/{settings.GRAPH_API_CREDENTIALS['mail_from']}/sendMail"
email_msg = {
'message': {
'subject': email.subject, # Subject of the email
'body': {
'contentType': "HTML", # Specify the format of the email body
'content': email.body # The actual email content
},
'toRecipients': [{'emailAddress': {'address': addr}} for addr in email.to], # List of recipients
'ccRecipients': [{'emailAddress': {'address': addr}} for addr in email.cc or []], # CC recipients
'bccRecipients': [{'emailAddress': {'address': addr}} for addr in email.bcc or []], # BCC recipients
},
'saveToSentItems': 'true' # Save the email to the "Sent Items" folder
}

try:
logger.debug("Making POST request to Microsoft Graph API endpoint.")
response = requests.post(
endpoint,
headers={'Authorization': f'Bearer {access_token}'}, # Authorization header with access token
json=email_msg, # Email message payload
timeout=10 # Timeout in seconds
)
if response.ok:
logger.info(f"Email to {email.to} sent successfully.")
return True
logger.error(f"Failed to send email: {response.status_code} - {response.text}")
except requests.RequestException as e:
logger.exception(f"An exception occurred while sending the email: {e}")

if not self.fail_silently:
raise Exception("Failed to send email using Microsoft Graph API.")
return False

def send_messages_with_retries(self, email_messages, retries=3):
"""
Send multiple email messages with retry logic for better reliability.
"""
sent_count = 0
for email in email_messages:
attempt = 0
while attempt < retries:
try:
logger.debug("Attempt %d to send email to: %s", attempt + 1, email.to)
if self._send_email(email, self.get_access_token()):
sent_count += 1
break
except Exception as e:
logger.warning("Retry %d failed for email to %s: %s", attempt + 1, email.to, str(e))
attempt += 1
logger.debug("Total emails sent after retries: %d", sent_count)
return sent_count
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import os
from django.core.management.base import BaseCommand
from geonode.utils import send_email # Replace with the actual path to your email utility

Check warning on line 3 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L1-L3

Added lines #L1 - L3 were not covered by tests

class Command(BaseCommand):
help = 'Test email sending with Microsoft Graph API'

Check warning on line 6 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L5-L6

Added lines #L5 - L6 were not covered by tests

def handle(self, *args, **kwargs):

Check warning on line 8 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L8

Added line #L8 was not covered by tests
# Replace with your test recipient email
recipient_email = "[email protected]"
subject = "Test Email"
body = "This is a test email sent via Microsoft Graph API."

Check warning on line 12 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L10-L12

Added lines #L10 - L12 were not covered by tests

success = send_email(

Check warning on line 14 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L14

Added line #L14 was not covered by tests
to_email=recipient_email,
subject=subject,
body=body,
content_type='HTML'
)

if success:
self.stdout.write(self.style.SUCCESS(f"Email sent successfully to {recipient_email}"))

Check warning on line 22 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L22

Added line #L22 was not covered by tests
else:
self.stdout.write(self.style.ERROR("Failed to send email. Check logs for details."))

Check warning on line 24 in geonode/management_commands_http/management/commands/test_emails.py

View check run for this annotation

Codecov / codecov/patch

geonode/management_commands_http/management/commands/test_emails.py#L24

Added line #L24 was not covered by tests
20 changes: 19 additions & 1 deletion geonode/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@
"allauth.socialaccount",
# GeoNode
"geonode",
"allauth.socialaccount.providers.microsoft",

)

markdown_white_listed_tags = [
Expand Down Expand Up @@ -1364,7 +1366,7 @@
)

# Number of results per page listed in the GeoNode search pages
CLIENT_RESULTS_LIMIT = int(os.getenv("CLIENT_RESULTS_LIMIT", "5"))
CLIENT_RESULTS_LIMIT = int(os.getenv("CLIENT_RESULTS_LIMIT", "16"))

# LOCKDOWN API endpoints to prevent unauthenticated access.
# If set to True, search won't deliver results and filtering ResourceBase-objects is not possible for anonymous users
Expand Down Expand Up @@ -1976,9 +1978,18 @@ def get_geonode_catalogue_service():
_AZURE_TENANT_ID = os.getenv("MICROSOFT_TENANT_ID", "")
_AZURE_SOCIALACCOUNT_PROVIDER = {
"NAME": "Microsoft Azure",
"APP":{
"client_id": os.getenv("MICROSOFT_CLIENT_ID"),
"secret": os.getenv("MICROSOFT_CLIENT_SECRET"),
"key":"",
},
"SCOPE": [
"User.Read",
"openid",
"email",
"profile",
"User.Read",
"Mail.Send",
],
"AUTH_PARAMS": {
"access_type": "online",
Expand Down Expand Up @@ -2365,3 +2376,10 @@ def get_geonode_catalogue_service():
AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS = ast.literal_eval(
os.getenv("AUTO_ASSIGN_REGISTERED_MEMBERS_TO_CONTRIBUTORS", "True")
)

GRAPH_API_CREDENTIALS = {
'client_id': os.getenv('MICROSOFT_CLIENT_ID'),
'client_secret': os.getenv('MICROSOFT_CLIENT_SECRET'),
'tenant_id': os.getenv('MICROSOFT_TENANT_ID'),
'mail_from': os.getenv('DEFAULT_FROM_EMAIL')
}
99 changes: 99 additions & 0 deletions installed_packages.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
attrs==21.2.0
Automat==20.2.0
Babel==2.8.0
bcrypt==3.2.0
beautifulsoup4==4.10.0
blinker==1.4
Brlapi==0.8.3
Brotli==1.0.9
certifi==2020.6.20
chardet==4.0.0
click==8.0.3
cloud-init==24.1.3
colorama==0.4.4
command-not-found==0.3
configobj==5.0.6
constantly==15.1.0
cryptography==3.4.8
cupshelpers==1.0
dbus-python==1.2.18
distlib==0.3.4
distro==1.7.0
distro-info==1.1+ubuntu0.2
filelock==3.6.0
galternatives==1.0.8
gyp==0.1
html5lib==1.1
httplib2==0.20.2
hyperlink==21.0.0
idna==3.3
importlib-metadata==4.6.4
incremental==21.3.0
jeepney==0.7.1
Jinja2==3.0.3
jsonpatch==1.32
jsonpointer==2.0
jsonschema==3.2.0
keyring==23.5.0
launchpadlib==1.10.16
lazr.restfulclient==0.14.4
lazr.uri==1.0.6
louis==3.20.0
lxml==4.8.0
MarkupSafe==2.0.1
meteo_qt==2.1
more-itertools==8.10.0
mutagen==1.45.1
netifaces==0.11.0
oauthlib==3.2.0
pbr==5.8.0
pexpect==4.8.0
platformdirs==2.5.1
ptyprocess==0.7.0
pyasn1==0.4.8
pyasn1-modules==0.2.1
pycairo==1.20.1
pycryptodomex==3.11.0
pycups==2.0.1
PyGObject==3.42.1
PyHamcrest==2.0.2
PyJWT==2.3.0
pyOpenSSL==21.0.0
pyparsing==2.4.7
PyQt5==5.15.6
PyQt5-sip==12.9.1
pyrsistent==0.18.1
pyserial==3.5
python-apt==2.4.0+ubuntu3
python-debian==0.1.43+ubuntu1.1
python-magic==0.4.24
pytz==2022.1
pyxattr==0.7.2
pyxdg==0.27
PyYAML==5.4.1
requests==2.25.1
SecretStorage==3.3.1
service-identity==18.1.0
six==1.16.0
sos==4.5.6
soupsieve==2.3.1
ssh-import-id==5.11
stevedore==3.5.0
systemd-python==234
Twisted==22.1.0
ubuntu-drivers-common==0.0.0
ubuntu-pro-client==8001
ufw==0.36.1
unattended-upgrades==0.1
urllib3==1.26.5
virtualenv==20.13.0+ds
virtualenv-clone==0.3.0
virtualenvwrapper==4.8.4
wadllib==1.3.6
webencodings==0.5.1
websockets==9.1
xdg==5
xkit==0.0.0
yt-dlp==2022.4.8
zipp==1.0.0
zope.interface==5.4.0
Loading