From b460fff91524597bf6bd61d1a3487b2ed5ece6c6 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Mon, 17 Mar 2025 11:14:22 +0100
Subject: [PATCH 01/17] Send notification about started review to other
 reviewers

* so that reviewers know that somebody has started looking at the record
  and can remove it from their (mental) TODO list
---
 invenio_config_tuw/config.py             |  4 ++++
 invenio_config_tuw/curations/__init__.py |  2 ++
 invenio_config_tuw/curations/requests.py | 18 ++++++++++++++++++
 3 files changed, 24 insertions(+)

diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py
index b913ac4..6c092cc 100644
--- a/invenio_config_tuw/config.py
+++ b/invenio_config_tuw/config.py
@@ -24,6 +24,9 @@ from invenio_i18n import gettext as _
 from invenio_oauthclient.views.client import auto_redirect_login
 
 from .auth import TUWSSOSettingsHelper
+from .curations import (
+    TUWCurationRequestReviewNotificationBuilder as TUWReviewNotifBuilder,
+)
 from .curations import (
     TUWCurationRequestUploaderResubmitNotificationBuilder as TUWUploaderResubmitNotifBuilder,
 )
@@ -330,6 +333,7 @@ MAX_CONTENT_LENGTH = 100 * (1024**2)
 NOTIFICATIONS_BUILDERS = {
     **NOTIFICATIONS_BUILDERS,
     **CURATIONS_NOTIFICATIONS_BUILDERS,
+    TUWReviewNotifBuilder.type: TUWReviewNotifBuilder,
     TUWUploaderResubmitNotifBuilder.type: TUWUploaderResubmitNotifBuilder,
     UserNotificationBuilder.type: UserNotificationBuilder,
     GroupNotificationBuilder.type: GroupNotificationBuilder,
diff --git a/invenio_config_tuw/curations/__init__.py b/invenio_config_tuw/curations/__init__.py
index 1923bf4..244db8f 100644
--- a/invenio_config_tuw/curations/__init__.py
+++ b/invenio_config_tuw/curations/__init__.py
@@ -9,10 +9,12 @@
 
 from .requests import (
     TUWCurationRequest,
+    TUWCurationRequestReviewNotificationBuilder,
     TUWCurationRequestUploaderResubmitNotificationBuilder,
 )
 
 __all__ = (
     "TUWCurationRequest",
+    "TUWCurationRequestReviewNotificationBuilder",
     "TUWCurationRequestUploaderResubmitNotificationBuilder",
 )
diff --git a/invenio_config_tuw/curations/requests.py b/invenio_config_tuw/curations/requests.py
index 5e3d3c3..abc0625 100644
--- a/invenio_config_tuw/curations/requests.py
+++ b/invenio_config_tuw/curations/requests.py
@@ -9,7 +9,9 @@
 
 from invenio_curations.notifications.builders import (
     CurationRequestActionNotificationBuilder,
+    CurationRequestReviewNotificationBuilder,
 )
+from invenio_curations.notifications.generators import GroupMembersRecipient
 from invenio_curations.requests.curation import (
     CurationCreateAndSubmitAction,
     CurationRequest,
@@ -17,6 +19,7 @@ from invenio_curations.requests.curation import (
     CurationSubmitAction,
 )
 from invenio_notifications.services.uow import NotificationOp
+from invenio_requests.notifications.filters import UserRecipientFilter
 from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter
 from invenio_users_resources.notifications.generators import UserRecipient
 
@@ -34,6 +37,21 @@ class TUWCurationRequestUploaderResubmitNotificationBuilder(
     recipient_filters = [UserPreferencesRecipientFilter()]
 
 
+class TUWCurationRequestReviewNotificationBuilder(
+    CurationRequestReviewNotificationBuilder
+):
+    """Notification builder for review action."""
+
+    recipients = [
+        UserRecipient("request.created_by"),
+        GroupMembersRecipient("request.receiver"),
+    ]
+    recipient_filters = [
+        UserPreferencesRecipientFilter(),
+        UserRecipientFilter("executing_user"),
+    ]
+
+
 class TUWCurationResubmitAction(CurationResubmitAction):
     """Notify both uploader and reviewer on resubmit, and auto-review."""
 
-- 
GitLab


From 08ebb532c8ff2c9e4b47b84a83aa6b38bb626507 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Mon, 17 Mar 2025 11:15:33 +0100
Subject: [PATCH 02/17] Add some explanatory comments to the rdm-curation
 request machinery

* because it's very easy to get lost in the code without explanation
---
 invenio_config_tuw/curations/requests.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/invenio_config_tuw/curations/requests.py b/invenio_config_tuw/curations/requests.py
index abc0625..7adf3fd 100644
--- a/invenio_config_tuw/curations/requests.py
+++ b/invenio_config_tuw/curations/requests.py
@@ -26,6 +26,15 @@ from invenio_users_resources.notifications.generators import UserRecipient
 from ..notifications import TUWTaskOp
 from .tasks import auto_review_curation_request
 
+# Notification builders
+# ---------------------
+# They are used to generate notifications, and will primarily be used by the request
+# actions (see below).
+# Each notification builder has information about the target audience (recipients &
+# recipient filters), and means to extract relevant information from the notification
+# context.
+# The generated notifications will be handled by the registered notification backend.
+
 
 class TUWCurationRequestUploaderResubmitNotificationBuilder(
     CurationRequestActionNotificationBuilder
@@ -52,6 +61,15 @@ class TUWCurationRequestReviewNotificationBuilder(
     ]
 
 
+# Request actions
+# ---------------
+# Requests are effectively state machines, which have states and transitions.
+# The transitions are modeled via the "request actions", and they perform some
+# code operation on activation.
+# These operations typically also include the generation of notifications via
+# notification builders (see above).
+
+
 class TUWCurationResubmitAction(CurationResubmitAction):
     """Notify both uploader and reviewer on resubmit, and auto-review."""
 
@@ -97,6 +115,12 @@ class TUWCurationCreateAndSubmitAction(CurationCreateAndSubmitAction):
         return super().execute(identity, uow)
 
 
+# Request type
+# ------------
+# As mentioned above, requests are basically state machines.
+# The individual pieces (e.g. request actions) are registered in the request type.
+
+
 class TUWCurationRequest(CurationRequest):
     """Customized curation request class with modified resubmit action."""
 
-- 
GitLab


From a7a65c12c0d63600517296d4b779a89476a981a8 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Mon, 17 Mar 2025 20:07:53 +0100
Subject: [PATCH 03/17] Allow email notifications to render a different
 template

* by temporarily overriding the `notification.type`
---
 invenio_config_tuw/notifications/backends.py | 23 +++++++++++++++++++-
 invenio_config_tuw/notifications/builders.py |  6 ++++-
 2 files changed, 27 insertions(+), 2 deletions(-)

diff --git a/invenio_config_tuw/notifications/backends.py b/invenio_config_tuw/notifications/backends.py
index c4ad17c..dd7d937 100644
--- a/invenio_config_tuw/notifications/backends.py
+++ b/invenio_config_tuw/notifications/backends.py
@@ -7,6 +7,8 @@
 
 """Custom notification backends for TU Wien."""
 
+from contextlib import contextmanager
+
 from flask import current_app
 from invenio_mail.tasks import send_email
 from invenio_notifications.backends.email import EmailNotificationBackend
@@ -15,6 +17,24 @@ from marshmallow_utils.html import strip_html
 from ..proxies import current_config_tuw
 
 
+@contextmanager
+def temp_hack_notification_type(notification):
+    """Temporarily override the ``notification.type``.
+
+    This enables notifications built by our custom user/group notification builders
+    to render templates that are not ``{user,group}-notification.jinja``.
+    """
+    old_notif_type = notification.type
+    temp_type = getattr(
+        notification, "template_name", notification.context.get("template_name", None)
+    )
+    if temp_type:
+        notification.type = temp_type.rstrip(".jinja")
+
+    yield notification
+    notification.type = old_notif_type
+
+
 class TUWEmailNotificationBackend(EmailNotificationBackend):
     """Email notification backend extended for the use cases at TU Wien.
 
@@ -24,7 +44,8 @@ class TUWEmailNotificationBackend(EmailNotificationBackend):
 
     def send(self, notification, recipient):
         """Mail sending implementation."""
-        content = self.render_template(notification, recipient)
+        with temp_hack_notification_type(notification):
+            content = self.render_template(notification, recipient)
         subject = content["subject"]
 
         # if a site identifier is configured, we set is as prefix for email subjects
diff --git a/invenio_config_tuw/notifications/builders.py b/invenio_config_tuw/notifications/builders.py
index 6eb0d61..e16fa5d 100644
--- a/invenio_config_tuw/notifications/builders.py
+++ b/invenio_config_tuw/notifications/builders.py
@@ -27,10 +27,12 @@ class UserNotificationBuilder(NotificationBuilder):
         cls,
         receiver,
         subject,
-        message,
+        message=None,
         html_message=None,
         plain_message=None,
         md_message=None,
+        template_name=None,
+        **kwargs,
     ):
         """Build notification with context."""
         return Notification(
@@ -42,6 +44,8 @@ class UserNotificationBuilder(NotificationBuilder):
                 "html_message": html_message,
                 "plain_message": plain_message,
                 "md_message": md_message,
+                "template_name": template_name,
+                **kwargs,
             },
         )
 
-- 
GitLab


From 6f6bc080c0f7d7de5e3b21ee3039f8545c2f30bd Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Mon, 17 Mar 2025 12:58:21 +0100
Subject: [PATCH 04/17] Send notification emails to owners about published
 edits

* whenever somebody other than the record owner publishes an edit for a
  record, the owner gets a notification with a summary of the changes
* if the owner publishes the edit themselves, then no such mail will be
  sent out - same for the system identity
* shared links with "edit" permissions still require a login to access
  the deposit form anyway, so edits are always backed by users
---
 invenio_config_tuw/services.py                |  78 ++++++++++++-
 invenio_config_tuw/tasks.py                   |  63 ++++++++++
 .../email/metadata-edit.jinja                 | 108 ++++++++++++++++++
 3 files changed, 248 insertions(+), 1 deletion(-)
 create mode 100644 invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja

diff --git a/invenio_config_tuw/services.py b/invenio_config_tuw/services.py
index 3e0afbc..9b50de4 100644
--- a/invenio_config_tuw/services.py
+++ b/invenio_config_tuw/services.py
@@ -9,8 +9,11 @@
 
 """Overrides for core services."""
 
+from collections import namedtuple
 from datetime import datetime
 
+import dictdiffer
+from flask import current_app
 from invenio_curations.services.components import (
     CurationComponent as BaseCurationComponent,
 )
@@ -18,9 +21,10 @@ from invenio_drafts_resources.services.records.components import ServiceComponen
 from invenio_pidstore.models import PIDStatus
 from invenio_rdm_records.services.components import DefaultRecordsComponents
 from invenio_records_resources.services.uow import TaskOp
+from invenio_requests.resolvers.registry import ResolverRegistry
 
 from .proxies import current_config_tuw
-from .tasks import send_publication_notification
+from .tasks import send_metadata_edit_notification, send_publication_notification
 
 
 class ParentAccessSettingsComponent(ServiceComponent):
@@ -96,10 +100,82 @@ class PublicationDateComponent(ServiceComponent):
         )
 
 
+class MetadataEditNotificationComponent(ServiceComponent):
+    """Component for notifying the record owner about metadata edits."""
+
+    def publish(self, identity, draft=None, record=None):
+        """Send a notification to the record owner about edits they haven't made."""
+        if not record or not (owner := record.parent.access.owned_by):
+            return
+
+        owner_id = str(owner.owner_id)
+        has_revisions = record and list(record.revisions)
+        is_system_or_owner = identity and str(identity.id) in ["system", owner_id]
+        if not has_revisions or is_system_or_owner:
+            # skip if there are no revisions, or if the owner published the edit, or
+            # if the system is the publisher (mostly happens in scripts)
+            return
+
+        # compare the latest revision with the `draft` - this seems to list more
+        # details (e.g. access settings) than comparisons with the `record`
+        *_, latest_rev = record.revisions
+        diffs = list(
+            dictdiffer.diff(latest_rev, draft, dot_notation=False, expand=True)
+        )
+        if not latest_rev or not diffs:
+            return
+
+        Diff = namedtuple("Diff", ["field", "change"])
+        additions, changes, removals = [], [], []
+        for diff in diffs:
+            type_, field_path, change = diff
+            field_path = field_path.copy()
+
+            # if certain fields didn't have values in the draft, their fields may not
+            # have been present at all in its dict form - in this case, the change will
+            # include the field's name (similar for removals, but other way):
+            #
+            # ('add', ['metadata'], [('version', '1')])
+            # ('add', ['metadata'], [('languages', [{'id': 'eng'}])])
+            # ('remove', ['metadata'], [('dates', [{'date': '2025', 'type': {'id': 'accepted'}}])])
+            if type_ in ["add", "remove"] and len(change) == 1:
+                field_name, change_ = change[0]
+                if isinstance(field_name, str):
+                    field_path.append(field_name)
+                    change = change_
+
+            difference = Diff(field_path, change)
+            if type_ == "add":
+                additions.append(difference)
+            elif type_ == "remove":
+                removals.append(difference)
+            elif type_ == "change":
+                changes.append(difference)
+            else:
+                current_app.logger.warn(
+                    f"(calculating record diff) unknown diff type: {diff}"
+                )
+
+        # note: we use the "resolver registry" from Invenio-Requests here because it
+        # operates on "raw" objects rather than service result items (which we don't
+        # have available here) like the one from Invenio-Notifications does
+        self.uow.register(
+            TaskOp(
+                send_metadata_edit_notification,
+                record.pid.pid_value,
+                ResolverRegistry.reference_identity(identity),
+                additions,
+                removals,
+                changes,
+            )
+        )
+
+
 TUWRecordsComponents = [
     *DefaultRecordsComponents,
     ParentAccessSettingsComponent,
     PublicationNotificationComponent,
     PublicationDateComponent,
+    MetadataEditNotificationComponent,
     CurationComponent,
 ]
diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py
index 36f5b1d..f86bcb8 100644
--- a/invenio_config_tuw/tasks.py
+++ b/invenio_config_tuw/tasks.py
@@ -7,6 +7,7 @@
 
 """Celery tasks running in the background."""
 
+from difflib import HtmlDiff
 from typing import Optional
 
 from celery import shared_task
@@ -16,6 +17,9 @@ from invenio_access.permissions import system_identity
 from invenio_accounts.proxies import current_datastore
 from invenio_db import db
 from invenio_files_rest.models import FileInstance
+from invenio_notifications.registry import (
+    EntityResolverRegistry as NotificationsResolverRegistry,
+)
 from invenio_notifications.tasks import broadcast_notification
 from invenio_rdm_records.proxies import current_rdm_records_service as records_service
 
@@ -83,6 +87,65 @@ def send_publication_notification(recid: str, user_id: Optional[str] = None):
     broadcast_notification(notification.dumps())
 
 
+@shared_task(ignore_reuslt=True)
+def send_metadata_edit_notification(
+    recid: str,
+    publisher: dict,
+    additions: list,
+    removals: list,
+    changes: list,
+):
+    """Send an email to the record's owner about a published edit."""
+    record = records_service.read(identity=system_identity, id_=recid)._obj
+    record.relations.clean()
+    if (owner := record.parent.access.owner) is None:
+        current_app.logger.warn(
+            f"Record '{recid}' has no owner to notify about the published edit!"
+        )
+        return
+
+    description_diff_table = None
+    for change in changes:
+        field_path, (old, new) = change
+        if field_path[0] == "metadata" and field_path[1] == "description":
+            diff = HtmlDiff(tabsize=4, wrapcolumn=100)
+            old, new = old.splitlines(keepends=True), new.splitlines(keepends=True)
+            description_diff_table = diff.make_table(old, new)
+
+    # parse the most interesting changes for the user out of the dictionary diffs
+    md_field_names = {"rights": "licenses"}
+    updated_metadata_fields = set()
+    updated_access_settings = False
+    for change in [*additions, *removals, *changes]:
+        field_path, *_ = change
+        section, field_name = (
+            (field_path[0], field_path[1])
+            if len(field_path) > 1
+            else (None, field_path[0])
+        )
+        if section == "metadata":
+            field_name = md_field_names.get(field_name) or field_name
+            updated_metadata_fields.add(field_name.replace("_", " ").capitalize())
+        elif section == "access":
+            updated_access_settings = True
+
+    # note: in contrast to the "resolver registry" from Invenio-Requests, the one from
+    # Invenio-Notifications resolves expanded service result item dictionaries that
+    # can be passed on to notifications
+    notification = UserNotificationBuilder.build(
+        receiver=owner.dump(),
+        subject=f'Edits for your record "{record.metadata['title']}" were published',
+        recid=record.pid.pid_value,
+        record=record,
+        publisher=NotificationsResolverRegistry.resolve_entity(publisher),
+        updated_access_settings=updated_access_settings,
+        updated_metadata_fields=sorted(updated_metadata_fields),
+        description_diff_table=description_diff_table,
+        template_name="metadata-edit.jinja",
+    )
+    broadcast_notification(notification.dumps())
+
+
 @shared_task
 def remove_dead_files():
     """Remove dead file instances (that don't have a URI) from the database.
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja
new file mode 100644
index 0000000..32bab3a
--- /dev/null
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja
@@ -0,0 +1,108 @@
+{%- set context = notification.context -%}
+{%- set publisher = context.publisher -%}
+{%- set receiver = recipient.data -%}
+{%- set record_url = url_for("invenio_app_rdm_records.record_detail", pid_value=context.recid, _external=True) -%}
+
+{#- Subject line for emails #}
+{%- block subject -%}
+    {{ context.subject }}
+{%- endblock subject %}
+
+{#- HTML body for emails #}
+{%- block html_body -%}
+{%- if context.description_diff_table %}
+<style>
+.diff_sub{
+  background-color: orangered;
+}
+.diff_add {
+  background-color: yellowgreen;
+}
+.diff_next /* hide links */ {
+  display: none
+}
+.diff_header {
+  min-width: 2em; text-align: center;
+}
+[nowrap] /* for diff lines */ {
+  padding: 0 0.5em;
+}
+</style>
+{%- endif %}
+<p>
+    Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+</p>
+<p>
+    A new edit for your record "{{ context.record.metadata.title }}" was just published by @{{ publisher.username }}.
+</p>
+{%- if context.updated_access_settings %}
+<p>
+    The access/visibility settings were updated.
+</p>
+{%- endif %}
+{%- if context.updated_metadata_fields %}
+<p>
+    The following metadata fields were updated:
+    <ul>
+        {%- for field_name in context.updated_metadata_fields %}
+        <li>{{ field_name }}</li>
+        {%- endfor %}
+    </ul>
+</p>
+{%- endif %}
+{%- if context.description_diff_table %}
+<p>
+    The description has changed as follows:
+
+    {{ context.description_diff_table|safe }}
+</p>
+{%- endif %}
+<p>
+    See the new revision on its landing page: <a href="{{ record_url }}">{{ record_url}}</a>
+</p>
+<p>
+    From: {{ config.THEME_SITENAME }}
+</p>
+{%- endblock html_body -%}
+
+{#- Plaintext body for emails #}
+{%- block plain_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+A new edit for your record "{{ context.record.metadata.title }}" was just published by @{{ publisher.username }}.
+
+{%- if context.updated_access_settings %}
+The access/visibility settings were updated.
+{%- endif %}
+{%- if context.updated_metadata_fields %}
+The following metadata fields were updated:
+{%- for field_name in context.updated_metadata_fields %}
+* {{ field_name }}
+{%- endfor %}
+{%- endif %}
+
+See the new revision on its landing page: {{ record_url }}
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock plain_body -%}
+
+{#- Markdown body for chat #}
+{%- block md_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+A new edit for your record "{{ context.record.metadata.title }}" was just published by @{{ publisher.username }}.
+
+{%- if context.updated_access_settings %}
+The access/visibility settings were updated.
+{%- endif %}
+{%- if context.updated_metadata_fields %}
+The following metadata fields were updated:
+{%- for field_name in context.updated_metadata_fields %}
+* {{ field_name }}
+{%- endfor %}
+{%- endif %}
+
+See the new revision on its landing page: [{{ record_url }}]({{ record_url }})
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock md_body -%}
-- 
GitLab


From 31ae27b5e6f8ef7cbcd5b652ba757ba14f20686c Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 18 Mar 2025 19:32:54 +0100
Subject: [PATCH 05/17] Update publication notification to use a separate jinja
 template

* this is quite a bit simpler than cobbling together the message in a
  normal python function
---
 invenio_config_tuw/tasks.py                   | 59 +++++++------------
 .../email/group-notification.jinja            |  1 -
 .../email/record-publication.jinja            | 51 ++++++++++++++++
 tests/test_misc.py                            | 10 +++-
 4 files changed, 79 insertions(+), 42 deletions(-)
 create mode 100644 invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja

diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py
index f86bcb8..906c29f 100644
--- a/invenio_config_tuw/tasks.py
+++ b/invenio_config_tuw/tasks.py
@@ -8,13 +8,11 @@
 """Celery tasks running in the background."""
 
 from difflib import HtmlDiff
-from typing import Optional
 
 from celery import shared_task
 from celery.schedules import crontab
 from flask import current_app, url_for
 from invenio_access.permissions import system_identity
-from invenio_accounts.proxies import current_datastore
 from invenio_db import db
 from invenio_files_rest.models import FileInstance
 from invenio_notifications.registry import (
@@ -27,21 +25,15 @@ from .notifications import UserNotificationBuilder
 
 
 @shared_task(ignore_result=True)
-def send_publication_notification(recid: str, user_id: Optional[str] = None):
+def send_publication_notification(recid: str):
     """Send the record uploader an email about the publication of their record."""
     record = records_service.read(identity=system_identity, id_=recid)._obj
-    record_title = record["metadata"]["title"]
-    if user_id is not None:
-        user = current_datastore.get_user(user_id)
-    else:
-        owner = record.parent.access.owner
-        if owner is not None and owner.owner_type == "user":
-            user = owner.resolve()
-        else:
-            current_app.logger.warn(
-                f"Couldn't find owner of record '{recid}' for sending email!"
-            )
-            return
+    record.relations.clean()
+    if (owner := record.parent.access.owner) is None:
+        current_app.logger.warn(
+            f"Record '{recid}' has no owner to notify about its publication!"
+        )
+        return
 
     # build the message
     datacite_test_mode = current_app.config["DATACITE_TEST_MODE"]
@@ -49,40 +41,29 @@ def send_publication_notification(recid: str, user_id: Optional[str] = None):
         doi = record["pids"]["doi"]["identifier"]
 
         if datacite_test_mode:
-            doi_base_url = "https://handle.test.datacite.org"
-            doi_type = "DOI-like handle"
+            base_url = "https://handle.test.datacite.org"
+            pid_type = "DOI-like handle"
         else:
-            doi_base_url = "https://doi.org"
-            doi_type = "DOI"
+            base_url = "https://doi.org"
+            pid_type = "DOI"
 
-        doi_url = f"{doi_base_url}/{doi}"
-        link_line = f"It is now available under the following {doi_type}: {doi_url}"
-        link_line_html = f'It is now available under the following {doi_type}: <a href="{doi_url}">{doi_url}</a>'
+        pid_url = f"{base_url}/{doi}"
 
     else:
-        landing_page_url = url_for(
+        pid_type = "URL"
+        pid_url = url_for(
             "invenio_app_rdm_records.record_detail",
             pid_value=record.pid.pid_value,
             _external=True,
         )
-        link_line = f"It is now available under the following URL: {landing_page_url}"
-        link_line_html = f'It is now available under the following URL: <a href="{landing_page_url}">{landing_page_url}</a>'
-
-    publish_line = f'Your record "{record_title}" just got published!'
-    edits_line = "Metadata edits for this record will *not* require another review."
-    edits_line_html = (
-        "Metadata edits for this record will <em>not</em> require another review."
-    )
-
-    message = "\n".join([publish_line, link_line, "", edits_line])
-    html_message = "<br />".join([publish_line, link_line_html, "", edits_line_html])
 
     # send the notification
-    notification = UserNotificationBuilder().build(
-        receiver={"user": user.id},
-        subject=f'Your record "{record_title}" was published',
-        message=message,
-        html_message=html_message,
+    notification = UserNotificationBuilder.build(
+        receiver=owner.dump(),
+        subject=f'Your record "{record.metadata['title']}" was published',
+        record=record,
+        record_pid={"type": pid_type, "url": pid_url},
+        template_name="record-publication.jinja",
     )
     broadcast_notification(notification.dumps())
 
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
index 2473431..1582454 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
@@ -36,4 +36,3 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 
 From: {{ config.THEME_SITENAME }}
 {%- endblock md_body -%}
-
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja
new file mode 100644
index 0000000..d4dc681
--- /dev/null
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja
@@ -0,0 +1,51 @@
+{%- set context = notification.context -%}
+{%- set record = context.record -%}
+{%- set record_pid = context.record_pid -%}
+{%- set receiver = recipient.data -%}
+
+{#- Subject line for emails #}
+{%- block subject -%}
+    {{ context.subject }}
+{%- endblock subject %}
+
+{#- HTML body for emails #}
+{%- block html_body -%}
+<p>
+    Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+</p>
+<p>
+    Your record "{{ record.metadata.title }}" just got published!
+    <br />
+    It is now available under the following {{ record_pid.type }}: <a href="{{ record_pid.url }}">{{ record_pid.url }}</a>
+</p>
+<p>
+    Metadata edits for this record will <em>not</em> require another review.
+</p>
+<p>
+    From: {{ config.THEME_SITENAME }}
+</p>
+{%- endblock html_body -%}
+
+{#- Plaintext body for emails #}
+{%- block plain_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+Your record "{{ record.metadata.title }}" just got published!
+It is now available under the following {{ record_pid.type }}: {{ record_pid.url }}
+
+Metadata edits for this record will *not* require another review.
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock plain_body -%}
+
+{#- Markdown body for chat #}
+{%- block md_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+Your record "{{ record.metadata.title }}" just got published!
+It is now available under the following {{ record_pid.type }}: [{{ record_pid.url }}]({{ record_pid.url }})
+
+Metadata edits for this record will *not* require another review.
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock md_body -%}
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 3e56618..94d747a 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -32,9 +32,15 @@ def test_send_publication_notification_email(example_record, mocker):
             "context": {
                 "receiver": {"user": record.parent.access.owned_by.resolve().id},
                 "subject": f'Your record "{title}" was published',
-                "message": f'Your record "{title}" just got published!\nIt is now available under the following URL: https://localhost/records/{recid}\n\nMetadata edits for this record will *not* require another review.',
-                "html_message": f'Your record "{title}" just got published!<br />It is now available under the following URL: <a href="https://localhost/records/{recid}">https://localhost/records/{recid}</a><br /><br />Metadata edits for this record will <em>not</em> require another review.',
+                "record": dict(record),
+                "record_pid": {
+                    "type": "URL",
+                    "url": f"https://localhost/records/{recid}",
+                },
+                "template_name": "record-publication.jinja",
+                "message": None,
                 "plain_message": None,
+                "html_message": None,
                 "md_message": None,
             },
         }
-- 
GitLab


From c5a7315cf9306f7a604daf170224541a3d45ddf6 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 18 Mar 2025 20:12:14 +0100
Subject: [PATCH 06/17] Fix incorrect usage of `lstrip()` and `rstrip()`

* what we actually want to do is `removeprefix()` and `removesuffix()`
---
 invenio_config_tuw/notifications/backends.py | 2 +-
 invenio_config_tuw/startup/config.py         | 6 ++++--
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/invenio_config_tuw/notifications/backends.py b/invenio_config_tuw/notifications/backends.py
index dd7d937..1b85887 100644
--- a/invenio_config_tuw/notifications/backends.py
+++ b/invenio_config_tuw/notifications/backends.py
@@ -29,7 +29,7 @@ def temp_hack_notification_type(notification):
         notification, "template_name", notification.context.get("template_name", None)
     )
     if temp_type:
-        notification.type = temp_type.rstrip(".jinja")
+        notification.type = temp_type.removesuffix(".jinja")
 
     yield notification
     notification.type = old_notif_type
diff --git a/invenio_config_tuw/startup/config.py b/invenio_config_tuw/startup/config.py
index 2ed303a..e5f3f62 100644
--- a/invenio_config_tuw/startup/config.py
+++ b/invenio_config_tuw/startup/config.py
@@ -81,7 +81,7 @@ def _make_site_url(suffix):
         url = current_app.config.get("THEME_SITEURL", "")
 
     # do a little dance to make sure there's no extra slashes
-    return (url.rstrip("/") + "/" + suffix.lstrip("/")).rstrip("/")
+    return (url.removesuffix("/") + "/" + suffix.removeprefix("/")).removesuffix("/")
 
 
 def assemble_db_uri_from_parts(app):
@@ -186,7 +186,9 @@ def assemble_site_urls_from_parts(app):
         theme_siteurl = theme_siteurl or f"{preferred_scheme}://{server_name}"
 
     elif theme_siteurl:
-        server_name = theme_siteurl.lstrip("http://").lstrip("https://").split("/")[0]
+        server_name = (
+            theme_siteurl.removeprefix("http://").removeprefix("https://").split("/")[0]
+        )
         app.logger.info(
             f"No SERVER_NAME set, calculated value '{server_name}' from THEME_SITEURL: '{theme_siteurl}'"
         )
-- 
GitLab


From d329b36b521329a0e2a53191cb1dcd74ebf83cdc Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 18 Mar 2025 22:03:35 +0100
Subject: [PATCH 07/17] Simplify `sorted_app_loader()` to satisfy SonarQube

---
 invenio_config_tuw/config.py | 17 ++++++++---------
 1 file changed, 8 insertions(+), 9 deletions(-)

diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py
index 6c092cc..e7dad56 100644
--- a/invenio_config_tuw/config.py
+++ b/invenio_config_tuw/config.py
@@ -400,15 +400,14 @@ def sorted_app_loader(app, entry_points=None, modules=None):
     def init_func(ext):
         ext(app)
 
-    if entry_points:
-        for entry_point in entry_points:
-            unique_eps = set(iter_entry_points(group=entry_point))
-            for ep in sorted(unique_eps, key=attrgetter("name")):
-                try:
-                    init_func(ep.load())
-                except Exception:
-                    app.logger.error(f"Failed to initialize entry point: {ep}")
-                    raise
+    for entry_point in entry_points or []:
+        unique_eps = set(iter_entry_points(group=entry_point))
+        for ep in sorted(unique_eps, key=attrgetter("name")):
+            try:
+                init_func(ep.load())
+            except Exception:
+                app.logger.error(f"Failed to initialize entry point: {ep}")
+                raise
     if modules:
         for m in modules:
             try:
-- 
GitLab


From 7a35fbb2353f74974304e7f85aa1d8f597576dd5 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 18 Mar 2025 22:08:17 +0100
Subject: [PATCH 08/17] Remove old TODO entry in permission policy

* communities are created by administrators on request
---
 invenio_config_tuw/permissions/policies.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/invenio_config_tuw/permissions/policies.py b/invenio_config_tuw/permissions/policies.py
index ee5ad8f..b3d600f 100644
--- a/invenio_config_tuw/permissions/policies.py
+++ b/invenio_config_tuw/permissions/policies.py
@@ -285,9 +285,6 @@ class TUWCommunityPermissionPolicy(CommunityPermissionPolicy):
     # and disable write operations if the system is in read-only mode
     #
     # current state: invenio-communities v14.0.0
-    #
-    # TODO: discuss who should have permissions to create communities
-    #       -> new role?
     can_create = [SystemProcess(), DisableIfReadOnly()]
 
     # fmt: off
-- 
GitLab


From 6ddfce0b020a367d9fcff05da3da6a6993e0ef68 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 18 Mar 2025 22:18:09 +0100
Subject: [PATCH 09/17] Add 'for' attribute in control label

* to make SonarQube happy
---
 invenio_config_tuw/users/templates/notifications_settings.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invenio_config_tuw/users/templates/notifications_settings.html b/invenio_config_tuw/users/templates/notifications_settings.html
index 6d2f85c..1d8f914 100644
--- a/invenio_config_tuw/users/templates/notifications_settings.html
+++ b/invenio_config_tuw/users/templates/notifications_settings.html
@@ -50,7 +50,7 @@ details.
                 </p>
                 <div class="ui toggle on-off checkbox">
                   <input type="checkbox" name="{{ enabled_field.id }}" id="{{ enabled_field.id }}" {{ "checked" if notif_enabled else "" }}>
-                  <label>
+                  <label for="{{ enabled_field.id }}">
                     <small class="ml-10">{{ enabled_field.description }}</small>
                   </label>
                 </div>
-- 
GitLab


From cd9cd922a6ac7b839e0a06770e75e687b5f4d94a Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Wed, 19 Mar 2025 16:53:45 +0100
Subject: [PATCH 10/17] Add test for the metadata edit notification

---
 tests/test_misc.py | 55 +++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 54 insertions(+), 1 deletion(-)

diff --git a/tests/test_misc.py b/tests/test_misc.py
index 94d747a..6a68b6d 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -10,11 +10,17 @@
 from logging.handlers import SMTPHandler
 
 from flask import g
+from invenio_access.permissions import Identity, system_identity
 from invenio_db import db
+from invenio_rdm_records.proxies import current_rdm_records_service as service
+from invenio_records_resources.services.uow import UnitOfWork
 
 import invenio_config_tuw
 from invenio_config_tuw.startup import register_smtp_error_handler
-from invenio_config_tuw.tasks import send_publication_notification
+from invenio_config_tuw.tasks import (
+    send_metadata_edit_notification,
+    send_publication_notification,
+)
 from invenio_config_tuw.users.utils import current_user_as_creator
 
 
@@ -50,6 +56,53 @@ def test_send_publication_notification_email(example_record, mocker):
         )
 
 
+def test_send_edit_notification_email(example_record, users, mocker):
+    """Test if published record edits send a notification to the record owner."""
+    recid = example_record.pid.pid_value
+    old_check_permission = service.check_permission
+    service.check_permission = (lambda *args, **kwargs: True).__get__(
+        service, type(service)
+    )
+
+    def _update_draft(title, identity):
+        """Update the draft with the given title."""
+        draft = service.edit(identity, recid)
+        new_data = draft.data.copy()
+        new_data["metadata"]["title"] = title
+        service.update_draft(identity, recid, new_data)
+
+        uow = UnitOfWork()
+        service.publish(identity, recid, uow=uow)
+        uow.commit()
+        return uow
+
+    # the system identity *should not* trigger the notification
+    uow = _update_draft("Different title", system_identity)
+    assert not [
+        op
+        for op in uow._operations
+        if getattr(op, "_celery_task", None) == send_metadata_edit_notification
+    ]
+
+    # the owner *should not* trigger the notification
+    uow = _update_draft("Yet another title", Identity(users[0].id))
+    assert not [
+        op
+        for op in uow._operations
+        if getattr(op, "_celery_task", None) == send_metadata_edit_notification
+    ]
+
+    # another user *should* trigger the notification
+    uow = _update_draft("Last new title", Identity(users[1].id))
+    assert [
+        op
+        for op in uow._operations
+        if getattr(op, "_celery_task", None) == send_metadata_edit_notification
+    ]
+
+    service.check_permission = old_check_permission
+
+
 def test_record_metadata_current_user_as_creator(client_with_login):
     """Test the auto-generation of a "creator" entry for the current user."""
     user = client_with_login._user
-- 
GitLab


From e5db925f34b87387fb10f5006b2830558731ea0f Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Wed, 19 Mar 2025 17:31:17 +0100
Subject: [PATCH 11/17] Set created & updated timestamps of names to datetime
 values

* it looks like these fields in the name entry records got turned into
  strings at some point in our TISS sync, causing issues during
  marshmallow serialization
---
 invenio_config_tuw/tiss/tasks.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/invenio_config_tuw/tiss/tasks.py b/invenio_config_tuw/tiss/tasks.py
index afe2161..31e26a0 100644
--- a/invenio_config_tuw/tiss/tasks.py
+++ b/invenio_config_tuw/tiss/tasks.py
@@ -151,6 +151,11 @@ def sync_names_from_tiss() -> dict:
 
                     # if we found a match via ORCID, we update it according to the TISS data
                     name = svc.read(identity=system_identity, id_=name_voc_id)
+
+                    # reset created & updated timestamps to their datetime/non-string
+                    # form, to avoid breakage in the serialization
+                    name._obj["updated"] = name._obj.updated
+                    name._obj["created"] = name._obj.created
                     new_name_data = update_name_data(
                         name.data, employee, tuw_ror_aliases
                     )
-- 
GitLab


From 30a75ee6da0f56badb95c1b8fcd86e8648fec81b Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Wed, 19 Mar 2025 22:33:08 +0100
Subject: [PATCH 12/17] Update the request reminder email tasks to make them
 simpler to test

* return the request/record IDs for which reminders will be sent out, to
  enable simpler asserts in the tests
* handle the case that request topic's PID cannot be resolved - this is
  likely when the topic has not been published yet
* include "today" in the "accepted review" reminder, as too new requests
  will be filtered out anyway and are not expected to add significant
  load
---
 invenio_config_tuw/curations/tasks.py | 37 +++++++++++++++++++--------
 1 file changed, 26 insertions(+), 11 deletions(-)

diff --git a/invenio_config_tuw/curations/tasks.py b/invenio_config_tuw/curations/tasks.py
index 2fef1b5..7956786 100644
--- a/invenio_config_tuw/curations/tasks.py
+++ b/invenio_config_tuw/curations/tasks.py
@@ -15,6 +15,7 @@ from celery.schedules import crontab
 from flask import current_app, url_for
 from invenio_access.permissions import system_identity
 from invenio_notifications.tasks import broadcast_notification
+from invenio_pidstore.models import PIDDoesNotExistError
 from invenio_rdm_records.proxies import current_rdm_records_service as records_service
 from invenio_requests.customizations.event_types import CommentEventType
 from invenio_requests.proxies import current_events_service as events_service
@@ -145,7 +146,7 @@ def send_open_requests_reminder_to_reviewers(request_ids: List[str]):
 @shared_task(ignore_result=True)
 def remind_uploaders_about_accepted_reviews(
     remind_after_days: Optional[List[int]] = None,
-):
+) -> List[str]:
     """Find curation reviews that were accepted a while ago and remind the uploaders.
 
     ``remind_after_days`` specifies after how many days of inactivity reminders
@@ -156,7 +157,6 @@ def remind_uploaders_about_accepted_reviews(
         remind_after_days = [1, 3, 5, 7, 10, 14, 30]
 
     # first, we get a list of all requests that have been updated in the last year
-    # but excluding today (with the brackets "[ ... }")
     #
     # note: the date query is intended to set a soft limit on the number of results
     #       to avoid unbounded degradation over time
@@ -173,10 +173,11 @@ def remind_uploaders_about_accepted_reviews(
         q=(
             "type:rdm-curation AND "
             "status:accepted AND "
-            f"updated:[{start_date} TO {today}}}"
+            f"updated:[{start_date} TO {today}]"
         ),
     )
 
+    records_reminded = []
     now = datetime.now(tz=UTC)
     for request in accepted_curation_requests:
         if isinstance(request, dict):
@@ -184,18 +185,27 @@ def remind_uploaders_about_accepted_reviews(
             # BEWARE: other than for resolving the topic, this is useless!
             request = requests_service.record_cls(request)
 
-        record = request.topic.resolve()
-        if record.is_published:
-            continue
+        try:
+            # quick sanity check: don't notify about weird zombie requests
+            record = request.topic.resolve()
+            if record.is_published:
+                continue
+        except PIDDoesNotExistError:
+            pass
 
         # check if we're hitting one of the reminder dates
         timestamp = _get_last_request_action_timestamp(request)
         if abs((now - timestamp).days) in remind_after_days:
             send_acceptance_reminder_to_uploader.delay(record.pid.pid_value)
+            records_reminded.append(record.pid.pid_value)
+
+    return records_reminded
 
 
 @shared_task(ignore_result=True)
-def remind_reviewers_about_open_reviews(remind_after_days: Optional[List[int]] = None):
+def remind_reviewers_about_open_reviews(
+    remind_after_days: Optional[List[int]] = None,
+) -> List[str]:
     """Remind a user about having an accepted review for an unpublished record.
 
     ``remind_after_days`` specifies after how many days of inactivity reminders
@@ -220,10 +230,13 @@ def remind_reviewers_about_open_reviews(remind_after_days: Optional[List[int]] =
             # BEWARE: other than for resolving the topic, this is useless!
             request = requests_service.record_cls(request)
 
-        # quick sanity check: don't notify about weird zombie requests
-        record = request.topic.resolve()
-        if record.is_published:
-            continue
+        try:
+            # quick sanity check: don't notify about weird zombie requests
+            record = request.topic.resolve()
+            if record and record.is_published:
+                continue
+        except PIDDoesNotExistError:
+            pass
 
         # check if we're hitting one of the reminder dates
         timestamp = _get_last_request_action_timestamp(request)
@@ -233,6 +246,8 @@ def remind_reviewers_about_open_reviews(remind_after_days: Optional[List[int]] =
     if stale_request_ids:
         send_open_requests_reminder_to_reviewers.delay(stale_request_ids)
 
+    return stale_request_ids
+
 
 @shared_task(ignore_result=True)
 def auto_review_curation_request(request_id: str):
-- 
GitLab


From 3eef9237c89a7cb722c4121ae97ae6a8937d3570 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Wed, 19 Mar 2025 22:40:18 +0100
Subject: [PATCH 13/17] Add "roles" fixture to tests

---
 tests/conftest.py | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/tests/conftest.py b/tests/conftest.py
index 331b76e..7103c2c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -145,6 +145,21 @@ def users(app, db):
     return [user1, user2]
 
 
+@pytest.fixture()
+def roles(app, db):
+    """Create required roles."""
+    with db.session.begin_nested():
+        datastore = app.extensions["security"].datastore
+        role = datastore.create_role(
+            id=app.config["CURATIONS_MODERATION_ROLE"],
+            name=app.config["CURATIONS_MODERATION_ROLE"],
+            description="Publication request reviewers",
+        )
+
+    db.session.commit()
+    return [role]
+
+
 @pytest.fixture()
 def client_with_login(client, users):
     """A test client for the app with a logged-in user."""
-- 
GitLab


From 48e24807cfca19707e5211d5ab6bbff38f1af3fb Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Wed, 19 Mar 2025 22:40:47 +0100
Subject: [PATCH 14/17] Add tests for the custom rdm-curations workflows

---
 tests/conftest.py       |   1 +
 tests/test_curations.py | 174 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 175 insertions(+)
 create mode 100644 tests/test_curations.py

diff --git a/tests/conftest.py b/tests/conftest.py
index 7103c2c..9c387a8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -258,6 +258,7 @@ def example_record(app, db, files_loc, users, resource_types):
                     }
                 },
             ],
+            "description": app.config["APP_RDM_DEPOSIT_FORM_DEFAULTS"]["description"],
             "publication_date": "2024-12-31",
             "publisher": "TU Wien",
             "resource_type": {"id": "dataset"},
diff --git a/tests/test_curations.py b/tests/test_curations.py
new file mode 100644
index 0000000..0968599
--- /dev/null
+++ b/tests/test_curations.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2025 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Tests the rdm-curation utilities."""
+
+import pytest
+from flask import current_app
+from invenio_access.permissions import system_identity
+from invenio_curations.proxies import current_curations_service as curations_service
+from invenio_rdm_records.proxies import current_rdm_records_service as records_service
+from invenio_requests.proxies import current_requests_service as requests_service
+
+from invenio_config_tuw.curations.tasks import (
+    auto_generate_curation_request_remarks,
+    auto_review_curation_request,
+    remind_reviewers_about_open_reviews,
+    remind_uploaders_about_accepted_reviews,
+)
+
+
+@pytest.fixture()
+def example_draft(app, db, files_loc, users, resource_types):
+    """Example draft."""
+    data = {
+        "access": {
+            "record": "public",
+            "files": "public",
+        },
+        "files": {
+            "enabled": False,
+        },
+        "metadata": {
+            "creators": [
+                {
+                    "person_or_org": {
+                        "family_name": "Darksouls",
+                        "given_name": "John",
+                        "type": "personal",
+                    }
+                },
+            ],
+            "description": app.config["APP_RDM_DEPOSIT_FORM_DEFAULTS"]["description"],
+            "publication_date": "2024-12-31",
+            "publisher": "TU Wien",
+            "resource_type": {"id": "dataset"},
+            "title": "Exciting dataset",
+        },
+    }
+
+    # create the draft & make the first user the owner of the record
+    draft = records_service.create(system_identity, data)._obj
+    draft.parent.access.owned_by = users[0]
+    draft.parent.commit()
+    draft.commit()
+    db.session.commit()
+
+    return draft
+
+
+def test_curation_auto_remarks(example_record, roles):
+    """Test the automatic generation of remarks on rdm-curation requests."""
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_record.pid.pid_value}}
+    )
+    remarks = auto_generate_curation_request_remarks(request._obj)
+    assert len(remarks) == 2
+    assert [r for r in remarks if "description" in r]
+    assert [r for r in remarks if "license" in r]
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
+
+
+def test_auto_accept_curation_requests(example_record, roles):
+    """Test the automatic acceptance of rdm-curation requests."""
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_record.pid.pid_value}}
+    )
+
+    # if not enabled, don't auto-accept requests
+    current_app.config["CONFIG_TUW_AUTO_ACCEPT_CURATION_REQUESTS"] = False
+    auto_review_curation_request(request.id)
+    request = requests_service.read(system_identity, request.id)._obj
+    assert request.status == "submitted"
+
+    # if enabled, don auto-accept requests
+    current_app.config["CONFIG_TUW_AUTO_ACCEPT_CURATION_REQUESTS"] = True
+    auto_review_curation_request(request.id)
+    request = requests_service.read(system_identity, request.id)._obj
+    assert request.status == "accepted"
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
+    current_app.config["CONFIG_TUW_AUTO_ACCEPT_CURATION_REQUESTS"] = False
+
+
+def test_remind_reviewers_about_open_requests(example_record, example_draft, roles):
+    """Test the automatic reminder emails to reviewers about open curation requests."""
+    # create request & force-sync search index
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_record.pid.pid_value}}
+    )
+    requests_service.indexer.index(request._obj, arguments={"refresh": "wait_for"})
+
+    # we're dealing with published records here, so we don't expect any notifications
+    assert request._obj.status == "submitted"
+    assert len(remind_reviewers_about_open_reviews()) == 0
+    assert len(remind_reviewers_about_open_reviews([1])) == 0
+    assert len(remind_reviewers_about_open_reviews([0])) == 0
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
+
+    # -------------------------------------
+    # now we do the same thing with a draft
+    # -------------------------------------
+
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_draft.pid.pid_value}}
+    )
+    requests_service.indexer.index(request._obj, arguments={"refresh": "wait_for"})
+
+    assert request._obj.status == "submitted"
+    assert len(remind_reviewers_about_open_reviews()) == 0
+    assert len(remind_reviewers_about_open_reviews([1])) == 0
+    assert len(remind_reviewers_about_open_reviews([0])) == 1
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
+
+
+def test_remind_uploaders_about_accepted_requests(example_record, example_draft, roles):
+    """Test the automatic reminder emails to users about accepted curation requests."""
+    # create request & force-sync search index
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_record.pid.pid_value}}
+    )
+    if request._obj.status != "accepted":
+        request = requests_service.execute_action(system_identity, request.id, "review")
+        request = requests_service.execute_action(system_identity, request.id, "accept")
+    requests_service.indexer.index(request._obj, arguments={"refresh": "wait_for"})
+
+    # we're dealing with published records here, so we don't expect any notifications
+    assert request._obj.status == "accepted"
+    assert len(remind_uploaders_about_accepted_reviews()) == 0
+    assert len(remind_uploaders_about_accepted_reviews([1])) == 0
+    assert len(remind_uploaders_about_accepted_reviews([0])) == 0
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
+
+    # -------------------------------------
+    # now we do the same thing with a draft
+    # -------------------------------------
+
+    request = curations_service.create(
+        system_identity, {"topic": {"record": example_draft.pid.pid_value}}
+    )
+    if request._obj.status != "accepted":
+        request = requests_service.execute_action(system_identity, request.id, "review")
+        request = requests_service.execute_action(system_identity, request.id, "accept")
+    requests_service.indexer.index(request._obj, arguments={"refresh": "wait_for"})
+
+    assert request._obj.status == "accepted"
+    assert len(remind_uploaders_about_accepted_reviews()) == 0
+    assert len(remind_uploaders_about_accepted_reviews([1])) == 0
+    assert len(remind_uploaders_about_accepted_reviews([0])) == 1
+
+    # clean the rdm-curation request for other tests
+    requests_service.delete(system_identity, request.id)
-- 
GitLab


