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