diff --git a/CHANGES.rst b/CHANGES.rst index 2d48d2020ee508accf44c731324d16ad8fecc089..4d1bfd6e99789851accd2a767baf894cd321d9ac 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 3c5ad867a95e9cb8e6c1147c36fa02659caef371..bf8af4b5472da864f4ed306ecfcec8d26354a288 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") diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py index b913ac498cf67a652fe3fbe7902e9b7b634fdc41..e7dad56374c634b0bc51b9d38c73ab9a350ddec3 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, @@ -396,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: diff --git a/invenio_config_tuw/curations/__init__.py b/invenio_config_tuw/curations/__init__.py index 1923bf4a99c4afc44ad2e4175e3833651de29e8b..244db8f2ee693e082ac7477c9f56f54b997b0ac7 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 5e3d3c3b105eaeff38700d95d92324a622511168..7adf3fdc82ecaa256130a4b3c0b0ce305cb8b609 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,12 +19,22 @@ 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 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 @@ -34,6 +46,30 @@ 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"), + ] + + +# 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.""" @@ -79,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.""" diff --git a/invenio_config_tuw/curations/tasks.py b/invenio_config_tuw/curations/tasks.py index 2fef1b56b1caa1da76b05223334780f4518afc93..e8a72369f1af254060111c25ee2f72ac010e2b4e 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 @@ -78,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( @@ -87,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()) @@ -111,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/...") @@ -126,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()) @@ -145,7 +128,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 +139,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 +155,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 +167,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 +212,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 +228,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): diff --git a/invenio_config_tuw/notifications/backends.py b/invenio_config_tuw/notifications/backends.py index c4ad17ce4b4faad5d3244e703bedd5d688b784d9..1b858875902518436baf149f2d8888fe327b3f90 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.removesuffix(".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 6eb0d616fd12c2640cfdf2e223d86821f2afde90..e16fa5dac4c27509c393748676fa4146e155255f 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, }, ) diff --git a/invenio_config_tuw/permissions/policies.py b/invenio_config_tuw/permissions/policies.py index ee5ad8f21a9687ac640a3f3948966fc0c82223b7..b3d600f172defa2f009db9fc25f8173dfb9cea17 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 diff --git a/invenio_config_tuw/services.py b/invenio_config_tuw/services.py index 3e0afbc4a253c6a5ca9048663345910aeb31dd91..9b50de411b1f4036510b5b6c94ffbd8645070bc5 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/startup/config.py b/invenio_config_tuw/startup/config.py index 2ed303aefc47e839d8342f929ef0887b647bb69c..e5f3f623a08cc02bf98bb4a87a77e7f9f6486392 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}'" ) diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py index 36f5b1d7adccda726f7130bd58466ecdc8686167..906c29f82d17063ab6cb7c2fa5278db209f673b6 100644 --- a/invenio_config_tuw/tasks.py +++ b/invenio_config_tuw/tasks.py @@ -7,15 +7,17 @@ """Celery tasks running in the background.""" -from typing import Optional +from difflib import HtmlDiff 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 ( + EntityResolverRegistry as NotificationsResolverRegistry, +) from invenio_notifications.tasks import broadcast_notification from invenio_rdm_records.proxies import current_rdm_records_service as records_service @@ -23,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"] @@ -45,40 +41,88 @@ 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." + # send the notification + 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()) - 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, +@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()) diff --git a/invenio_config_tuw/tiss/tasks.py b/invenio_config_tuw/tiss/tasks.py index afe2161b7d8b38938588e8f0eb097908b7c6aef2..31e26a0776811953ae0e75660cd96c327de958dd 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 ) 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 0000000000000000000000000000000000000000..e21cc7d28cb7765a07f5827f0da78f3e7589d049 --- /dev/null +++ b/invenio_config_tuw/users/templates/invenio_notifications/email/acceptance-reminder.jinja @@ -0,0 +1,53 @@ +{%- set context = notification.context -%} +{%- set publisher = context.publisher -%} +{%- 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 -%} + {{ 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> +<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 -%} + +{#- 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 }} + +{%- 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 #} +{%- 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 }}) + +{%- 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 2473431afb6afd2e574a16de1241c86468c4e008..6a9593af0469f8d181a082ddfbfc835a6b6268c5 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,6 +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 new file mode 100644 index 0000000000000000000000000000000000000000..24f0f9a5fe22b615ea377cf0774949906c70040b --- /dev/null +++ b/invenio_config_tuw/users/templates/invenio_notifications/email/metadata-edit.jinja @@ -0,0 +1,116 @@ +{%- 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) -%} +{%- set account_settings_link = "{ui}/account/settings/notifications".format(ui=config.SITE_UI_URL) %} + +{#- 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> +<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 -%} + +{#- 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 }} + +{%- 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 #} +{%- 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 }}) + +{%- 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 new file mode 100644 index 0000000000000000000000000000000000000000..c2918f7fb4facb27616717248fbbe4da5e9bce74 --- /dev/null +++ b/invenio_config_tuw/users/templates/invenio_notifications/email/record-publication.jinja @@ -0,0 +1,59 @@ +{%- set context = notification.context -%} +{%- 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 -%} + {{ 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> +<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 -%} + +{#- 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. + +{%- 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 #} +{%- 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. + +{%- 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 new file mode 100644 index 0000000000000000000000000000000000000000..7bc4e77f2fcd37da691538241b34b05a84afc763 --- /dev/null +++ b/invenio_config_tuw/users/templates/invenio_notifications/email/review-reminder.jinja @@ -0,0 +1,64 @@ +{%- set context = notification.context -%} +{%- 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 -%} + {{ 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> +<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 -%} + +{#- 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 -%} + +{%- 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 #} +{%- 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 -%} + +{%- 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 1582454f903b552c5327bf42c5f3175e28fd5a58..6a9593af0469f8d181a082ddfbfc835a6b6268c5 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 -%} diff --git a/invenio_config_tuw/users/templates/notifications_settings.html b/invenio_config_tuw/users/templates/notifications_settings.html index 6d2f85c339e37b3f636bb6a7a4414f7090d95d8d..1d8f914893eb4761f9aa7536762b475153953678 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> diff --git a/tests/conftest.py b/tests/conftest.py index 331b76e87f32d0012ff51dc4ceee3efa2d2ac61d..9c387a8b93fc870aaad2dc5e2a2be9a10214fefb 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.""" @@ -243,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 0000000000000000000000000000000000000000..0968599e8b2dd5b0c2ae937cf414a07f3664a8cf --- /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) diff --git a/tests/test_misc.py b/tests/test_misc.py index 3e566180470f7e8b09b5f59f6702cecf99f76888..6a68b6db44560d496a2f702b22b0e46589264ef0 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 @@ -32,9 +38,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, }, } @@ -44,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