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] 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