diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py index e635cbd60a72beb3e7f1165bdbcb8e2d9ce2b189..b1ae213912f0d77ddb8caaeb576fab0a80ee06f4 100644 --- a/invenio_config_tuw/config.py +++ b/invenio_config_tuw/config.py @@ -33,6 +33,7 @@ from .permissions import ( TUWRequestsPermissionPolicy, ) from .services import TUWRecordsComponents +from .tasks import auto_generate_curation_request_remarks from .users import ( TUWUserPreferencesSchema, TUWUserProfileSchema, @@ -78,6 +79,23 @@ If set to `None`, the first available option will be used: * the first entry for `APP_ALLOWED_HOSTS` """ +CONFIG_TUW_AUTO_ACCEPT_CURATION_REQUESTS = False +"""Whether or not the system should auto-accept curation requests. + +This can be either a boolean value to be returned for all requests, or it can be +a function that takes the request as argument and returns a boolean value. +Functions can be either supplied via reference, or via import string. +""" + +CONFIG_TUW_AUTO_COMMENT_CURATION_REQUESTS = auto_generate_curation_request_remarks +"""A function to automatically generate remarks for record curation requests. + +The function must take the request as argument and return a list of messages (strings) +to be used to create a system comment on the request. +Functions can be either supplied via reference, or via import string. +A value of ``None`` disables this feature. +""" + # Invenio-Mail # ============ diff --git a/invenio_config_tuw/ext.py b/invenio_config_tuw/ext.py index c7e2805601756b9ec71abc2c6df1d6e75b201742..d21bbc9bc9fa979270d72611d5f3d138a56c03c3 100644 --- a/invenio_config_tuw/ext.py +++ b/invenio_config_tuw/ext.py @@ -7,10 +7,13 @@ """Invenio module containing some customizations and configuration for TU Wien.""" +from typing import List + from flask import current_app from flask.config import Config from flask_minify import Minify from flask_security.signals import user_registered +from invenio_base.utils import obj_or_import_string from . import config from .auth.utils import auto_trust_user @@ -92,6 +95,26 @@ class InvenioConfigTUW(object): minify = Minify(app, static=False, go=False) app.extensions["flask-minify"] = minify + def auto_accept_record_curation_request(self, request) -> bool: + """Check if the request should be auto-accepted according to the config.""" + auto_accept = current_app.config.get( + "CONFIG_TUW_AUTO_ACCEPT_CURATION_REQUESTS", False + ) + if isinstance(auto_accept, bool): + return auto_accept + + return obj_or_import_string(auto_accept)(request) + + def generate_record_curation_request_remarks(self, request) -> List[str]: + """Generate remarks to automatically add as comment to the curation request.""" + generate_remarks = current_app.config.get( + "CONFIG_TUW_AUTO_COMMENT_CURATION_REQUESTS", None + ) + if generate_remarks is None: + return [] + + return obj_or_import_string(generate_remarks)(request) + @property def curations_enabled(self): """Shorthand for ``current_app.config.get["CONFIG_TUW_CURATIONS_ENABLED"]``.""" diff --git a/invenio_config_tuw/notifications.py b/invenio_config_tuw/notifications.py index 5bbd7b90152395e5edb783a44f8b5d88d5669594..24d2066cba2e8d47c5b35d0d035aa6b79b3832b0 100644 --- a/invenio_config_tuw/notifications.py +++ b/invenio_config_tuw/notifications.py @@ -187,7 +187,7 @@ class TUWCurationRequestUploaderResubmitNotificationBuilder( class TUWCurationResubmitAction(CurationResubmitAction): - """Notify both uploader and reviewer on resubmit.""" + """Notify both uploader and reviewer on resubmit, and auto-review.""" def execute(self, identity, uow): """Notify uploader when the record gets resubmitted for review.""" @@ -198,6 +198,36 @@ class TUWCurationResubmitAction(CurationResubmitAction): ) ) ) + uow.register( + TUWTaskOp(auto_review_curation_request, str(self.request.id), countdown=15) + ) + return super().execute(identity, uow) + + +class TUWCurationSubmitAction(CurationSubmitAction): + """Submit action with a hook for automatic reviews. + + Note: It looks like this isn't really being used, in favor of "create & submit". + """ + + def execute(self, identity, uow): + """Register auto-review task and perform the submit action.""" + uow.register( + TUWTaskOp(auto_review_curation_request, str(self.request.id), countdown=15) + ) + + return super().execute(identity, uow) + + +class TUWCurationCreateAndSubmitAction(CurationCreateAndSubmitAction): + """'Create & submit' action with a hook for automatic reviews.""" + + def execute(self, identity, uow): + """Register auto-review task and perform the 'create & submit' action.""" + uow.register( + TUWTaskOp(auto_review_curation_request, str(self.request.id), countdown=15) + ) + return super().execute(identity, uow) @@ -206,5 +236,7 @@ class TUWCurationRequest(CurationRequest): available_actions = { **CurationRequest.available_actions, + "create": TUWCurationCreateAndSubmitAction, + "submit": TUWCurationSubmitAction, "resubmit": TUWCurationResubmitAction, } diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py index a9dad2d6bbca8462a961825144b2494f9674e96e..a3fa8325884434211f78fed988ff86b69943888f 100644 --- a/invenio_config_tuw/tasks.py +++ b/invenio_config_tuw/tasks.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2023 TU Wien. +# Copyright (C) 2023-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. @@ -9,20 +9,27 @@ import copy from collections import defaultdict +from datetime import UTC, date, datetime, timedelta from difflib import SequenceMatcher from typing import List, Optional import requests from celery import shared_task -from flask import current_app, render_template +from celery.schedules import crontab +from flask import current_app, render_template, url_for from invenio_access.permissions import system_identity from invenio_accounts.proxies import current_datastore from invenio_db import db from invenio_mail.tasks import send_email +from invenio_notifications.tasks import broadcast_notification from invenio_rdm_records.proxies import current_rdm_records_service as records_service from invenio_records_resources.services.uow import RecordIndexOp, UnitOfWork +from invenio_requests.customizations.event_types import CommentEventType +from invenio_requests.proxies import current_events_service as events_service +from invenio_requests.proxies import current_requests_service as requests_service from invenio_vocabularies.contrib.names.api import Name +from .proxies import current_config_tuw from .tiss import Employee, fetch_tiss_data @@ -107,6 +114,53 @@ def _calc_name_distance( return fn_dist + ln_dist +def auto_generate_curation_request_remarks(request): + """Auto-generate remarks on the curation request based on simple rules.""" + record = request.topic.resolve() + remarks = [] + + # check if the description has been edited + deposit_form_defaults = current_app.config.get("APP_RDM_DEPOSIT_FORM_DEFAULTS", {}) + default_description = deposit_form_defaults.get("description", None) + if callable(default_description): + default_description = default_description() + + description = record.metadata["description"] or "" + if description == default_description: + remarks.append("The description is still the default template, please edit.") + elif "to be edited" in description.lower(): + remarks.append( + "The description looks like it's meant to still be edited, please check." + ) + + # check if a license has been applied + if not record.metadata.get("rights", []): + remarks.append( + "Not assigning a license strongly restricts the legal reusability (all rights are reserved). Is this intentional?" + ) + + return remarks + + +def _get_last_request_action_timestamp(request): + """Get the timestamp of the last log event on the request, or its creation time.""" + # check if the request has been sitting around for a while + events = events_service.search( + identity=system_identity, + request_id=request["id"], + ) + log_events = [e for e in events if e["type"] == "L"] + if not log_events: + # curation requests without any log events are in "submitted" state, + # and we need to look at the creation/update time + timestamp = datetime.fromisoformat(request["created"]) + else: + # otherwise, we look at the last log event + timestamp = datetime.fromisoformat(log_events[-1]["created"]) + + return timestamp + + @shared_task(ignore_result=True) def sync_names_from_tiss() -> dict: """Look up TU Wien employees via TISS and update the names vocabulary.""" @@ -238,3 +292,228 @@ def send_publication_notification_email(recid: str, user_id: Optional[str] = Non "recipients": [user.email], } ) + + +@shared_task(ignore_result=True) +def send_acceptance_reminder_to_uploader(recid: str): + """Send a reminder notification about the accepted review to the uploader.""" + from .notifications import UserNotificationBuilder + + 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( + "invenio_app_rdm_records.deposit_edit", + 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}, + subject="â„¹ï¸ Reminder: Your record is ready for publication!", + message=message, + html_message=html_message, + ) + broadcast_notification(notification.dumps()) + + +@shared_task(ignore_result=True) +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 = [], [] + 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"] + + # NOTE: the reported "self_html" URL is currently broken + # (points to "/requests/..." rather than "/me/requests/...") + request_url = url_for( + "invenio_app_rdm_requests.user_dashboard_request_view", + request_pid_value=request["id"], + _external=True, + ) + + plain_lines.append(f'* "{title}": {request_url}') + html_lines.append(f'<li>"<a href="{request_url}">{title}</a>"</li>') + + 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}, + subject="âš ï¸ Reminder: There are some open curation requests", + message=message, + html_message=html_message, + ) + broadcast_notification(notification.dumps()) + + +@shared_task(ignore_result=True) +def remind_uploaders_about_accepted_reviews( + remind_after_days: Optional[List[int]] = None, +): + """Find curation reviews that were accepted a while ago and remind the uploaders. + + ``remind_after_days`` specifies after how many days of inactivity reminders + should be sent out to reviewers. + Default: ``[1, 3, 5, 7, 10, 14, 30]`` + """ + if remind_after_days is None: + 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 + # also, we don't expect any requests that haven't been updated in over a year + # to still be relevant for notifications + # + # note: querying for "L"-type "accepted" events won't work, as the information + # about the action is stored in the payload which is disabled for indexing + # as of InvenioRDM v12 + start_date = (date.today() - timedelta(days=365)).isoformat() + today = date.today().isoformat() + accepted_curation_requests = requests_service.search( + identity=system_identity, + q=( + "type:rdm-curation AND " + "status:accepted AND " + f"updated:[{start_date} TO {today}}}" + ), + ) + + now = datetime.now(tz=UTC) + for request in accepted_curation_requests: + if isinstance(request, dict): + # we don't want dictionaries, we want request API classes + # 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 + + # 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) + + +@shared_task(ignore_result=True) +def remind_reviewers_about_open_reviews(remind_after_days: Optional[List[int]] = None): + """Remind a user about having an accepted review for an unpublished record. + + ``remind_after_days`` specifies after how many days of inactivity reminders + should be sent out to reviewers. + Default: ``[1, 3, 5, 7, 10, 14, 30]`` + """ + if remind_after_days is None: + remind_after_days = [1, 3, 5, 7, 10, 14, 30] + + # note: we don't expect a lot of results for this query at any time, + # as the number of open requests should be low at any point + open_curation_requests = requests_service.search( + identity=system_identity, + q="type:rdm-curation AND (status:submitted OR status:resubmitted)", + ) + + now = datetime.now(tz=UTC) + stale_request_ids = [] + for request in open_curation_requests: + if isinstance(request, dict): + # we don't want dictionaries, we want request API classes + # 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 + + # 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: + stale_request_ids.append(request["id"]) + + if stale_request_ids: + send_open_requests_reminder_to_reviewers.delay(stale_request_ids) + + +@shared_task(ignore_result=True) +def auto_review_curation_request(request_id: str): + """Have the system automatically accept a submission request.""" + request = requests_service.read(id_=request_id, identity=system_identity)._obj + if request.status not in ["submitted", "resubmitted"]: + return + + # if configured, let the system automatically start a review and accept + auto_accept = current_config_tuw.auto_accept_record_curation_request(request) + if auto_accept: + requests_service.execute_action( + identity=system_identity, + id_=request_id, + action="review", + ) + + # auto-generate a mini review about the record + remarks = current_config_tuw.generate_record_curation_request_remarks(request) + if remarks: + events_service.create( + identity=system_identity, + request_id=request_id, + event_type=CommentEventType, + data={ + "payload": { + "content": "\n".join([f"<p>{remark}</p>" for remark in remarks]), + "format": "html", + } + }, + ) + + if auto_accept: + requests_service.execute_action( + identity=system_identity, + id_=request_id, + action="accept", + data={ + "payload": { + "content": "<p>Automatically accepted by the system</p>", + "format": "html", + } + }, + ) + + +CELERY_BEAT_SCHEDULE = { + "tiss-name-sync": { + "task": "invenio_config_tuw.tasks.sync_names_from_tiss", + "schedule": crontab(minute=0, hour=3, day_of_week="sat"), + }, + "reviewers-open-requests-reminder": { + "task": "invenio_config_tuw.tasks.remind_reviewers_about_open_reviews", + "schedule": crontab(minute=30, hour=8), + }, + "uploaders-acceptance-reminder": { + "task": "invenio_config_tuw.tasks.remind_uploaders_about_accepted_reviews", + "schedule": crontab(minute=30, hour=8), + }, +}