diff --git a/invenio_rdm_records/notifications/builders.py b/invenio_rdm_records/notifications/builders.py index 64624276fe..19d15197c5 100644 --- a/invenio_rdm_records/notifications/builders.py +++ b/invenio_rdm_records/notifications/builders.py @@ -15,6 +15,7 @@ from invenio_notifications.services.generators import EntityResolve, UserEmailBackend from invenio_requests.notifications.filters import UserRecipientFilter from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter +from invenio_users_resources.notifications.generators import UserRecipient class CommunityInclusionNotificationBuilder(NotificationBuilder): @@ -59,3 +60,84 @@ class CommunityInclusionSubmittedNotificationBuilder( """Notification builder for record community inclusion submitted.""" type = "community-submission.submit" + + +class CommunityInclusionActionNotificationBuilder(NotificationBuilder): + """Notification builder for inclusion actions.""" + + @classmethod + def build(cls, identity, request): + """Build notification with request context.""" + return Notification( + type=cls.type, + context={ + "request": EntityResolverRegistry.reference_entity(request), + "executing_user": EntityResolverRegistry.reference_identity(identity), + }, + ) + + context = [ + EntityResolve(key="request"), + EntityResolve(key="request.created_by"), + EntityResolve(key="request.topic"), + EntityResolve(key="request.receiver"), + EntityResolve(key="executing_user"), + ] + + recipients = [ + UserRecipient("request.created_by"), + ] + + recipient_filters = [ + UserPreferencesRecipientFilter(), + UserRecipientFilter("executing_user"), + ] + + recipient_backends = [ + UserEmailBackend(), + ] + + +class CommunityInclusionAcceptNotificationBuilder( + CommunityInclusionActionNotificationBuilder +): + """Notification builder for inclusion accept action.""" + + type = f"{CommunityInclusionNotificationBuilder.type}.accept" + + +class CommunityInclusionCancelNotificationBuilder( + CommunityInclusionActionNotificationBuilder +): + """Notification builder for inclusion cancel action.""" + + type = f"{CommunityInclusionNotificationBuilder.type}.cancel" + + recipients = [ + CommunityMembersRecipient("request.receiver", roles=["curator", "owner"]), + ] + + +class CommunityInclusionDeclineNotificationBuilder( + CommunityInclusionActionNotificationBuilder +): + """Notification builder for inclusion decline action.""" + + type = f"{CommunityInclusionNotificationBuilder.type}.decline" + + +class CommunityInclusionExpireNotificationBuilder( + CommunityInclusionActionNotificationBuilder +): + """Notification builder for inclusion expire action.""" + + type = f"{CommunityInclusionNotificationBuilder.type}.expire" + + # Executing user will most probably be the system. It is not resolvable on the service level + # as of now and we do not use it in the template. + context = [ + EntityResolve(key="request"), + EntityResolve(key="request.created_by"), + EntityResolve(key="request.topic"), + EntityResolve(key="request.receiver"), + ] diff --git a/invenio_rdm_records/requests/community_inclusion.py b/invenio_rdm_records/requests/community_inclusion.py index fe4359a4e1..a518dca4f2 100644 --- a/invenio_rdm_records/requests/community_inclusion.py +++ b/invenio_rdm_records/requests/community_inclusion.py @@ -9,10 +9,14 @@ from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp from invenio_i18n import lazy_gettext as _ +from invenio_notifications.services.uow import NotificationOp from invenio_records_resources.services.uow import RecordIndexOp from invenio_requests.customizations import RequestType, actions from invenio_requests.errors import CannotExecuteActionError +from invenio_rdm_records.notifications.builders import ( + CommunityInclusionAcceptNotificationBuilder, +) from invenio_rdm_records.services.errors import InvalidAccessRestrictions from ..proxies import current_rdm_records_service as service @@ -68,7 +72,13 @@ def execute(self, identity, uow): # not be immediately visible in the community's records, when the `all versions` # facet is not toggled uow.register(RecordIndexOp(record, indexer=service.indexer, index_refresh=True)) - + uow.register( + NotificationOp( + CommunityInclusionAcceptNotificationBuilder.build( + identity=identity, request=self.request + ) + ) + ) super().execute(identity, uow) diff --git a/invenio_rdm_records/requests/community_submission.py b/invenio_rdm_records/requests/community_submission.py index 619f393cac..f22297f79d 100644 --- a/invenio_rdm_records/requests/community_submission.py +++ b/invenio_rdm_records/requests/community_submission.py @@ -10,8 +10,15 @@ from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp from invenio_i18n import lazy_gettext as _ +from invenio_notifications.services.uow import NotificationOp from invenio_requests.customizations import actions +from ..notifications.builders import ( + CommunityInclusionAcceptNotificationBuilder, + CommunityInclusionCancelNotificationBuilder, + CommunityInclusionDeclineNotificationBuilder, + CommunityInclusionExpireNotificationBuilder, +) from ..proxies import current_rdm_records_service as service from .base import ReviewRequest @@ -64,6 +71,13 @@ def execute(self, identity, uow): # Publish the record # TODO: Ensure that the accepting user has permissions to publish. service.publish(identity, draft.pid.pid_value, uow=uow) + uow.register( + NotificationOp( + CommunityInclusionAcceptNotificationBuilder.build( + identity=identity, request=self.request + ) + ) + ) super().execute(identity, uow) @@ -86,6 +100,13 @@ def execute(self, identity, uow): uow.register( ParentRecordCommitOp(draft.parent, indexer_context=dict(service=service)) ) + uow.register( + NotificationOp( + CommunityInclusionDeclineNotificationBuilder.build( + identity=identity, request=self.request + ) + ) + ) class CancelAction(actions.CancelAction): @@ -101,6 +122,13 @@ def execute(self, identity, uow): ParentRecordCommitOp(draft.parent, indexer_context=dict(service=service)) ) super().execute(identity, uow) + uow.register( + NotificationOp( + CommunityInclusionCancelNotificationBuilder.build( + identity=identity, request=self.request + ) + ) + ) class ExpireAction(actions.ExpireAction): @@ -122,6 +150,13 @@ def execute(self, identity, uow): uow.register( ParentRecordCommitOp(draft.parent, indexer_context=dict(service=service)) ) + uow.register( + NotificationOp( + CommunityInclusionExpireNotificationBuilder.build( + identity=identity, request=self.request + ) + ) + ) # diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.accept.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.accept.jinja new file mode 100644 index 0000000000..db22d625b0 --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.accept.jinja @@ -0,0 +1,42 @@ +{% set submission_request = notification.context.request %} +{% set community = submission_request.receiver %} +{% set creator = submission_request.created_by %} +{% set record = submission_request.topic %} +{% set request_id = submission_request.id %} +{% set executing_user = notification.context.executing_user %} + +{% set community_title = community.metadata.title %} +{% set record_title = record.metadata.title %} +{% set curator_name = executing_user.username %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} + +{%- block subject -%} +{{ _("The submission for your record '{record_title}' has been accepted").format(record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} +

+ {{ _("Community curator '{curator_name}' accepted the record '{record_title}' in the community '{community_title}'.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} +

+ + {{ _("Check out the submission request") }} +{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("Community curator '{curator_name}' accepted the record '{record_title}' in the community '{community_title}'.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} + +{{ _("Check out the submission request: {request_link}").format(request_link=request_link) }} + +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Community curator *{curator_name}* accepted the record *{record_title}* in the community *{community_title}*.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} + +[{{_("Check out the submission request")}}]({{request_link}}) +{%- endblock md_body %} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.cancel.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.cancel.jinja new file mode 100644 index 0000000000..72a2eceec3 --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.cancel.jinja @@ -0,0 +1,41 @@ +{% set submission_request = notification.context.request %} +{% set community = submission_request.receiver %} +{% set creator = submission_request.created_by %} +{% set record = submission_request.topic %} +{% set request_id = submission_request.id %} +{% set executing_user = notification.context.executing_user %} + +{% set community_title = community.metadata.title %} +{% set record_title = record.metadata.title %} +{% set cancel_name = executing_user.username %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} + +{%- block subject -%} +{{ _("Record submission for your community '{community_title}' cancelled by '{cancel_name}'").format(community_title=community_title, cancel_name=cancel_name) }} +{%- endblock subject -%} + +{%- block html_body -%} +

+ {{ _("The record submission for the record '{record_title}' for your community '{community_title}' was cancelled by '{cancel_name}'.").format(record_title=record_title, community_title=community_title, cancel_name=cancel_name) }} +

+ + {{ _("Check out the submission request") }} +{%- endblock html_body -%} + +{%- block plain_body -%} +{{ _("The record submission for the record '{record_title}' for your community '{community_title}' was cancelled by '{cancel_name}'.").format(record_title=record_title, community_title=community_title, cancel_name=cancel_name) }} + +{{ _("Check out the submission request: {request_link}").format(request_link=request_link) }} +{%- endblock plain_body -%} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The record submission for the record '{record_title}' for your community '{community_title}' was cancelled by '{cancel_name}'.").format(record_title=record_title, community_title=community_title, cancel_name=cancel_name) }} + +[{{ _("Check out the submission request") }}]({{ request_link }}) +{%- endblock md_body -%} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.decline.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.decline.jinja new file mode 100644 index 0000000000..137d7e8a69 --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.decline.jinja @@ -0,0 +1,42 @@ +{% set submission_request = notification.context.request %} +{% set community = submission_request.receiver %} +{% set creator = submission_request.created_by %} +{% set record = submission_request.topic %} +{% set request_id = submission_request.id %} +{% set executing_user = notification.context.executing_user %} + +{% set community_title = community.metadata.title %} +{% set record_title = record.metadata.title %} +{% set curator_name = executing_user.username %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} + +{%- block subject -%} +{{ _("The submission for your record '{record_title}' has been declined").format(record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} +

+ {{ _("Community curator '{curator_name}' declined the record '{record_title}' in the community '{community_title}'.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} +

+ + {{ _("Check out the submission request") }} +{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("Community curator '{curator_name}' declined the record '{record_title}' in the community '{community_title}'.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} + +{{ _("Check out the submission request: {request_link}").format(request_link=request_link) }} + +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Community curator *{curator_name}* declined the record *{record_title}* in the community *{community_title}*.").format(record_title=record_title, community_title=community_title, curator_name=curator_name) }} + +[{{_("Check out the submission request")}}]({{request_link}}) +{%- endblock md_body %} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.expire.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.expire.jinja new file mode 100644 index 0000000000..8d890f3e3e --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/community-submission.expire.jinja @@ -0,0 +1,40 @@ +{% set submission_request = notification.context.request %} +{% set community = submission_request.receiver %} +{% set creator = submission_request.created_by %} +{% set record = submission_request.topic %} +{% set request_id = submission_request.id %} + +{% set community_title = community.metadata.title %} +{% set record_title = record.metadata.title %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/me/requests/{id}".format( + ui=config.SITE_UI_URL, id=request_id + ) +%} + +{%- block subject -%} +{{ _("The submission for your record '{record_title}' has been expired").format(record_title=record_title) }} +{%- endblock subject -%} + +{%- block html_body -%} +

+ {{ _("The record submission for the record '{record_title}' for the community '{community_title}' has expired'.").format(record_title=record_title, community_title=community_title) }} +

+ + {{ _("Check out the submission request") }} +{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("The record submission for the record '{record_title}' for the community '{community_title}' has expired'.").format(record_title=record_title, community_title=community_title) }} + +{{ _("Check out the submission request: {request_link}").format(request_link=request_link) }} + +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The record submission for the record '{record_title}' for the community '{community_title}' has expired'.").format(record_title=record_title, community_title=community_title) }} + +[{{_("Check out the submission request")}}]({{request_link}}) +{%- endblock md_body %} diff --git a/tests/conftest.py b/tests/conftest.py index d9b3e5d2ea..dacfbb34ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,7 @@ CommunityInvitationSubmittedNotificationBuilder, ) from invenio_notifications.backends import EmailNotificationBackend +from invenio_notifications.proxies import current_notifications_manager from invenio_notifications.services.builders import NotificationBuilder from invenio_oauth2server.models import Client from invenio_pidstore.errors import PIDDoesNotExistError @@ -86,6 +87,10 @@ from invenio_rdm_records import config from invenio_rdm_records.notifications.builders import ( + CommunityInclusionAcceptNotificationBuilder, + CommunityInclusionCancelNotificationBuilder, + CommunityInclusionDeclineNotificationBuilder, + CommunityInclusionExpireNotificationBuilder, CommunityInclusionSubmittedNotificationBuilder, ) from invenio_rdm_records.proxies import current_rdm_records_service @@ -279,6 +284,10 @@ def app_config(app_config, mock_datacite_client): # Specifying dummy builders to avoid raising errors for most tests. Extend as needed. app_config["NOTIFICATIONS_BUILDERS"] = { CommentRequestEventCreateNotificationBuilder.type: DummyNotificationBuilder, + CommunityInclusionAcceptNotificationBuilder.type: DummyNotificationBuilder, + CommunityInclusionCancelNotificationBuilder.type: DummyNotificationBuilder, + CommunityInclusionDeclineNotificationBuilder.type: DummyNotificationBuilder, + CommunityInclusionExpireNotificationBuilder.type: DummyNotificationBuilder, CommunityInclusionSubmittedNotificationBuilder.type: DummyNotificationBuilder, CommunityInvitationSubmittedNotificationBuilder.type: DummyNotificationBuilder, } @@ -1931,3 +1940,25 @@ def _index(): current_users_service.record_cls.index.refresh() return _index + + +@pytest.fixture() +def replace_notification_builder(monkeypatch): + """Replace a notification builder and return a mock.""" + + def _replace(builder_cls): + mock_build = mock.MagicMock() + mock_build.side_effect = builder_cls.build + monkeypatch.setattr(builder_cls, "build", mock_build) + # setting specific builder for test case + monkeypatch.setattr( + current_notifications_manager, + "builders", + { + **current_notifications_manager.builders, + builder_cls.type: builder_cls, + }, + ) + return mock_build + + return _replace diff --git a/tests/services/test_service_review.py b/tests/services/test_service_review.py index 7d592bf0fa..b8d79171fe 100644 --- a/tests/services/test_service_review.py +++ b/tests/services/test_service_review.py @@ -7,21 +7,22 @@ """Test of the review deposit integration.""" -from unittest.mock import MagicMock - import pytest from flask_principal import Identity, UserNeed -from invenio_access.permissions import any_user, authenticated_user +from invenio_access.permissions import any_user, authenticated_user, system_identity from invenio_communities.communities.records.api import Community from invenio_communities.generators import CommunityRoleNeed from invenio_communities.members.records.api import Member -from invenio_notifications.proxies import current_notifications_manager from invenio_records_resources.services.errors import PermissionDeniedError from invenio_requests import current_requests_service from marshmallow.exceptions import ValidationError from sqlalchemy.orm.exc import NoResultFound from invenio_rdm_records.notifications.builders import ( + CommunityInclusionAcceptNotificationBuilder, + CommunityInclusionCancelNotificationBuilder, + CommunityInclusionDeclineNotificationBuilder, + CommunityInclusionExpireNotificationBuilder, CommunityInclusionSubmittedNotificationBuilder, ) from invenio_rdm_records.proxies import current_rdm_records @@ -608,7 +609,24 @@ def test_review_gives_access_to_curator(running_app, draft, service, requests_se item = service.read_draft(identity, draft.pid.pid_value) -def test_review_notification( +# def _replace_notification_builder(builder_cls, monkeypatch): +# """Replace a notification builder and return a mock.""" +# mock_build = MagicMock() +# mock_build.side_effect = builder_cls.build +# monkeypatch.setattr(builder_cls, "build", mock_build) +# # setting specific builder for test case +# monkeypatch.setattr( +# current_notifications_manager, +# "builders", +# { +# **current_notifications_manager.builders, +# builder_cls.type: builder_cls, +# }, +# ) +# return mock_build + + +def test_review_submit_notification( draft_for_open_review, running_app, open_review_community, @@ -616,35 +634,17 @@ def test_review_notification( community_owner, service, inviter, - monkeypatch, + replace_notification_builder, ): """Test notification being built on review submit.""" original_builder = CommunityInclusionSubmittedNotificationBuilder - # mock build to observe calls - mock_build = MagicMock() - mock_build.side_effect = original_builder.build - monkeypatch.setattr(original_builder, "build", mock_build) - # setting specific builder for test case - monkeypatch.setattr( - current_notifications_manager, - "builders", - { - **current_notifications_manager.builders, - original_builder.type: original_builder, - }, - ) + mock_build = replace_notification_builder(original_builder) + assert not mock_build.called inviter(curator.id, open_review_community.id, "curator") - # check draft status - assert ( - draft_for_open_review["status"] - == DraftStatus.review_to_draft_statuses["created"] - ) - assert not mock_build.called - mail = running_app.app.extensions.get("mail") assert mail @@ -664,6 +664,174 @@ def test_review_notification( assert curator.email in sent_mail.recipients +def test_review_accept_notification( + draft_for_open_review, + running_app, + open_review_community, + curator, + community_owner, + service, + requests_service, + inviter, + replace_notification_builder, +): + """Test notification being built on review submit.""" + + original_builder = CommunityInclusionAcceptNotificationBuilder + # mock build to observe calls + mock_build = replace_notification_builder(original_builder) + assert not mock_build.called + + inviter(curator.id, open_review_community.id, "curator") + + mail = running_app.app.extensions.get("mail") + assert mail + + req = service.review.submit( + community_owner.identity, draft_for_open_review.id + ).to_dict() + + with mail.record_messages() as outbox: + # Validate that email was sent + req = requests_service.execute_action( + curator.identity, req["id"], "accept", {} + ).to_dict() + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + sent_mail = outbox[0] + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(req["id"]) in sent_mail.html + assert community_owner.email in sent_mail.recipients + assert curator.email not in sent_mail.recipients + + +def test_review_cancel_notification( + draft_for_open_review, + running_app, + open_review_community, + curator, + community_owner, + service, + requests_service, + inviter, + replace_notification_builder, +): + """Test notification being built on review submit.""" + + original_builder = CommunityInclusionCancelNotificationBuilder + # mock build to observe calls + mock_build = replace_notification_builder(original_builder) + assert not mock_build.called + + inviter(curator.id, open_review_community.id, "curator") + + mail = running_app.app.extensions.get("mail") + assert mail + + req = service.review.submit( + community_owner.identity, draft_for_open_review.id + ).to_dict() + + with mail.record_messages() as outbox: + # Validate that email was sent + req = requests_service.execute_action( + community_owner.identity, req["id"], "cancel", {} + ).to_dict() + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + sent_mail = outbox[0] + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(req["id"]) in sent_mail.html + assert community_owner.email not in sent_mail.recipients + assert curator.email in sent_mail.recipients + + +def test_review_decline_notification( + draft_for_open_review, + running_app, + open_review_community, + curator, + community_owner, + service, + requests_service, + inviter, + replace_notification_builder, +): + """Test notification being built on review submit.""" + + original_builder = CommunityInclusionDeclineNotificationBuilder + # mock build to observe calls + mock_build = replace_notification_builder(original_builder) + assert not mock_build.called + + inviter(curator.id, open_review_community.id, "curator") + + mail = running_app.app.extensions.get("mail") + assert mail + + req = service.review.submit( + community_owner.identity, draft_for_open_review.id + ).to_dict() + + with mail.record_messages() as outbox: + # Validate that email was sent + req = requests_service.execute_action( + curator.identity, req["id"], "decline", {} + ).to_dict() + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + sent_mail = outbox[0] + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(req["id"]) in sent_mail.html + assert community_owner.email in sent_mail.recipients + assert curator.email not in sent_mail.recipients + + +def test_review_expire_notification( + draft_for_open_review, + running_app, + open_review_community, + curator, + community_owner, + service, + requests_service, + inviter, + replace_notification_builder, +): + """Test notification being built on review submit.""" + + original_builder = CommunityInclusionExpireNotificationBuilder + # mock build to observe calls + mock_build = replace_notification_builder(original_builder) + assert not mock_build.called + + inviter(curator.id, open_review_community.id, "curator") + + mail = running_app.app.extensions.get("mail") + assert mail + + req = service.review.submit( + community_owner.identity, draft_for_open_review.id + ).to_dict() + + with mail.record_messages() as outbox: + # Validate that email was sent + req = requests_service.execute_action( + system_identity, req["id"], "expire", {} + ).to_dict() + # check notification is build on submit + assert mock_build.called + assert len(outbox) == 1 + sent_mail = outbox[0] + # TODO: update to `req["links"]["self_html"]` when addressing https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 + assert "/me/requests/{}".format(req["id"]) in sent_mail.html + assert community_owner.email in sent_mail.recipients + assert curator.email not in sent_mail.recipients + + # TODO tests: # - Test: submit to restricted community not allowed by user # (likely requires members structure in communities?)