diff --git a/docker-compose.yml b/docker-compose.yml index 3e3ca03578a..945fe8454bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ -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 @@ -14,8 +14,9 @@ x-common-django: - 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 @@ -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" @@ -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: @@ -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: diff --git a/geonode/email_backends/__initi__.py b/geonode/email_backends/__initi__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/geonode/email_backends/ms_graph_backend.py b/geonode/email_backends/ms_graph_backend.py new file mode 100644 index 00000000000..b6d51ad3f85 --- /dev/null +++ b/geonode/email_backends/ms_graph_backend.py @@ -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 diff --git a/geonode/management_commands_http/management/commands/test_emails.py b/geonode/management_commands_http/management/commands/test_emails.py new file mode 100644 index 00000000000..bbefd07d065 --- /dev/null +++ b/geonode/management_commands_http/management/commands/test_emails.py @@ -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 + +class Command(BaseCommand): + help = 'Test email sending with Microsoft Graph API' + + def handle(self, *args, **kwargs): + # Replace with your test recipient email + recipient_email = "recipient@example.com" + subject = "Test Email" + body = "This is a test email sent via Microsoft Graph API." + + success = send_email( + 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}")) + else: + self.stdout.write(self.style.ERROR("Failed to send email. Check logs for details.")) diff --git a/geonode/settings.py b/geonode/settings.py index c2a2660a4f3..24cff861e3e 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -502,6 +502,8 @@ "allauth.socialaccount", # GeoNode "geonode", + "allauth.socialaccount.providers.microsoft", + ) markdown_white_listed_tags = [ @@ -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 @@ -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", @@ -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') +} \ No newline at end of file diff --git a/installed_packages.txt b/installed_packages.txt new file mode 100644 index 00000000000..74263f3b99a --- /dev/null +++ b/installed_packages.txt @@ -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 diff --git a/rename_duplicates.py b/rename_duplicates.py new file mode 100644 index 00000000000..0c91edaec9b --- /dev/null +++ b/rename_duplicates.py @@ -0,0 +1,57 @@ +import os +import re +from collections import defaultdict + +# Directory where your static files are located +static_dirs = [ + '/usr/src/geonode/geonode/static', + # Add more directories as needed +] + +# Dictionary to keep track of file occurrences +file_occurrences = defaultdict(list) + +# Function to scan directories and detect duplicates +def scan_directories(directories): + for static_dir in directories: + for root, _, files in os.walk(static_dir): + for file in files: + file_path = os.path.join(root, file) + relative_path = os.path.relpath(file_path, static_dir) + file_occurrences[relative_path].append(file_path) + +# Function to rename duplicate files and update references +def rename_duplicates_and_update_references(): + for relative_path, file_paths in file_occurrences.items(): + if len(file_paths) > 1: + for index, file_path in enumerate(file_paths): + if index == 0: + continue # Keep the first file as is + new_file_path = f"{file_path.rsplit('.', 1)[0]}_{index}.{file_path.rsplit('.', 1)[1]}" + os.rename(file_path, new_file_path) + print(f"Renamed {file_path} to {new_file_path}") + update_references(relative_path, new_file_path) + +# Function to update file references in HTML, CSS, and JavaScript files +def update_references(old_path, new_path): + search_path = '/usr/src/geonode/' # Root directory to search for references + old_file_name = os.path.basename(old_path) + new_file_name = os.path.basename(new_path) + + for root, _, files in os.walk(search_path): + for file in files: + if file.endswith(('.html', '.css', '.js')): + file_path = os.path.join(root, file) + with open(file_path, 'r') as f: + content = f.read() + new_content = re.sub(rf'\b{old_file_name}\b', new_file_name, content) + if content != new_content: + with open(file_path, 'w') as f: + f.write(new_content) + print(f"Updated references in {file_path}") + +# Scan directories for duplicates +scan_directories(static_dirs) + +# Rename duplicate files and update references +rename_duplicates_and_update_references() diff --git a/requirements.txt b/requirements.txt index 16237c7484d..9ed8e151242 100644 --- a/requirements.txt +++ b/requirements.txt @@ -172,3 +172,5 @@ aiohttp>=3.9.0 # not directly required, pinned by Snyk to avoid a vulnerability dnspython>=2.6.0rc1 # not directly required, pinned by Snyk to avoid a vulnerability nh3==0.2.17 sqlparse>=0.5.0 # not directly required, pinned by Snyk to avoid a vulnerability +msal>=1.31.1 +django-microsoft-auth>=3.0.1 diff --git a/viewer/templates/viewer/viewer.html b/viewer/templates/viewer/viewer.html new file mode 100644 index 00000000000..69ab48be834 --- /dev/null +++ b/viewer/templates/viewer/viewer.html @@ -0,0 +1,35 @@ + + + + Map Viewer + + + + + + + + + + + + +
+ + + +