From 28a5e85fa99b6748f3f1c9975603487ea99ff99e Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Thu, 20 Mar 2025 09:56:11 +0100
Subject: [PATCH 15/17] Use jinja templates for reminder emails

---
 invenio_config_tuw/curations/tasks.py         | 36 +++---------
 .../email/acceptance-reminder.jinja           | 45 +++++++++++++++
 .../email/review-reminder.jinja               | 56 +++++++++++++++++++
 3 files changed, 110 insertions(+), 27 deletions(-)
 create mode 100644 invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
 create mode 100644 invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja

diff --git a/invenio_config_tuw/curations/tasks.py b/invenio_config_tuw/curations/tasks.py
index 7956786..e8a7236 100644
--- a/invenio_config_tuw/curations/tasks.py
+++ b/invenio_config_tuw/curations/tasks.py
@@ -79,8 +79,6 @@ def send_acceptance_reminder_to_uploader(recid: str):
     draft = records_service.read_draft(identity=system_identity, id_=recid)._obj
     if (owner := draft.parent.access.owned_by) is None:
         return
-    else:
-        owner = owner.resolve()
 
     # NOTE: this requires the UI app, which is the base for the celery app
     deposit_form_url = url_for(
@@ -88,21 +86,13 @@ def send_acceptance_reminder_to_uploader(recid: str):
         pid_value=draft.pid.pid_value,
         _external=True,
     )
