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