diff --git a/CHANGELOG.md b/CHANGELOG.md
index 846666a..3a4ba49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#27](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/27) Move API documentation under `/v1`
- [#28](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/28) Keep API documentation public
- [#30](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/30) Thread access request notification emails
+- [#32](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/32) Send access request notifications as multipart plain-text and HTML emails.
+- [#32](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/32) Show access request use-case links only after approval.
### `Added`
@@ -24,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#17](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/17) Notify use-case admins of pending access requests
- [#18](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/18) Use English copy in API response payloads
- [#26](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/26) Support proxy CSRF settings for admin forms behind HTTPS reverse proxies
+- [#31](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/31/) Add a `send_test_email` management command and configurable Django email backend.
### `Fixed`
diff --git a/README.md b/README.md
index 30988a2..a4976d3 100644
--- a/README.md
+++ b/README.md
@@ -291,8 +291,10 @@ safe defaults and normally do not need to be changed.
| `EMAIL_PORT` | `1025` or `587` | SMTP port. |
| `EMAIL_HOST_USER` | SMTP username | Optional SMTP auth username. |
| `EMAIL_HOST_PASSWORD` | SMTP password | Optional SMTP auth password. |
+| `EMAIL_BACKEND` | `django.core.mail.backends.smtp.EmailBackend` | Django email backend. Use `django.core.mail.backends.console.EmailBackend` for CLI-only development checks. |
| `EMAIL_USE_TLS` | `false` or `true` | Whether SMTP uses TLS. |
| `DEFAULT_FROM_EMAIL` | `no-reply@pathocore.local` | Sender shown in PathoCore API emails. |
+| `ALLOWED_EMAIL_DOMAINS` | `ciberisciii.es,externos.isciii.es` | Optional comma-separated recipient domain allow-list for the test email command. |
| `PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS` | `admin@example.org` | Optional fallback/copy recipients if Keycloak use-case admins are not found. |
New access requests notify admins from the Keycloak group
@@ -518,8 +520,9 @@ matching use-case admin group, for example `/use-cases/mepram/admin`. Configure
`PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS` only as a fallback or general copy.
Rejected and revoked notifications include the review note as the reason and
the available use-case admin contact emails.
-All access request workflow emails include a plain-text footer with PathoCore /
-MEPRAM DataHub and the technical platform links.
+Access request workflow emails are sent as multipart plain text and HTML.
+The HTML version presents the requested use-case, role and web section in a
+structured summary instead of exposing raw group paths in the main sentence.
In the local Docker test stack these messages are captured by Mailpit:
@@ -527,6 +530,19 @@ In the local Docker test stack these messages are captured by Mailpit:
http://127.0.0.1:8025
```
+Send a test email with the active Django settings:
+
+```bash
+python manage.py send_test_email user@example.org
+```
+
+For a console-only check, override the backend:
+
+```bash
+EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
+ python manage.py send_test_email user@example.org
+```
+
Revocation removes the approved Keycloak group but does not disable the whole
account.
diff --git a/conf/docker_production_settings.txt b/conf/docker_production_settings.txt
index 4a839b6..4da9b92 100644
--- a/conf/docker_production_settings.txt
+++ b/conf/docker_production_settings.txt
@@ -50,8 +50,10 @@ EMAIL_HOST="change_me_smtp_host"
EMAIL_PORT=587
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
+EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend"
EMAIL_USE_TLS="true"
DEFAULT_FROM_EMAIL="no-reply@pathocore.local"
+ALLOWED_EMAIL_DOMAINS=""
### Logs settings
LOG_TYPE="regular_folder"
diff --git a/conf/docker_test_settings.txt b/conf/docker_test_settings.txt
index 72f828e..2f9f4bc 100644
--- a/conf/docker_test_settings.txt
+++ b/conf/docker_test_settings.txt
@@ -50,8 +50,10 @@ EMAIL_HOST="mailpit"
EMAIL_PORT=1025
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
+EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend"
EMAIL_USE_TLS="false"
DEFAULT_FROM_EMAIL="no-reply@pathocore.local"
+ALLOWED_EMAIL_DOMAINS=""
### Logs settings
LOG_TYPE="regular_folder"
diff --git a/conf/template_install_settings.txt b/conf/template_install_settings.txt
index 1b835c6..66b6947 100644
--- a/conf/template_install_settings.txt
+++ b/conf/template_install_settings.txt
@@ -52,8 +52,10 @@ EMAIL_HOST=""
EMAIL_PORT=587
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
+EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend"
EMAIL_USE_TLS="true"
DEFAULT_FROM_EMAIL="no-reply@pathocore.local"
+ALLOWED_EMAIL_DOMAINS=""
### Logs settings
LOG_TYPE="symbolic_link" # can be symbolic link, or regular_folder
diff --git a/conf/template_settings.py b/conf/template_settings.py
index 70f32de..92bc877 100644
--- a/conf/template_settings.py
+++ b/conf/template_settings.py
@@ -239,6 +239,9 @@ def _csv_env(name, default=None):
EMAIL_PORT = _int_env("EMAIL_PORT", "emailport")
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "emailhostuser")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "emailhostpassword")
+EMAIL_BACKEND = os.environ.get(
+ "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend"
+)
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "emailhosttls").lower() in (
"1",
"true",
@@ -246,6 +249,7 @@ def _csv_env(name, default=None):
"on",
)
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "pathocore-api@localhost")
+ALLOWED_EMAIL_DOMAINS = _csv_env("ALLOWED_EMAIL_DOMAINS")
SPECTACULAR_SETTINGS = {
"TITLE": "PathoCore API",
diff --git a/core/api/services/access_requests.py b/core/api/services/access_requests.py
index 74ac4f0..88c2888 100644
--- a/core/api/services/access_requests.py
+++ b/core/api/services/access_requests.py
@@ -1,8 +1,9 @@
from email.utils import parseaddr
+from html import escape
from django.conf import settings
from django.contrib.auth import get_user_model
-from django.core.mail import EmailMessage
+from django.core.mail import EmailMultiAlternatives
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
@@ -25,9 +26,6 @@
Technical platforms:
BIPLAT-CIBERINFEC
https://github.com/BIPLAT-CIBERINFEC/
-
-BU-ISCIII
-https://github.com/BU-ISCIII
"""
@@ -328,19 +326,25 @@ def build_group_path_from_scope(scope):
def notify_access_request_created(access_request):
- requested_access = _requested_access_label(access_request)
+ access_summary = _requested_access_text(access_request)
_send_access_request_email(
access_request,
message=_with_email_footer(
f"Hello {access_request.first_name},\n\n"
"We have received your PathoCore access request and it is pending "
"administrator review.\n\n"
- f"Requested access: {requested_access}\n"
+ f"Requested access:\n{access_summary}\n\n"
"You will receive another notification once the request has been "
"reviewed.\n"
),
+ title="Access request received",
+ intro=(
+ "We have received your PathoCore access request and it is pending "
+ "administrator review."
+ ),
recipient_list=[access_request.email],
message_key="received",
+ show_action=False,
)
recipients = _access_request_admin_recipients(access_request)
@@ -350,12 +354,22 @@ def notify_access_request_created(access_request):
access_request,
message=_with_email_footer(
f"User: {access_request.username} <{access_request.email}>\n"
- f"Requested: {requested_access}\n"
+ "Requested access:\n"
+ f"{_requested_access_text(access_request, include_group=True)}\n"
f"Message: {access_request.message or '-'}"
),
+ title="New access request pending review",
+ intro=(
+ f"{access_request.username} requested access to "
+ f"{_requested_access_label(access_request)}."
+ ),
+ note_label="User message",
+ note=access_request.message or "-",
+ include_group=True,
recipient_list=recipients,
message_key="admin-pending",
reply_to_thread=True,
+ show_action=True,
)
@@ -383,7 +397,7 @@ def _unique_emails(emails):
def notify_access_request_reviewed(access_request):
- requested_access = _requested_access_label(access_request)
+ access_summary = _requested_access_text(access_request)
section_url = _use_case_section_url(access_request)
section_line = (
f"Web section: {section_url}\n\n"
@@ -394,7 +408,7 @@ def notify_access_request_reviewed(access_request):
if access_request.status == core.models.AccessRequest.STATUS_APPROVED:
status_message = (
"Your PathoCore access request has been approved.\n\n"
- f"You requested access to: {requested_access}\n"
+ f"Requested access:\n{access_summary}\n\n"
f"{section_line}"
"If this is your first access, you will receive a Keycloak email "
"to verify your email and set your password."
@@ -406,7 +420,7 @@ def notify_access_request_reviewed(access_request):
elif access_request.status == core.models.AccessRequest.STATUS_REJECTED:
status_message = (
"Your PathoCore access request has been rejected.\n\n"
- f"Requested access: {requested_access}\n"
+ f"Requested access:\n{access_summary}\n\n"
f"Reason: {access_request.review_note or '-'}\n"
f"{_admin_contact_line(access_request)}"
)
@@ -424,25 +438,34 @@ def notify_access_request_reviewed(access_request):
_send_access_request_email(
access_request,
message=_with_email_footer(message),
+ title=_review_email_title(access_request),
+ intro=status_message.split("\n\n", 1)[0],
+ note_label="Review note",
+ note=access_request.review_note or "-",
recipient_list=[access_request.email],
message_key=access_request.status,
reply_to_thread=True,
+ show_action=access_request.status == core.models.AccessRequest.STATUS_APPROVED,
)
def notify_access_request_revoked(access_request):
- revoked_access = _requested_access_label(access_request)
_send_access_request_email(
access_request,
message=_with_email_footer(
"Your PathoCore access has been revoked.\n\n"
- f"Revoked access: {revoked_access}\n"
+ f"Revoked access:\n{_requested_access_text(access_request)}\n\n"
f"Reason: {access_request.review_note or '-'}\n"
f"{_admin_contact_line(access_request)}"
),
+ title="Access revoked",
+ intro="Your PathoCore access has been revoked.",
+ note_label="Reason",
+ note=access_request.review_note or "-",
recipient_list=[access_request.email],
message_key="revoked",
reply_to_thread=True,
+ show_action=False,
)
@@ -450,11 +473,17 @@ def _send_access_request_email(
access_request,
*,
message,
+ title,
+ intro,
recipient_list,
message_key,
+ note_label=None,
+ note=None,
+ include_group=False,
reply_to_thread=False,
+ show_action=False,
):
- email = EmailMessage(
+ email = EmailMultiAlternatives(
subject=_access_request_email_subject(access_request),
body=message,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
@@ -465,6 +494,18 @@ def _send_access_request_email(
reply_to_thread=reply_to_thread,
),
)
+ email.attach_alternative(
+ _access_request_email_html(
+ access_request,
+ title=title,
+ intro=intro,
+ note_label=note_label,
+ note=note,
+ include_group=include_group,
+ show_action=show_action,
+ ),
+ "text/html",
+ )
email.send(fail_silently=True)
@@ -513,9 +554,41 @@ def _with_email_footer(message):
def _requested_access_label(access_request):
use_case_label = _use_case_label(access_request.requested_use_case)
- role = access_request.requested_role
+ return f"{use_case_label} {_role_label(access_request.requested_role)} access"
+
+
+def _requested_access_text(access_request, *, include_group=False):
+ use_case_label = _use_case_label(access_request.requested_use_case)
+ lines = [
+ f"- Use case: {use_case_label}",
+ f"- Role: {_role_label(access_request.requested_role)}",
+ ]
+ section_url = _use_case_section_url(access_request)
+ if section_url:
+ lines.append(f"- Web section: {section_url}")
+ if include_group:
+ lines.append(f"- Permission group: {_permission_group_path(access_request)}")
+ return "\n".join(lines)
+
+
+def _permission_group_path(access_request):
group_path = access_request.approved_group or build_group_path(access_request)
- return f"{use_case_label} ({role}) [{group_path}]"
+ return group_path
+
+
+def _role_label(role):
+ return str(role or "").strip().replace("_", " ").title()
+
+
+def _review_email_title(access_request):
+ titles = {
+ core.models.AccessRequest.STATUS_APPROVED: "Access request approved",
+ core.models.AccessRequest.STATUS_REJECTED: "Access request rejected",
+ }
+ return titles.get(
+ access_request.status,
+ "Access request status updated",
+ )
def _use_case_label(use_case_name):
@@ -539,6 +612,168 @@ def _admin_contact_line(access_request):
return f"If you have questions, contact: {', '.join(contacts)}\n"
+def _access_request_email_html(
+ access_request,
+ *,
+ title,
+ intro,
+ note_label=None,
+ note=None,
+ include_group=False,
+ show_action=False,
+):
+ use_case_label = _use_case_label(access_request.requested_use_case)
+ role_label = _role_label(access_request.requested_role)
+ group_path = _permission_group_path(access_request)
+ section_url = _use_case_section_url(access_request)
+ page_style = _style(
+ "margin:0",
+ "padding:0",
+ "background:#f8fafc",
+ "color:#0f172a",
+ "font-family:Arial,Helvetica,sans-serif",
+ )
+ wrapper_style = _style("background:#f8fafc", "padding:24px 0")
+ card_style = _style(
+ "max-width:640px",
+ "width:100%",
+ "background:#ffffff",
+ "border:1px solid #e2e8f0",
+ "border-radius:6px",
+ "overflow:hidden",
+ )
+ header_style = _style("padding:20px 24px", "background:#0f172a", "color:#ffffff")
+ eyebrow_style = _style(
+ "font-size:13px",
+ "letter-spacing:.04em",
+ "text-transform:uppercase",
+ "color:#99f6e4",
+ )
+ title_style = _style(
+ "margin:8px 0 0",
+ "font-size:22px",
+ "line-height:1.3",
+ "font-weight:700",
+ )
+ intro_style = _style(
+ "margin:0 0 20px",
+ "font-size:15px",
+ "line-height:1.6",
+ "color:#334155",
+ )
+ table_style = _style(
+ "border:1px solid #e2e8f0",
+ "border-radius:6px",
+ "border-collapse:separate",
+ "border-spacing:0",
+ "overflow:hidden",
+ )
+ label_style = _style("padding:12px 16px", "color:#475569")
+ value_style = _style("padding:12px 16px", "color:#0f172a")
+ top_label_style = _style(label_style, "border-top:1px solid #e2e8f0")
+ top_value_style = _style(value_style, "border-top:1px solid #e2e8f0")
+ group_style = _style(
+ top_value_style,
+ "font-family:Consolas,Menlo,monospace",
+ "font-size:13px",
+ )
+ button_style = _style(
+ "display:inline-block",
+ "padding:10px 16px",
+ "background:#0f766e",
+ "color:#ffffff",
+ "text-decoration:none",
+ "border-radius:4px",
+ "font-weight:600",
+ )
+ footer_style = _style(
+ "padding:16px 24px",
+ "background:#f1f5f9",
+ "color:#475569",
+ "font-size:13px",
+ "line-height:1.5",
+ )
+ link_style = _style("color:#0f766e", "text-decoration:none")
+ note_html = ""
+ if note_label and note is not None:
+ note_html = f"""
+
+ | {escape(note_label)} |
+ {escape(str(note))} |
+
+ """
+ action_html = ""
+ if show_action and section_url:
+ action_html = f"""
+
+
+ Open use-case section
+
+
+ """
+ group_html = ""
+ if include_group:
+ group_html = f"""
+
+ | Permission group |
+ {escape(group_path)} |
+
+ """
+ return f"""
+
+
+
+
+
+
+
+ |
+ PathoCore / MEPRAM DataHub
+ {escape(title)}
+ |
+
+
+ |
+ {escape(intro)}
+
+
+ | Use case |
+
+ {escape(use_case_label)}
+ |
+
+
+ | Role |
+ {escape(role_label)} |
+
+ {group_html}
+ {note_html}
+
+ {action_html}
+ |
+
+
+ |
+ Technical platform:
+ BIPLAT-CIBERINFEC
+ |
+
+
+ |
+
+
+
+"""
+
+
+def _style(*rules):
+ return ";".join(rule.rstrip(";") for rule in rules if rule)
+
+
def _get_use_case_config(use_case_name):
normalized_name = _normalize_identifier(use_case_name)
for use_case in getattr(settings, "PATHOCORE_ACCESS_REQUEST_USE_CASES", []):
diff --git a/core/management/commands/send_test_email.py b/core/management/commands/send_test_email.py
new file mode 100644
index 0000000..577a13f
--- /dev/null
+++ b/core/management/commands/send_test_email.py
@@ -0,0 +1,82 @@
+from email.utils import parseaddr
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.core.management.base import BaseCommand, CommandError
+
+
+def _email_domain(address):
+ _, parsed_address = parseaddr(address or "")
+ if "@" not in parsed_address:
+ return ""
+ return parsed_address.rsplit("@", 1)[1].lower()
+
+
+class Command(BaseCommand):
+ help = "Send a test email using the active Django email settings."
+
+ def add_arguments(self, parser):
+ parser.add_argument("recipient", help="Recipient email address.")
+ parser.add_argument(
+ "--subject",
+ default="PathoCore email test",
+ help="Email subject. Default: PathoCore email test",
+ )
+ parser.add_argument(
+ "--message",
+ default="This is a PathoCore email test.",
+ help="Plain-text email body.",
+ )
+ parser.add_argument(
+ "--from-email",
+ default=None,
+ help="Sender address. Defaults to DEFAULT_FROM_EMAIL.",
+ )
+ parser.add_argument(
+ "--ignore-domain-policy",
+ action="store_true",
+ help="Skip ALLOWED_EMAIL_DOMAINS validation for this test send.",
+ )
+
+ def handle(self, *args, **options):
+ recipient = options["recipient"].strip()
+ from_email = options["from_email"] or settings.DEFAULT_FROM_EMAIL
+ allowed_domains = {
+ domain.lower() for domain in getattr(settings, "ALLOWED_EMAIL_DOMAINS", [])
+ }
+
+ if not _email_domain(recipient):
+ raise CommandError("recipient must be a valid email address")
+
+ recipient_domain = _email_domain(recipient)
+ if (
+ allowed_domains
+ and not options["ignore_domain_policy"]
+ and recipient_domain not in allowed_domains
+ ):
+ raise CommandError(
+ "recipient domain '%s' is not in ALLOWED_EMAIL_DOMAINS"
+ % recipient_domain
+ )
+
+ self.stdout.write("EMAIL_BACKEND=%s" % settings.EMAIL_BACKEND)
+ self.stdout.write("EMAIL_HOST=%s" % getattr(settings, "EMAIL_HOST", ""))
+ self.stdout.write("EMAIL_PORT=%s" % getattr(settings, "EMAIL_PORT", ""))
+ self.stdout.write("EMAIL_USE_TLS=%s" % getattr(settings, "EMAIL_USE_TLS", ""))
+ self.stdout.write("DEFAULT_FROM_EMAIL=%s" % settings.DEFAULT_FROM_EMAIL)
+ if allowed_domains:
+ self.stdout.write(
+ "ALLOWED_EMAIL_DOMAINS=%s" % ",".join(sorted(allowed_domains))
+ )
+
+ sent_count = send_mail(
+ options["subject"],
+ options["message"],
+ from_email,
+ [recipient],
+ fail_silently=False,
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS("Sent %s test email(s) to %s" % (sent_count, recipient))
+ )
diff --git a/core/tests.py b/core/tests.py
index c1658fd..0321511 100644
--- a/core/tests.py
+++ b/core/tests.py
@@ -1,5 +1,6 @@
import base64
from datetime import date
+from io import StringIO
from unittest.mock import patch
from django.contrib.auth.models import User
@@ -7,6 +8,7 @@
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.management import call_command
+from django.core.management.base import CommandError
from django.test import SimpleTestCase, TestCase
from django.test import override_settings
from django.utils import timezone
@@ -1491,6 +1493,42 @@ def test_command_creates_or_updates_default_superuser(self):
self.assertTrue(user.check_password("new_pass"))
+class SendTestEmailCommandTests(SimpleTestCase):
+ @override_settings(
+ ALLOWED_EMAIL_DOMAINS=["ciberisciii.es"],
+ DEFAULT_FROM_EMAIL="no-reply@pathocore.local",
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
+ EMAIL_HOST="mailpit",
+ EMAIL_PORT=1025,
+ EMAIL_USE_TLS=False,
+ )
+ def test_command_uses_configured_backend(self):
+ mail.outbox = []
+ stdout = StringIO()
+
+ call_command("send_test_email", "da.valle@ciberisciii.es", stdout=stdout)
+
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(mail.outbox[0].to, ["da.valle@ciberisciii.es"])
+ self.assertEqual(mail.outbox[0].from_email, "no-reply@pathocore.local")
+ self.assertIn(
+ "EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend",
+ stdout.getvalue(),
+ )
+ self.assertIn("Sent 1 test email(s)", stdout.getvalue())
+
+ @override_settings(
+ ALLOWED_EMAIL_DOMAINS=["ciberisciii.es"],
+ EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
+ )
+ def test_command_rejects_disallowed_recipient_domain(self):
+ with self.assertRaisesMessage(
+ CommandError,
+ "recipient domain 'example.org' is not in ALLOWED_EMAIL_DOMAINS",
+ ):
+ call_command("send_test_email", "user@example.org")
+
+
@override_settings(
ROOT_URLCONF="conf.urls",
ALLOWED_HOSTS=["testserver", "localhost"],
@@ -1525,8 +1563,15 @@ def test_public_user_can_create_pending_access_request(self):
self.assertEqual(mail.outbox[0].to, ["new.user@example.org"])
self.assertEqual(
mail.outbox[0].subject,
- f"[PathoCore access #{response.data['id']}] MEPRAM (view) - new_user",
- )
+ f"[PathoCore access #{response.data['id']}] "
+ "MEPRAM View access - new_user",
+ )
+ self.assertIn("- Use case: MEPRAM", mail.outbox[0].body)
+ self.assertIn("- Role: View", mail.outbox[0].body)
+ self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html")
+ self.assertIn("Access request received", mail.outbox[0].alternatives[0][0])
+ self.assertNotIn("Open use-case section", mail.outbox[0].alternatives[0][0])
+ self.assertNotIn("Permission group", mail.outbox[0].alternatives[0][0])
self.assertEqual(
mail.outbox[0].extra_headers["Message-ID"],
(f""),
@@ -1628,7 +1673,13 @@ def test_access_request_notifies_keycloak_use_case_admins(self, emails_mock):
mail.outbox[1].extra_headers["References"],
mail.outbox[0].extra_headers["Message-ID"],
)
- self.assertIn("https://github.com/BU-ISCIII", mail.outbox[1].body)
+ self.assertIn("- Permission group: /use-cases/mepram/view", mail.outbox[1].body)
+ self.assertIn(
+ "New access request pending review",
+ mail.outbox[1].alternatives[0][0],
+ )
+ self.assertIn("Open use-case section", mail.outbox[1].alternatives[0][0])
+ self.assertIn("Permission group", mail.outbox[1].alternatives[0][0])
@override_settings(PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS=["fallback@example.org"])
@patch("core.api.services.keycloak_admin.list_group_member_emails")
@@ -1708,18 +1759,21 @@ def test_admin_can_approve_access_request(self, provision_mock):
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
mail.outbox[0].subject,
- f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user",
+ f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user",
)
self.assertEqual(
mail.outbox[0].extra_headers["In-Reply-To"],
(f""),
)
- self.assertIn("MEPRAM (view)", mail.outbox[0].body)
+ self.assertIn("- Use case: MEPRAM", mail.outbox[0].body)
+ self.assertIn("- Role: View", mail.outbox[0].body)
self.assertIn(
"https://mepram-datahub.ciberisciii.es/use-cases/mepram",
mail.outbox[0].body,
)
self.assertIn("Technical platforms:", mail.outbox[0].body)
+ self.assertIn("Access request approved", mail.outbox[0].alternatives[0][0])
+ self.assertIn("Open use-case section", mail.outbox[0].alternatives[0][0])
@patch("core.api.services.keycloak_admin.list_group_member_emails")
def test_admin_can_reject_access_request(self, emails_mock):
@@ -1743,7 +1797,7 @@ def test_admin_can_reject_access_request(self, emails_mock):
self.assertEqual(mail.outbox[0].to, ["new.user@example.org"])
self.assertEqual(
mail.outbox[0].subject,
- f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user",
+ f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user",
)
self.assertEqual(
mail.outbox[0].extra_headers["In-Reply-To"],
@@ -1752,6 +1806,8 @@ def test_admin_can_reject_access_request(self, emails_mock):
self.assertIn("Reason: Missing project justification", mail.outbox[0].body)
self.assertIn("contact: mepram.admin@example.org", mail.outbox[0].body)
self.assertIn("Technical platforms:", mail.outbox[0].body)
+ self.assertIn("Access request rejected", mail.outbox[0].alternatives[0][0])
+ self.assertNotIn("Open use-case section", mail.outbox[0].alternatives[0][0])
@patch("core.api.services.keycloak_admin.revoke_approved_user_access")
@patch("core.api.services.keycloak_admin.list_group_member_emails")
@@ -1784,7 +1840,7 @@ def test_admin_can_revoke_approved_access_request(self, emails_mock, revoke_mock
self.assertEqual(mail.outbox[0].to, ["new.user@example.org"])
self.assertEqual(
mail.outbox[0].subject,
- f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user",
+ f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user",
)
self.assertEqual(
mail.outbox[0].extra_headers["In-Reply-To"],
@@ -1793,6 +1849,7 @@ def test_admin_can_revoke_approved_access_request(self, emails_mock, revoke_mock
self.assertIn("Reason: Access no longer required", mail.outbox[0].body)
self.assertIn("contact: mepram.admin@example.org", mail.outbox[0].body)
self.assertIn("Technical platforms:", mail.outbox[0].body)
+ self.assertIn("Access revoked", mail.outbox[0].alternatives[0][0])
@staticmethod
def _request_payload():
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index ce3f7e4..97b20e5 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -66,8 +66,10 @@ services:
EMAIL_PORT: ${EMAIL_PORT:-1025}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
+ EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-false}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-no-reply@pathocore.local}
+ ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
DATABROWSER_CACHE_SCHEDULER_ENABLED: ${DATABROWSER_CACHE_SCHEDULER_ENABLED:-true}
# Python weekday: Monday=0, Friday=4.
DATABROWSER_CACHE_REFRESH_WEEKDAY: ${DATABROWSER_CACHE_REFRESH_WEEKDAY:-4}
diff --git a/install.sh b/install.sh
index 8c5a657..ed6750e 100755
--- a/install.sh
+++ b/install.sh
@@ -134,10 +134,14 @@ write_runtime_env_file() {
write_runtime_env_var "EMAIL_PORT" "${EMAIL_PORT:-587}"
write_runtime_env_var "EMAIL_HOST_USER" "${EMAIL_HOST_USER:-}"
write_runtime_env_var "EMAIL_HOST_PASSWORD" "${EMAIL_HOST_PASSWORD:-}"
+ write_runtime_env_var \
+ "EMAIL_BACKEND" \
+ "${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}"
write_runtime_env_var "EMAIL_USE_TLS" "${EMAIL_USE_TLS:-true}"
write_runtime_env_var \
"DEFAULT_FROM_EMAIL" \
"${DEFAULT_FROM_EMAIL:-no-reply@pathocore.local}"
+ write_runtime_env_var "ALLOWED_EMAIL_DOMAINS" "${ALLOWED_EMAIL_DOMAINS:-}"
for env_key in $(compgen -A variable KEYCLOAK_ | sort); do
write_runtime_env_var "$env_key" "${!env_key}"
done