-    title = draft.metadata["title"]
-    message = (
-        f'Reminder: Your record "{title}" has been reviewed and is ready for publication.\n'
-        f"You can publish it on the deposit form: {deposit_form_url}"
-    )
-    html_message = (
-        f'Reminder: Your record "{title}" has been reviewed and is ready for publication.<br />'
-        f'You can publish it on the <a href="{deposit_form_url}">deposit form</a>.'
-    )
 
     notification = UserNotificationBuilder.build(
-        receiver={"user": owner.id},
+        receiver=owner.dumps(),
         subject="ℹ️ Reminder: Your record is ready for publication!",
-        message=message,
-        html_message=html_message,
+        draft=draft,
+        deposit_form_url=deposit_form_url,
+        template_name="acceptance-reminder.jinja",
     )
     broadcast_notification(notification.dumps())
 
@@ -112,12 +102,10 @@ def send_open_requests_reminder_to_reviewers(request_ids: List[str]):
     """Send a reminder notification about open curation requests to the reviewers."""
     from .notifications import GroupNotificationBuilder
 
-    reviewer_role_name = current_app.config["CURATIONS_MODERATION_ROLE"]
-    plain_lines, html_lines = [], []
+    requests = []
     for reqid in request_ids:
         # we assume that only a single request has the same UUID
