diff --git a/cap/config.py b/cap/config.py index 7b54ecd387..64817deded 100644 --- a/cap/config.py +++ b/cap/config.py @@ -106,6 +106,8 @@ def _(x): CAP_SEND_MAIL = True MAIL_DEFAULT_SENDER = SUPPORT_EMAIL +CAP_MAIL_HOST_URL = "https://analysispreservation.cern.ch" +CAP_MAIL_HOST_API_URL = "https://analysispreservation.cern.ch/api" # For Flask-mail variables, the defaults are used, found here: # https://pythonhosted.org/Flask-Mail/#configuring-flask-mail diff --git a/cap/modules/fixtures/schemas/cms-questionnaire.json b/cap/modules/fixtures/schemas/cms-questionnaire.json index 5ac804d541..8b7d485b3b 100644 --- a/cap/modules/fixtures/schemas/cms-questionnaire.json +++ b/cap/modules/fixtures/schemas/cms-questionnaire.json @@ -4,10 +4,244 @@ "fullname":"CMS Statistics Questionnaire", "experiment":"CMS", "is_indexed":true, - "use_deposit_as_record":true, "config": { - "reviewable": true + "reviewable": true, + "notifications": { + "actions": { + "publish": [ + { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }] + }, + "recipients": { + "bcc": [{ + "method": "get_cms_stat_recipients" + }, { + "method": "get_owner" + }, { + "method": "get_submitter" + }, { + "checks": [{ + "path": "parton_distribution_functions", + "condition": "exists" + }], + "mails": { + "default": ["pdf-forum-placeholder@cern.ch"] + } + }, { + "checks": [{ + "path": "multivariate_discriminants.mva_use", + "condition": "equals", + "value": "Yes" + }], + "mails": { + "default": ["cms-conveners-placeholder@cern.ch"] + } + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published_plain.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [{ + "checks": [{ + "path": "analysis_context.cadi_id", + "condition": "exists" + }], + "mails": { + "formatted": [{ + "template": "{% if cadi_id %}hn-cms-{{ cadi_id }}@cern.ch{% endif %}", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }] + } + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [{ + "op": "or", + "checks": [{ + "path": "multivariate_discriminants.mva_use", + "condition": "equals", + "value": "Yes" + }], + "mails": { + "default": ["cms-conveners-jira-placeholder@cern.ch"] + } + }] + } + } + ], + "review": [ + { + "subject": { + "template": "Questionnaire for {{ cadi_id }} - New Review on Analysis | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_review.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "working_url" + }, { + "method": "creator_email" + }, { + "method": "submitter_email" + }] + }, + "recipients": { + "bcc": [{ + "method": "get_owner" + }, { + "method": "get_submitter" + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id }} - New Review on Analysis | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_review_plain.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "working_url" + }, { + "method": "creator_email" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [ + { + "checks": [{ + "path": "analysis_context.cadi_id", + "condition": "exists" + }, { + "path": "parton_distribution_functions", + "condition": "is_egroup_member", + "value": "comittee-mail" + }], + "mails": { + "formatted": [{ + "template": "{% if cadi_id %}hn-cms-{{ cadi_id }}@cern.ch{% endif %}", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }] + } + } + ] + } + } + ] + } + } }, + "use_deposit_as_record":true, "deposit_schema":{ "additionalProperties":false, "$schema":"http://json-schema.org/draft-04/schema#", diff --git a/cap/modules/fixtures/schemas/cms-questionnaire_v2.json b/cap/modules/fixtures/schemas/cms-questionnaire_v2.json index 021bb14cf1..31da966593 100644 --- a/cap/modules/fixtures/schemas/cms-questionnaire_v2.json +++ b/cap/modules/fixtures/schemas/cms-questionnaire_v2.json @@ -4,10 +4,287 @@ "fullname":"CMS Statistics Questionnaire", "experiment":"CMS", "is_indexed":true, - "use_deposit_as_record":true, "config": { - "reviewable": true + "reviewable": true, + "notifications": { + "actions": { + "publish": [ + { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }] + }, + "recipients": { + "bcc": [{ + "method": "get_cms_stat_recipients" + }, { + "method": "get_owner" + }, { + "method": "get_submitter" + }, { + "checks": [{ + "path": "parton_distribution_functions", + "condition": "exists" + }], + "mails": { + "default": ["pdf-forum-placeholder@cern.ch"] + } + }, { + "op": "or", + "checks": [ + { + "path": "multivariate_discriminants.mva_use", + "condition": "equals", + "value": "Yes" + }, { + "path": "ml_app_use", + "condition": "exists" + }, { + "path": "ml_survey.options", + "condition": "equals", + "value": "Yes" + }, { + "op": "and", + "checks": [ + { + "path": "multivariate_discriminants.use_of_centralized_cms_apps.options", + "condition": "exists" + }, { + "path": "multivariate_discriminants.use_of_centralized_cms_apps.options", + "condition": "is_not_in", + "value": "No" + } + ] + } + ], + "mails": { + "default": ["cms-conveners-placeholder@cern.ch"] + } + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published_plain.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [{ + "checks": [{ + "path": "analysis_context.cadi_id", + "condition": "exists" + }], + "mails": { + "formatted": [{ + "template": "{% if cadi_id %}hn-cms-{{ cadi_id }}@cern.ch{% endif %}", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }] + } + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id if cadi_id else \"\" }} {{ published_id }} - {{ \"New Version of Published Analysis\" if revision > 0 else \"New Published Analysis\" }} | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "method": "revision" + }, { + "method": "published_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_published.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "published_url" + }, { + "method": "cms_stats_committee_by_pag" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [{ + "op": "or", + "checks": [ + { + "path": "multivariate_discriminants.mva_use", + "condition": "equals", + "value": "Yes" + }, { + "path": "ml_app_use", + "condition": "exists" + }, { + "path": "ml_survey.options", + "condition": "equals", + "value": "Yes" + }, { + "op": "and", + "checks": [ + { + "path": "multivariate_discriminants.use_of_centralized_cms_apps.options", + "condition": "exists" + }, { + "path": "multivariate_discriminants.use_of_centralized_cms_apps.options", + "condition": "is_not_in", + "value": "No" + } + ] + } + ], + "mails": { + "default": ["cms-conveners-jira-placeholder@cern.ch"] + } + }] + } + } + ], + "review": [ + { + "subject": { + "template": "Questionnaire for {{ cadi_id }} - New Review on Analysis | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_review.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "working_url" + }, { + "method": "creator_email" + }, { + "method": "submitter_email" + }] + }, + "recipients": { + "bcc": [{ + "method": "get_owner" + }, { + "method": "get_submitter" + }] + } + }, { + "subject": { + "template": "Questionnaire for {{ cadi_id }} - New Review on Analysis | CERN Analysis Preservation", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }, + "body": { + "template_file": "mail/body/experiments/cms/questionnaire_message_review_plain.html", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }, { + "name": "title", + "path": "general_title" + },{ + "method": "working_url" + }, { + "method": "creator_email" + }, { + "method": "submitter_email" + }], + "base_template": "mail/analysis_plain_text.html", + "plain": true + }, + "recipients": { + "bcc": [ + { + "checks": [{ + "path": "analysis_context.cadi_id", + "condition": "exists" + }, { + "path": "parton_distribution_functions", + "condition": "is_egroup_member", + "value": "comittee-mail" + }], + "mails": { + "formatted": [{ + "template": "{% if cadi_id %}hn-cms-{{ cadi_id }}@cern.ch{% endif %}", + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }] + } + } + ] + } + } + ] + } + } }, + "use_deposit_as_record":true, "deposit_schema":{ "additionalProperties":false, "$schema":"http://json-schema.org/draft-04/schema#", diff --git a/cap/modules/mail/attributes.py b/cap/modules/mail/attributes.py new file mode 100644 index 0000000000..b99fc3fd24 --- /dev/null +++ b/cap/modules/mail/attributes.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2021 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. +from flask import current_app + +from .conditions import CONDITION_METHODS +from .custom import body as custom_body +from .custom import recipients as custom_recipients +from .custom import subject as custom_subjects +from .utils import ( + EMAIL_REGEX, + get_config_default, + populate_template_from_ctx, + update_mail_list, +) + + +def check_condition(record, condition, default_ctx={}): + """ + Iterates conditions and return true/false. + + For each condition we have a group of checks to perform, which should + return True/False + + Each condition has the following: + - `checks`: the checks to be evaluated + - `op`: the operation to be used for all the checks (and/or) + - `mails`: the mail list + + Each check has their own attributes: + - `condition`: the type of check (e.g. exists, is_in, etc) + - `value`: the result + - `path`: the path that gets the field to be evaluated + + An example of conditions: + { + "checks": [{ + "path": "parton_distribution_functions", + "condition": "exists", + "value": true + }], + "mails": { + "default": ["pdf-forum-placeholder@cern.ch"] + } + } + + Nested checks are also allowed. + """ + # for each condition we have a group of checks to perform + # all of the checks should give a True/False result + operator = condition.get("op", "and") + checks = condition.get("checks", []) + check_results = [] + + for check in checks: + # get the method to apply, and use it on the required path/value + # for malformed conditions, we assume False + if check.get("checks"): + check_results.append(check_condition(record, check, default_ctx)) + elif check.get("condition"): + try: + method = CONDITION_METHODS[check["condition"]] + path = check.get("path") + value = check.get("value", True) + # TODO: maybe update with kwargs + check_results.append( + method(record, path, value, default_ctx=default_ctx) + ) + except (KeyError, Exception): + check_results.append(False) + else: + check_results.append(False) + + # due to all([]) == True, we make sure the list is not empty + if check_results: + # we check the validity of the condition depending on the operator: + # - if 'and', then we need all the items to be true + # - if 'or' we need at least 1 item to be true + if operator == "and": + return all(check_results) + elif operator == "or": + return any(check_results) + return False + + +def get_recipients_from_config(record, config, default_ctx={}): + """ + Get recipients from configuration. + + The `recipients` field differentiates 3 categories: + - recipients + - cc + - bcc + All 3 can be used to send a mail, and have their own mails and rules about + how they will be added. + + The rules are in 3 categories: + - `default`: the mails in the list will be added + - `method`: a method that returns a list of mail (for complicated options) + - `conditions`: mails will be added if a certain condition is true + + An example config of recipients: + "recipients": { + "bcc": [ + { + "method": "get_owner" + }, { + "mails": { + "default": ["some-recipient-placeholder@cern.ch"], + "formatted": [{ + "template": "{% if cadi_id %}hn-cms-{{ cadi_id }}@cern.ch{% endif %}", # noqa + "ctx": [{ + "name": "cadi_id", + "path": "analysis_context.cadi_id" + }] + }] + } + }, { + "checks": {...} + } + ] + } + """ + if not config: + return [] + + mails = [] + for item in config: + if isinstance(item, str): + mails.append(item) + elif isinstance(item, dict): + if item.get("checks"): + if not check_condition(record, item, default_ctx=default_ctx): + continue + + if item.get("method"): + if isinstance(item.get("method"), list): + methods = item.get("method") + else: + methods = [item.get("method")] + + for _method in methods: + try: + method = getattr(custom_recipients, _method) + result = method( + record, config=item, default_ctx=default_ctx + ) + if isinstance(result, list): + mails += result + else: + mails += [result] + except AttributeError as exc: + current_app.logger.error( + f"Recipients function not found. Skipping.\n" + f"Error: {exc.args[0]}" + ) + + if item.get("mails"): + mail_config = item.get("mails", {}) + update_mail_list( + record, mail_config, mails, default_ctx=default_ctx + ) + else: + continue + + # remove duplicates and possible empty values + _mails = [] + for mail in set(mails): + if isinstance(mail, str) and EMAIL_REGEX.match(mail): + _mails.append(mail) + + return _mails + + +def generate_recipients(record, config, default_ctx={}): + """ + Recipients generator for notification action. + + Using the `get_recipients_from_config` function, it retrieves and returns + 3 possible lists of mails: recipients, bcc, cc. + """ + re_config = config.get("recipients") + if not re_config: + return [], [], [] + + recipients = get_recipients_from_config( + record, re_config.get("recipients"), default_ctx=default_ctx + ) + cc = get_recipients_from_config( + record, re_config.get("cc"), default_ctx=default_ctx + ) + bcc = get_recipients_from_config( + record, re_config.get("bcc"), default_ctx=default_ctx + ) + + return recipients, cc, bcc + + +def generate_body(record, config, default_ctx={}): + """ + Body generator for notification action. + + It requires a template and a context (dict of vars-values), to populate it. + If no template is found, the default one will be used. + + This function will retrieve the template and context for the body (message) + as well as the base template and `plain` parameter. + + Example of message config: + "body": { + "body_template_file": "mail/message/questionnaire_message_published.html", + "ctx": [{ + "name": "title", + "path": "general_title" + }, { + "method": "submitter_email" + }], + "base_template_file": "mail/analysis_plain_text.html", + "plain": false + } + + In case of `method`, then the message will be retrieved from the result of + the method. It's implementation should always be in the mail.custom.messages.py file # noqa + """ + body_config = config.get("body", {}) + + body = populate_template_from_ctx( + record, + body_config, + module=custom_body, + type="body", + default_ctx=default_ctx, + ) + + base = "mail/base.html" + + if body_config.get("plain"): + base = "mail/base_plain.html" + + if body_config.get("base_template"): + base = body_config.get("base_template") + + return body, base + + +def generate_subject(record, config, default_ctx={}): + """ + Subject generator for notification action. + + It requires a template and a context (dict of vars-values), to populate it. + If no template is found, the default one will be used. + + Example of subject config: + "subject": { + "template": "Subject with {{ title }} and id {{ published_id }}", + "ctx": [{ + "name": "title", + "path": "general_title" + }, { + "method": "published_id" + }] + } + + In case of `method`, then the subject will be retrieved from the result of + the method. It's implementation should always be in the mail.custom.subjects.py file # noqa + """ + subj_config = config.get("subject") + action = default_ctx.get("action", "") + + if not subj_config: + return get_config_default(action, "subject") + + func = subj_config.get("method") + if func: + try: + custom_subject_func = getattr(custom_subjects, func) + subject = custom_subject_func(record, config) + return subject + except AttributeError as exc: + current_app.logger.error( + f"Subject function not found. Providing default subject.\n" + f"Error: {exc.args[0]}" + ) + return get_config_default(action, "subject") + + return populate_template_from_ctx( + record, + subj_config, + module=custom_subjects, + default_ctx=default_ctx, + type="subject", + ) diff --git a/cap/modules/mail/conditions.py b/cap/modules/mail/conditions.py new file mode 100644 index 0000000000..201c12ebc6 --- /dev/null +++ b/cap/modules/mail/conditions.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2016 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + +from flask import current_app +from flask_principal import RoleNeed +from invenio_access.permissions import Permission +from invenio_oauthclient.models import RemoteAccount + +from .utils import path_value_equals + + +def equals(record, path, value, **kwargs): + data = path_value_equals(path, record) + return True if data and data == value else False + + +def not_equals(record, path, value, **kwargs): + data = path_value_equals(path, record) + return True if data and data != value else False + + +def exists(record, path, value, **kwargs): + data = path_value_equals(path, record) + return True if data else False + + +def is_in(record, path, value, **kwargs): + data = path_value_equals(path, record) + return True if data and value in data else False + + +def is_not_in(record, path, value, **kwargs): + data = path_value_equals(path, record) + return True if data and value not in data else False + + +def is_egroup_member(record, path, value, **kwargs): + submitter_id = kwargs.get('default_ctx', {}).get('submitter_id') + groups = get_cern_extra_data_egroups(submitter_id) + + return True if value in groups else False + + +CONDITION_METHODS = { + # path/metadata conditions + 'equals': equals, + 'not_equals': not_equals, + 'exists': exists, + 'is_in': is_in, + 'is_not_in': is_not_in, + # mail/permission conditions + 'is_egroup_member': is_egroup_member, +} + + +def get_cern_extra_data_egroups(user_id): + client_id = current_app.config['CERN_APP_CREDENTIALS']['consumer_key'] + account = RemoteAccount.get( + user_id=user_id, + client_id=client_id, + ) + groups = [] + + if account: + groups = account.extra_data.get('groups', []) + + return groups diff --git a/cap/modules/mail/custom/__init__.py b/cap/modules/mail/custom/__init__.py new file mode 100644 index 0000000000..c50096b814 --- /dev/null +++ b/cap/modules/mail/custom/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2021 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + +"""Mail custom methods package.""" + +from .body import * +from .recipients import * +from .subject import * diff --git a/cap/modules/mail/custom/body.py b/cap/modules/mail/custom/body.py new file mode 100644 index 0000000000..6f13885a01 --- /dev/null +++ b/cap/modules/mail/custom/body.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2021 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + +from flask import current_app +from invenio_accounts.models import User + + +def draft_url(record, **kwargs): + """Get the draft html url of the analysis.""" + base_url = current_app.config['CAP_MAIL_HOST_URL'] + return f'{base_url}/drafts/{record["_deposit"]["id"]}' + + +def published_url(record, **kwargs): + """Get the published html url of the analysis.""" + base_url = current_app.config['CAP_MAIL_HOST_URL'] + if record.get("control_number"): + return f'{base_url}/published/{record["control_number"]}' + return None + + +def working_url(record, **kwargs): + """Get the working html url of the analysis.""" + status = record.get("_deposit", {}).get("status") + + if status == "draft": + return draft_url(record) + elif status == "published": + return published_url(record) + else: + return None + + +def submitter_email(record, **kwargs): + """Returns the submitter of the analysis, aka the current user.""" + submitter_user_id = kwargs.get('default_ctx', {}).get('submitter_id') + submitter_user = User.query.filter_by(id=submitter_user_id).one() + + return submitter_user.email + + +def creator_email(record, **kwargs): + """Returns the owner of the analysis.""" + owner_list = record.get("_deposit", {}).get("owners") + if owner_list: + return User.query.filter_by(id=owner_list[0]).one().email + return None + + +def cms_stats_committee_by_pag(record, **kwargs): + """Retrieve reviewer parameters according to the working group.""" + committee_pags = current_app.config.get("CMS_STATS_COMMITEE_AND_PAGS") + working_group = record.get("analysis_context", {}).get("wg") + + if working_group and committee_pags: + return committee_pags.get(working_group, {}).get("params", {}) + return {} diff --git a/cap/modules/mail/custom/recipients.py b/cap/modules/mail/custom/recipients.py new file mode 100644 index 0000000000..731be6ebff --- /dev/null +++ b/cap/modules/mail/custom/recipients.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2021 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + +from flask import current_app +from invenio_accounts.models import User + + +def get_submitter(record, **kwargs): + """Returns the submitter of the analysis, aka the current user.""" + submitter_user_id = kwargs.get('default_ctx', {}).get('submitter_id') + submitter_user = User.query.filter_by(id=submitter_user_id).one() + + return [submitter_user.email] if submitter_user else [] + + +def get_owner(record, **kwargs): + """Returns the owner of the analysis.""" + owner_list = record.get('_deposit', {}).get('owners') + if owner_list: + return [User.query.filter_by(id=owner_list[0]).one().email] + return [] + + +def get_cms_stat_recipients(record, **kwargs): + """Adds PAGS committee data from JSON file.""" + committee_pags = current_app.config.get("CMS_STATS_COMMITEE_AND_PAGS") + working_group = record.get('analysis_context', {}).get('wg') + if working_group and committee_pags: + return committee_pags.get(working_group, {}).get("contacts", []) + return [] diff --git a/cap/modules/mail/custom/subject.py b/cap/modules/mail/custom/subject.py new file mode 100644 index 0000000000..664653a620 --- /dev/null +++ b/cap/modules/mail/custom/subject.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# This file is part of CERN Analysis Preservation Framework. +# Copyright (C) 2021 CERN. +# +# CERN Analysis Preservation Framework is free software; you can redistribute +# it and/or modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of the +# License, or (at your option) any later version. +# +# CERN Analysis Preservation Framework is distributed in the hope that it will +# be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with CERN Analysis Preservation Framework; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, +# MA 02111-1307, USA. +# +# In applying this license, CERN does not +# waive the privileges and immunities granted to it by virtue of its status +# as an Intergovernmental Organization or submit itself to any jurisdiction. + + +def published_id(record, **kwargs): + try: + return record.get('_deposit', {}).get('pid', {}).get('value') + except: + return None + + +def draft_id(record, **kwargs): + try: + return record.get('_deposit', {}).get('id') + except: + return None + + +def revision(deposit, **kwargs): + try: + _, record = deposit.fetch_published() + return record.revision_id + except KeyError: + return None + + +def draft_revision(deposit, **kwargs): + try: + return deposit.revision_id + except KeyError: + return None diff --git a/cap/modules/mail/receivers.py b/cap/modules/mail/receivers.py index 67e53a3ebf..a1451c7726 100644 --- a/cap/modules/mail/receivers.py +++ b/cap/modules/mail/receivers.py @@ -21,40 +21,111 @@ # In applying this license, CERN does not # waive the privileges and immunities granted to it by virtue of its status # as an Intergovernmental Organization or submit itself to any jurisdiction. -from flask import request +from celery import shared_task +from flask import current_app +from flask_login import current_user -from .utils import NOTIFICATION_RECEPIENT, generate_notification_attrs, \ - send_mail_on_review, send_mail_on_publish, create_analysis_url +from cap.modules.deposit.api import CAPDeposit + +from .attributes import generate_body, generate_recipients, generate_subject +from .custom.body import working_url +from .custom.subject import draft_id, published_id, revision +from .tasks import create_and_send +from .utils import UnsuccessfulMail, generate_ctx, is_review_request def post_action_notifications(sender, action=None, pid=None, deposit=None): - """Method to run after a deposit action .""" - schema = deposit.get("$schema") - recipients_config = NOTIFICATION_RECEPIENT.get(schema, {}).get(action) - host_url = request.host_url - - if recipients_config: - subject, message, recipients = generate_notification_attrs( - deposit, host_url, recipients_config) - - if recipients: - if action == "publish": - recid, record = deposit.fetch_published() - - send_mail_on_publish( - recid.pid_value, - record.revision_id, - host_url, - recipients, - message, - subject_prefix=subject) - - if action == "review": - analysis_url = create_analysis_url(deposit) - - send_mail_on_review( - analysis_url, - host_url, - recipients, - message, - subject_prefix=subject) + """ + Notification through mail, after specified deposit actions. + + The procedure followed to get the mail attrs will be described here: + + - Get the config for the action that triggered the receiver. + - Through the configuration, retrieve the recipients, subject, body, and + base template, and render them when needed. + - Create the message and mail contexts (attributes), and pass them to + the `create_and_send` task. + """ + if not is_review_request(): + return + + action_notifications_config = ( + deposit.schema.config.get('notifications', {}) + .get('actions', {}) + .get(action, []) + ) + current_user_id = current_user.id + if action_notifications_config: + send_deposit_notifications(str(deposit.id), current_user_id, action) + + +@shared_task(ignore_result=True) +def send_deposit_notifications(record_uuid, user_id, action): + """ + Notification through mail, after specified deposit actions. + + The procedure followed to get the mail attrs will be described here: + + - Get the config for the action that triggered the receiver. + - Through the configuration, retrieve the recipients, subject, body, and + base template, and render them when needed. + - Create the message and mail contexts (attributes), and pass them to + the `create_and_send` task. + """ + deposit = CAPDeposit.get_record(record_uuid) + + mail_sender = current_app.config.get('MAIL_DEFAULT_SENDER') + if not mail_sender: + current_app.logger.info('Mail Error: Sender not found.') + return + + action_configs = ( + deposit.schema.config.get('notifications', {}) + .get('actions', {}) + .get(action, []) + ) + + # retrieve the most common Deposit/Record attributes, used in messages + # try: + msg_ctx = { + 'action': action, + 'base_url': current_app.config.get('CAP_MAIL_HOST_URL', ''), + 'base_api_url': current_app.config.get('CAP_MAIL_HOST_API_URL', ''), + 'submitter_id': user_id, + 'published_id': published_id(deposit), + 'draft_id': draft_id(deposit), + 'revision': revision(deposit), + 'working_url': working_url(deposit), + } + + for config in action_configs: + ctx_config = config.get("ctx", []) + default_ctx = generate_ctx(ctx_config, record=deposit) + _ctx = {**default_ctx, **msg_ctx} + recipients, cc, bcc = generate_recipients( + deposit, config, default_ctx=_ctx + ) + if not any([recipients, cc, bcc]): + continue + + try: + subject = generate_subject(deposit, config, default_ctx=_ctx) + body, base = generate_body(deposit, config, default_ctx=_ctx) + except UnsuccessfulMail as err: + current_app.logger.error( + f"UnsuccessfulMail | Rec_id:{err.rec_uuid} - {err.msg}" + ) + continue + plain = config.get('body', {}).get('plain') + + mail_ctx = { + 'sender': mail_sender, + 'subject': subject, + 'recipients': recipients, + 'cc': cc, + 'bcc': bcc, + } + + _ctx.update({'mail_body': body}) + + create_and_send(base, _ctx, mail_ctx, plain=plain) diff --git a/cap/modules/mail/tasks.py b/cap/modules/mail/tasks.py index 58b7f38c92..81db3d93bf 100644 --- a/cap/modules/mail/tasks.py +++ b/cap/modules/mail/tasks.py @@ -22,39 +22,47 @@ # waive the privileges and immunities granted to it by virtue of its status # as an Intergovernmental Organization or submit itself to any jurisdiction. -from celery import shared_task from flask import current_app from invenio_mail.api import TemplatedMessage +from jinja2.exceptions import TemplateNotFound, TemplateSyntaxError -@shared_task(autoretry_for=(Exception, ), - retry_kwargs={'max_retries': 3, - 'countdown': 10}) -def create_and_send(template, ctx, subject, recipients, - sender=None, type=None): - if not current_app.config['CAP_SEND_MAIL']: +def create_and_send(template, ctx, mail_ctx, plain=False): + """Creates the mail using the invenio-mail template, and sends it.""" + if not current_app.config["CAP_SEND_MAIL"]: + current_app.logger.info("Mail Error: Notifications disabled.") return - sender = sender or current_app.config.get('MAIL_DEFAULT_SENDER') - try: - assert recipients + if not any([mail_ctx["recipients"], mail_ctx["bcc"], mail_ctx["cc"]]): + current_app.logger.error( + f"Mail Error for analysis with the following information: {ctx}:\n" + f"Empty recipient list." + ) + return - if type == "plain": - msg = TemplatedMessage(template_body=template, - ctx=ctx, - **dict(sender=sender, - recipients=recipients, - subject=subject)) - else: - msg = TemplatedMessage(template_html=template, - ctx=ctx, - **dict(sender=sender, - recipients=recipients, - subject=subject)) - current_app.extensions['mail'].send(msg) + try: + msg = ( + TemplatedMessage(template_body=template, ctx=ctx, **mail_ctx) + if plain + else TemplatedMessage(template_html=template, ctx=ctx, **mail_ctx) + ) - except AssertionError: - current_app.logger.error( - f'Mail Error from {sender} with subject: {subject}.\n' - f'Empty recipient list.') - raise AssertionError + current_app.extensions["mail"].send(msg) + except TemplateNotFound as ex: + _msg = ( + f"Mail::create_and_send - Template {ex.name} not found." + f" Notification procedure aborted." + ) + current_app.logger.error(_msg) + except TemplateSyntaxError as ex: + _msg = ( + f"Mail::create_and_send - Template error: {ex.message}." + f" Notification procedure aborted." + ) + current_app.logger.error(_msg) + except TypeError: + _msg = ( + "Mail::create_and_send - Context for template is empty." + "Notification procedure aborted." + ) + current_app.logger.error(_msg) diff --git a/cap/modules/mail/templates/mail/analysis_published_new.html b/cap/modules/mail/templates/mail/analysis_published_new.html deleted file mode 100644 index 448085c6a3..0000000000 --- a/cap/modules/mail/templates/mail/analysis_published_new.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "mail/base.html" %} - -{% block paragraph %} -
You can check it here.
- - {% if message %} -{{message}}
- {% endif %} -{% endblock %} - -{% block button_url %}{{ url + "published/" + recid }}{% endblock %} - -{% block button_text %}Check document{% endblock %} \ No newline at end of file diff --git a/cap/modules/mail/templates/mail/analysis_published_revision.html b/cap/modules/mail/templates/mail/analysis_published_revision.html deleted file mode 100644 index beb2b41ab2..0000000000 --- a/cap/modules/mail/templates/mail/analysis_published_revision.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "mail/base.html" %} - -{% block paragraph %} -You can check it here.
- - {% if message %} -{{message}}
- {% endif %} -{% endblock %} - -{% block button_url %}{{ url + "published/" + recid }}{% endblock %} - -{% block button_text %}Check new version{% endblock %} \ No newline at end of file diff --git a/cap/modules/mail/templates/mail/analysis_review.html b/cap/modules/mail/templates/mail/analysis_review.html deleted file mode 100644 index 76821ad869..0000000000 --- a/cap/modules/mail/templates/mail/analysis_review.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "mail/base.html" %} - -{% block paragraph %} -You can check it here.
- - {% if message %} -{{message}}
- {% endif %} -{% endblock %} - -{% block button_url %}{{ url + analysis_url }}{% endblock %} - -{% block button_text %}Check review{% endblock %} \ No newline at end of file diff --git a/cap/modules/mail/templates/mail/base.html b/cap/modules/mail/templates/mail/base.html index 9cd421e852..c8f8d3f385 100644 --- a/cap/modules/mail/templates/mail/base.html +++ b/cap/modules/mail/templates/mail/base.html @@ -1,357 +1,26 @@ - + - - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% block hero %}{% endblock %}
-
-{% block title %}{% endblock %} -
-
-
-
-
-
-{% block id %}{% endblock %} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
--{% block paragraph %}{% endblock %} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% block footer %}{% endblock %}
-
-
-
-
-
-
-
-
-This email was sent automatically by CERN Analysis Preservation. -For tutorials, use cases, FAQ and more, please refer to our documentation here. -If you need help or have a question, please open a ticket through the CERN Service Portal. - - - -
-
-
-
-
-Copyright © 2014-2021 CERN -
|
-
+ {% include "mail/partials/header.html" %} + + {{ mail_body | safe }} + {% include "mail/partials/divider.html" %} + {% include "mail/partials/footer.html" %} + | +