-        request = list(requests_service.search(system_identity, q=f"uuid:{reqid}"))[0]
-        title = request["title"]
+        request, *_ = list(requests_service.search(system_identity, q=f"uuid:{reqid}"))
 
         # NOTE: the reported "self_html" URL is currently broken
         # (points to "/requests/..." rather than "/me/requests/...")
@@ -127,18 +115,12 @@ def send_open_requests_reminder_to_reviewers(request_ids: List[str]):
             _external=True,
         )
 
-        plain_lines.append(f'* "{title}": {request_url}')
-        html_lines.append(f'<li>"<a href="{request_url}">{title}</a>"</li>')
+        requests.append({"request": dict(request), "request_url": request_url})
 
-    plain_list = "\n".join(plain_lines)
-    message = f"Reminder: Please review the following requests, they've been waiting for a response for a while:\n{plain_list}"
-    html_list = "<ul>" + "".join(html_lines) + "</ul>"
-    html_message = f"<p>Reminder: Please review the following requests, they've been waiting for a response for a while:</p><p>{html_list}</p>"
     notification = GroupNotificationBuilder.build(
-        receiver={"group": reviewer_role_name},
+        receiver={"group": current_app.config["CURATIONS_MODERATION_ROLE"]},
         subject="⚠️ Reminder: There are some open curation requests",
-        message=message,
-        html_message=html_message,
+        requests=requests,
     )
     broadcast_notification(notification.dumps())
 
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
new file mode 100644
index 0000000..7ee0870
--- /dev/null
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
@@ -0,0 +1,45 @@
+{%- set context = notification.context -%}
+{%- set publisher = context.publisher -%}
+{%- set draft = context.draft -%}
+{%- set deposit_form_url = context.deposit_form_url -%}
+{%- set receiver = recipient.data -%}
+
+{#- Subject line for emails #}
+{%- block subject -%}
+    {{ context.subject }}
+{%- endblock subject %}
+
+{#- HTML body for emails #}
+{%- block html_body -%}
+<p>
+    Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+</p>
+<p>
+    <strong>Reminder:</strong>
+    Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.<br />
+    You can publish it on the <a href="{{ deposit_form_url }}">deposit form</a>.
+</p>
+<p>
+    From: {{ config.THEME_SITENAME }}
+</p>
+{%- endblock html_body -%}
+
+{#- Plaintext body for emails #}
+{%- block plain_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+*Reminder:* Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.
+You can publish it on the deposit form: {{ deposit_form_url }}
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock plain_body -%}
+
+{#- Markdown body for chat #}
+{%- block md_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+**Reminder:** Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.
+You can publish it on the deposit form: [{{ deposit_form_url }}]({{ deposit_form_url }})
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja
new file mode 100644
index 0000000..cc82e84
--- /dev/null
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja
@@ -0,0 +1,56 @@
+{%- set context = notification.context -%}
+{%- set publisher = context.publisher -%}
+{%- set requests = context.requests -%}
+{%- set receiver = recipient.data -%}
+
+{#- Subject line for emails #}
+{%- block subject -%}
+    {{ context.subject }}
+{%- endblock subject %}
+
+{#- HTML body for emails #}
+{%- block html_body -%}
+<p>
+    Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+</p>
+<p>
+    <strong>Reminder:</strong>
+    Please review the following requests, they've been waiting for a response for a while:
+</p>
+<p>
+    <ul>
+        {%- for request in requests -%}
+        <li>"<a href="{{ request.request_url }}">{{ request.request.title }}</a>"</li>
+        {%- endfor -%}
+    </ul>
+</p>
+<p>
+    From: {{ config.THEME_SITENAME }}
+</p>
+{%- endblock html_body -%}
+
+{#- Plaintext body for emails #}
+{%- block plain_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+*Reminder:* Please review the following requests, they've been waiting for a response for a while:\n{plain_list}
+
+{%- for request in requests -%}
+    * "{{ request.request.title }}": {{ request.request_url }}
+{%- endfor -%}
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock plain_body -%}
+
+{#- Markdown body for chat #}
+{%- block md_body -%}
+Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.username }}!
+
+**Reminder:** Please review the following requests, they've been waiting for a response for a while:
+
+{%- for request in requests -%}
+    * "{{ request.request.title }}": {{ request.request_url }}
+{%- endfor -%}
+
+From: {{ config.THEME_SITENAME }}
+{%- endblock md_body -%}
-- 
GitLab


From b95b521da6ff21bb373d1019625441dbb6383091 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Thu, 20 Mar 2025 10:31:09 +0100
Subject: [PATCH 16/17] Align notification templates with Invenio-Theme-TUW

* at this point it would be nice to have a common base template for
  notifications for the greeting, footer, etc.
---
 .../email/acceptance-reminder.jinja              | 16 ++++++++++++----
 .../email/group-notification.jinja               | 16 ++++++++++++----
 .../email/metadata-edit.jinja                    | 16 ++++++++++++----
 .../email/record-publication.jinja               | 16 ++++++++++++----
 .../email/review-reminder.jinja                  | 16 ++++++++++++----
 .../email/user-notification.jinja                | 16 ++++++++++++----
 6 files changed, 72 insertions(+), 24 deletions(-)

diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
index 7ee0870..e21cc7d 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja
@@ -3,6 +3,7 @@
 {%- set draft = context.draft -%}
 {%- set deposit_form_url = context.deposit_form_url -%}
 {%- set receiver = recipient.data -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -19,8 +20,11 @@
     Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.<br />
     You can publish it on the <a href="{{ deposit_form_url }}">deposit form</a>.
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -31,7 +35,9 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 *Reminder:* Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.
 You can publish it on the deposit form: {{ deposit_form_url }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -41,5 +47,7 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 **Reminder:** Your record "{{ draft.metadata.title }}" has been reviewed and is ready for publication.
 You can publish it on the deposit form: [{{ deposit_form_url }}]({{ deposit_form_url }})
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
index 1582454..6a9593a 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/group-notification.jinja
@@ -1,5 +1,6 @@
 {%- set context = notification.context -%}
 {%- set receiver = recipient.data -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -14,8 +15,11 @@
 <p>
     {{ context.html_message or context.message }}
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -25,7 +29,9 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 
 {{ context.plain_message or context.message }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -34,5 +40,7 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 
 {{ context.md_message or context.message }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja
index 32bab3a..24f0f9a 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja
@@ -2,6 +2,7 @@
 {%- set publisher = context.publisher -%}
 {%- set receiver = recipient.data -%}
 {%- set record_url = url_for("invenio_app_rdm_records.record_detail", pid_value=context.recid, _external=True) -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -60,8 +61,11 @@
 <p>
     See the new revision on its landing page: <a href="{{ record_url }}">{{ record_url}}</a>
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -83,7 +87,9 @@ The following metadata fields were updated:
 
 See the new revision on its landing page: {{ record_url }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -104,5 +110,7 @@ The following metadata fields were updated:
 
 See the new revision on its landing page: [{{ record_url }}]({{ record_url }})
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja
index d4dc681..c2918f7 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja
@@ -2,6 +2,7 @@
 {%- set record = context.record -%}
 {%- set record_pid = context.record_pid -%}
 {%- set receiver = recipient.data -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -21,8 +22,11 @@
 <p>
     Metadata edits for this record will <em>not</em> require another review.
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -35,7 +39,9 @@ It is now available under the following {{ record_pid.type }}: {{ record_pid.url
 
 Metadata edits for this record will *not* require another review.
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -47,5 +53,7 @@ It is now available under the following {{ record_pid.type }}: [{{ record_pid.ur
 
 Metadata edits for this record will *not* require another review.
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja
index cc82e84..7bc4e77 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja
@@ -2,6 +2,7 @@
 {%- set publisher = context.publisher -%}
 {%- set requests = context.requests -%}
 {%- set receiver = recipient.data -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -24,8 +25,11 @@
         {%- endfor -%}
     </ul>
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -39,7 +43,9 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
     * "{{ request.request.title }}": {{ request.request_url }}
 {%- endfor -%}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -52,5 +58,7 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
     * "{{ request.request.title }}": {{ request.request_url }}
 {%- endfor -%}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
diff --git a/invenio_config_tuw/users/templates/invenio_notifications/email/user-notification.jinja b/invenio_config_tuw/users/templates/invenio_notifications/email/user-notification.jinja
index 1582454..6a9593a 100644
--- a/invenio_config_tuw/users/templates/invenio_notifications/email/user-notification.jinja
+++ b/invenio_config_tuw/users/templates/invenio_notifications/email/user-notification.jinja
@@ -1,5 +1,6 @@
 {%- set context = notification.context -%}
 {%- set receiver = recipient.data -%}
+{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %}
 
 {#- Subject line for emails #}
 {%- block subject -%}
@@ -14,8 +15,11 @@
 <p>
     {{ context.html_message or context.message }}
 </p>
-<p>
-    From: {{ config.THEME_SITENAME }}
+<hr />
+<p style="font-size:smaller;">
+    {%- trans account_settings_link=account_settings_link -%}
+    This is an auto-generated message. To manage notifications, visit your <a href="{{ account_settings_link }}">account settings</a>.
+    {%- endtrans -%}
 </p>
 {%- endblock html_body -%}
 
@@ -25,7 +29,9 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 
 {{ context.plain_message or context.message }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your account settings: {{ account_settings_link }}
+{%- endtrans -%}
 {%- endblock plain_body -%}
 
 {#- Markdown body for chat #}
@@ -34,5 +40,7 @@ Hey, {{ receiver.profile.given_name or receiver.profile.full_name or receiver.us
 
 {{ context.md_message or context.message }}
 
-From: {{ config.THEME_SITENAME }}
+{%- trans account_settings_link=account_settings_link -%}
+This is an auto-generated message. To manage notifications, visit your [account settings]({{ account_settings_link }}).
+{%- endtrans -%}
 {%- endblock md_body -%}
-- 
GitLab


From 9d68d55188b42b4f29376f5bfc48ad457d336a22 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Thu, 20 Mar 2025 11:04:05 +0100
Subject: [PATCH 17/17] Bump version to v2025.1.12

---
 CHANGES.rst                    | 11 +++++++++++
 invenio_config_tuw/__init__.py |  2 +-
 2 files changed, 12 insertions(+), 1 deletion(-)

diff --git a/CHANGES.rst b/CHANGES.rst
index 2d48d20..4d1bfd6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,17 @@ Changes
 =======
 
 
+Version 2025.1.12 (released 2025-03-20)
+
+- Send notification about started reviews to other reviewers
+- Allow user/group notifications to render a different template
+- Send notifications to record owners about edits published by somebody else
+- Fix incorrect usage of `lstrip()` and `rstrip()`
+- Add tests for curation-related workflows and notifications
+- Align notification templates with those from Invenio-Theme-TUW
+- A few minor changes for SonarQube
+
+
 Version 2025.1.11 (released 2025-03-11)
 
 - Omit the default value for "version" on the deposit form
diff --git a/invenio_config_tuw/__init__.py b/invenio_config_tuw/__init__.py
index 3c5ad86..bf8af4b 100644
--- a/invenio_config_tuw/__init__.py
+++ b/invenio_config_tuw/__init__.py
@@ -9,6 +9,6 @@
 
 from .ext import InvenioConfigTUW
 
-__version__ = "2025.1.11"
+__version__ = "2025.1.12"
 
 __all__ = ("__version__", "InvenioConfigTUW")
-- 
GitLab