diff --git a/invenio_communities/notifications/builders.py b/invenio_communities/notifications/builders.py index 39a40672e..4e5a293f3 100644 --- a/invenio_communities/notifications/builders.py +++ b/invenio_communities/notifications/builders.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +# Copyright (C) 2024 CERN. # Copyright (C) 2023 Graz University of Technology. # # Invenio-Communities is free software; you can redistribute it and/or modify @@ -216,3 +217,78 @@ class SubCommunityDecline(SubCommunityBuilderBase): """Notification builder for subcommunity request decline.""" type = f"{SubCommunityBuilderBase.type}.decline" + + +class SubComInvitationBuilderBase(SubCommunityBuilderBase): + """Base notification builder for subcommunity invitation requests.""" + + type = "subcommunity-invitation-request" + + context = [ + EntityResolve("request"), + EntityResolve("request.created_by"), + EntityResolve("request.receiver"), + EntityResolve("executing_user"), + ] + + recipients = [ + CommunityMembersRecipient("request.created_by", roles=["owner", "manager"]), + CommunityMembersRecipient("request.receiver", roles=["owner", "manager"]), + ] + + +class SubComInvitationCreate(SubComInvitationBuilderBase): + """Notification builder for subcommunity request creation.""" + + type = f"{SubComInvitationBuilderBase.type}.create" + + context = [ + EntityResolve("request"), + EntityResolve("request.created_by"), + EntityResolve("request.receiver"), + # EntityResolve("executing_user") creating via script only for now + ] + + recipients = [ + CommunityMembersRecipient("request.receiver", roles=["owner", "manager"]), + ] + + +class SubComInvitationAccept(SubComInvitationBuilderBase): + """Notification builder for subcommunity request accept.""" + + type = f"{SubComInvitationBuilderBase.type}.accept" + + recipient_filters = [ + UserPreferencesRecipientFilter(), + # Don't send notifications to the user performing the action + UserRecipientFilter("executing_user"), + ] + + +class SubComInvitationDecline(SubComInvitationBuilderBase): + """Notification builder for subcommunity request decline.""" + + type = f"{SubComInvitationBuilderBase.type}.decline" + + recipient_filters = [ + UserPreferencesRecipientFilter(), + # Don't send notifications to the user performing the action + UserRecipientFilter("executing_user"), + ] + + +class SubComInvitationExpire(SubComInvitationBuilderBase): + """Notification builder for subcommunity invitation expire.""" + + type = f"{SubComInvitationBuilderBase.type}.expire" + + context = [ + EntityResolve("request"), + EntityResolve("request.created_by"), + EntityResolve("request.receiver"), + ] + + recipients = [ + CommunityMembersRecipient("request.receiver", roles=["owner", "manager"]), + ] diff --git a/invenio_communities/subcommunities/services/__init__.py b/invenio_communities/subcommunities/services/__init__.py index 0543e7479..939e2a188 100644 --- a/invenio_communities/subcommunities/services/__init__.py +++ b/invenio_communities/subcommunities/services/__init__.py @@ -7,6 +7,11 @@ """Subcommunities module service.""" from .config import SubCommunityServiceConfig +from .request import SubCommunityInvitationRequest from .service import SubCommunityService -__all__ = ("SubCommunityService", "SubCommunityServiceConfig") +__all__ = ( + "SubCommunityService", + "SubCommunityServiceConfig", + "SubCommunityInvitationRequest", +) diff --git a/invenio_communities/subcommunities/services/request.py b/invenio_communities/subcommunities/services/request.py index 6c2464342..c4ed86bf2 100644 --- a/invenio_communities/subcommunities/services/request.py +++ b/invenio_communities/subcommunities/services/request.py @@ -10,6 +10,7 @@ from invenio_i18n import lazy_gettext as _ from invenio_notifications.services.uow import NotificationOp from invenio_requests.customizations import RequestType, actions +from marshmallow.exceptions import ValidationError import invenio_communities.notifications.builders as notifications from invenio_communities.proxies import current_communities @@ -52,10 +53,10 @@ def execute(self, identity, uow): class SubCommunityRequest(RequestType): - """Request to add a subcommunity to a community.""" + """Request to join a parent community as a subcommunity.""" type_id = "subcommunity" - name = _("Subcommunity Request") + name = _("Subcommunity request") creator_can_be_none = False topic_can_be_none = False @@ -80,6 +81,91 @@ class SubCommunityRequest(RequestType): } +class CreateSubcommunityInvitation(actions.CreateAndSubmitAction): + """Represents an accept action used to accept a subcommunity.""" + + def execute(self, identity, uow): + """Execute approve action.""" + parent = self.request.created_by.resolve() + if not parent.children.allow: + raise ValidationError("Assigned parent is not allowed to be a parent.") + + uow.register( + NotificationOp( + notifications.SubComInvitationCreate.build( + identity=identity, request=self.request + ) + ) + ) + + super().execute(identity, uow) + + +class AcceptSubcommunityInvitation(actions.AcceptAction): + """Represents an accept action used to accept a subcommunity.""" + + def execute(self, identity, uow): + """Execute approve action.""" + child = self.request.receiver.resolve().id + parent = self.request.created_by.resolve().id + current_communities.service.bulk_update_parent( + system_identity, [child], parent_id=parent, uow=uow + ) + uow.register( + NotificationOp( + notifications.SubComInvitationAccept.build( + identity=identity, request=self.request + ) + ) + ) + super().execute(identity, uow) + + +class DeclineSubcommunityInvitation(actions.DeclineAction): + """Represents a decline action used to decline a subcommunity.""" + + def execute(self, identity, uow): + """Execute decline action.""" + # We override just to send a notification + uow.register( + NotificationOp( + notifications.SubComInvitationDecline.build( + identity=identity, request=self.request + ) + ) + ) + super().execute(identity, uow) + + +class SubCommunityInvitationRequest(RequestType): + """Request from a parent community to community to join.""" + + type_id = "subcommunity-invitation" + name = _("Subcommunity invitation") + + creator_can_be_none = False + topic_can_be_none = True + allowed_creator_ref_types = ["community"] + allowed_receiver_ref_types = ["community"] + allowed_topic_ref_types = ["community"] + + available_actions = { + "delete": actions.DeleteAction, + "cancel": actions.CancelAction, + # Custom implemented actions + "create": CreateSubcommunityInvitation, + "accept": AcceptSubcommunityInvitation, + "decline": DeclineSubcommunityInvitation, + } + + needs_context = { + "community_roles": [ + "owner", + "manager", + ] + } + + def subcommunity_request_type(app): """Return the subcommunity request type. @@ -91,3 +177,18 @@ def subcommunity_request_type(app): if not app: return return app.config.get("COMMUNITIES_SUB_REQUEST_CLS", SubCommunityRequest) + + +def subcommunity_invitation_request_type(app): + """Return the subcommunity request type. + + Since it can be overridden by the application, this function should be used + as the entry point for the request type. + + It must return a class that inherits from `RequestType`. + """ + if not app: + return + return app.config.get( + "COMMUNITIES_SUB_INVITATION_REQUEST_CLS", SubCommunityInvitationRequest + ) diff --git a/invenio_communities/subcommunities/services/service.py b/invenio_communities/subcommunities/services/service.py index bd15acb94..3d5a7eaa6 100644 --- a/invenio_communities/subcommunities/services/service.py +++ b/invenio_communities/subcommunities/services/service.py @@ -6,20 +6,22 @@ # it under the terms of the MIT License; see LICENSE file for more details. """Subcommunities service.""" +from datetime import datetime, timedelta, timezone + from invenio_i18n import gettext as _ from invenio_notifications.services.uow import NotificationOp from invenio_records_resources.services.base import LinksTemplate, Service -from invenio_records_resources.services.records.links import pagination_links from invenio_records_resources.services.records.schema import ServiceSchemaWrapper from invenio_records_resources.services.uow import unit_of_work +from invenio_requests import current_request_type_registry from invenio_requests.proxies import current_requests_service as requests_service -from invenio_search.engine import dsl from werkzeug.local import LocalProxy import invenio_communities.notifications.builders as notifications from invenio_communities.proxies import current_communities, current_roles from .errors import ParentChildrenNotAllowed +from .request import SubCommunityInvitationRequest community_service = LocalProxy(lambda: current_communities.service) @@ -150,3 +152,25 @@ def join(self, identity, id_, data, uow=None): # expandable_fields=self.expandable_fields, # expand=True, # ) + + @unit_of_work() + def create_subcommunity_invitation_request( + self, identity, parent_community_id, child_community_id, data, uow=None + ): + """Create and submit a SubCommunityInvitation request.""" + type_ = current_request_type_registry.lookup( + SubCommunityInvitationRequest.type_id + ) + parent_community = community_service.record_cls.pid.resolve(parent_community_id) + child_community = community_service.record_cls.pid.resolve(child_community_id) + expires_at = datetime.now(timezone.utc) + timedelta(days=30) + + requests_service.create( + identity=identity, + data=data, + request_type=type_, + creator=parent_community, + receiver=child_community, + expires_at=expires_at, + uow=uow, + ) diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.accept.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.accept.jinja new file mode 100644 index 000000000..a350fff8a --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.accept.jinja @@ -0,0 +1,59 @@ +{% set request = notification.context.request %} +{% set parent_community = request.created_by %} +{% set community = request.receiver %} +{% set user = notification.context.executing_user %} +{% set ui = config.SITE_UI_URL %} +{% set msg_ctx = { + "community_id": community.slug, + "community_title": community.metadata.title, + "parent_community_id": parent_community.slug, + "parent_community_title": parent_community.metadata.title, + "request_id": request.id, + "username": user.username or user.profile.full_name, + "ui": ui, +} %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/communities/{community_id}/requests/{request_id}".format(**msg_ctx) %} +{% set account_settings_link = "{ui}/account/settings/notifications".format(**msg_ctx) %} + +{%- block subject -%} +{{ _('✅ Invitation accepted for {community_title} to join the {parent_community_title} community').format(**msg_ctx) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + +
{{ _('@{username} accepted the invitation for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} +
{{ _("View the request")}}
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _('@{username} accepted the invitation for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} + +{{ _("View the request at:") }} {{ request_link }} + +— +{{ _("This is an auto-generated message. To manage notifications, visit your account settings at: ") }}{{ account_settings_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _('@{username} accepted the invitation the request for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} + +[{{ _("View the request") }}]({{ request_link }}) + +— +{{ _("This is an auto-generated message. To manage notifications, visit your") }} [{{ _("account settings") }}]({{ account_settings_link }}). +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.create.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.create.jinja new file mode 100644 index 000000000..bd60c3ff2 --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.create.jinja @@ -0,0 +1,63 @@ +{% set request = notification.context.request %} +{% set parent_community = request.created_by %} +{% set community = request.receiver %} +{% set ui = config.SITE_UI_URL %} +{% set msg_ctx = { + "community_id": community.slug, + "community_title": community.metadata.title, + "parent_community_id": parent_community.slug, + "parent_community_title": parent_community.metadata.title, + "request_id": request.id, + "expires_at": request.expires_at | from_isodatetime | dateformat(format="long"), + "ui": ui, +} %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/communities/{community_id}/requests/{request_id}".format(**msg_ctx) %} +{% set account_settings_link = "{ui}/account/settings/notifications".format(**msg_ctx) %} + +{%- block subject -%} +{{ _('📬 Invitation to join the {parent_community_title} community').format(**msg_ctx) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + + + + +
{{ _('We would like to invite your community "{community_title}" to join as a subcommunity of "{parent_community_title}"').format(**msg_ctx) }} +
{{ _("View the request")}}
{{ _('This invitation will expire on {expires_at}.').format(**msg_ctx) }}
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _('We would like to invite your community "{community_title}" to join as a subcommunity of "{parent_community_title}"').format(**msg_ctx) }} + +{{ _("View the request at:") }} {{ request_link }} + +{{ _('This invitation will expire on {expires_at}.').format(**msg_ctx) }} +— +{{ _("This is an auto-generated message. To manage notifications, visit your account settings at: ") }}{{ account_settings_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _('The community *{community_title}* has requested to join as a subcommunity of *{parent_community_title}*').format(**msg_ctx) }} + +[{{ _("View the request") }}]({{ request_link }}) + +{{ _('This invitation will expire on {expires_at}.').format(**msg_ctx) }} +— +{{ _("This is an auto-generated message. To manage notifications, visit your") }} [{{ _("account settings") }}]({{ account_settings_link }}). +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.decline.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.decline.jinja new file mode 100644 index 000000000..c472cf1f9 --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.decline.jinja @@ -0,0 +1,59 @@ +{% set request = notification.context.request %} +{% set parent_community = request.created_by %} +{% set community = request.receiver %} +{% set user = notification.context.executing_user %} +{% set ui = config.SITE_UI_URL %} +{% set msg_ctx = { + "community_id": community.slug, + "community_title": community.metadata.title, + "parent_community_id": parent_community.slug, + "parent_community_title": parent_community.metadata.title, + "request_id": request.id, + "username": user.username or user.profile.full_name, + "ui": ui, +} %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/communities/{community_id}/requests/{request_id}".format(**msg_ctx) %} +{% set account_settings_link = "{ui}/account/settings/notifications".format(**msg_ctx) %} + +{%- block subject -%} +{{ _('⛔️ Subcommunity invitation declined for {community_title} to join the {parent_community_title} community').format(**msg_ctx) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + +
{{ _('@{username} declined the invitation for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} +
{{ _("View the request")}}
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _('@{username} declined the invitation for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} + +{{ _("View the request at:") }} {{ request_link }} + +— +{{ _("This is an auto-generated message. To manage notifications, visit your account settings at: ") }}{{ account_settings_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _('@{username} declined the invitation for "{community_title}" to join as a subcommunity of "{parent_community_title}".').format(**msg_ctx) }} + +[{{ _("View the request") }}]({{ request_link }}) + +— +{{ _("This is an auto-generated message. To manage notifications, visit your") }} [{{ _("account settings") }}]({{ account_settings_link }}). +{%- endblock md_body %} diff --git a/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.expire.jinja b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.expire.jinja new file mode 100644 index 000000000..9fc3a0f11 --- /dev/null +++ b/invenio_communities/templates/semantic-ui/invenio_notifications/subcommunity-invitation-request.expire.jinja @@ -0,0 +1,49 @@ +{% set request = notification.context.request %} +{% set parent_community = request.created_by %} +{% set community = request.receiver %} +{% set ui = config.SITE_UI_URL %} +{% set msg_ctx = { + "community_id": community.slug, + "community_title": community.metadata.title, + "parent_community_title": parent_community.metadata.title, + "request_id": request.id, + "ui": ui, +} %} + +{# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} +{% set request_link = "{ui}/communities/{community_id}/requests/{request_id}".format(**msg_ctx) %} +{% set account_settings_link = "{ui}/account/settings/notifications".format(**msg_ctx) %} + +{%- block subject -%} + {{ _("⌛️ The invitation for {community_title} to join the '{parent_community_title}' community expired").format(**msg_ctx) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + +
{{ _("The invitation for '{community_title}' to join the '{parent_community_title}' community has expired.").format(**msg_ctx) }}
{{ _("Check out the invitation")}}
_
{{ _("This is an auto-generated message. To manage notifications, visit your")}} {{ _("account settings")}}.
+{%- endblock html_body %} + +{%- block plain_body -%} +{{ _("The invitation for {community_title} to join the '{parent_community_title}' community has expired.").format(**msg_ctx) }} + +{{ _("Check out the invitation:") }} {{ request_link }} +{%- endblock plain_body %} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("The invitation for *{community_title}* to join community *{parent_community_title}* has expired.").format(**msg_ctx) }} + +[{{ _("Check out the invitation") }}]({{ request_link }}) +{%- endblock md_body %} diff --git a/setup.cfg b/setup.cfg index ca550b1b1..77e02c2b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -83,6 +83,7 @@ invenio_requests.entity_resolvers = invenio_requests.types = community_invitation = invenio_communities.members.services.request:CommunityInvitation subcommunity = invenio_communities.subcommunities.services.request:subcommunity_request_type + subcommunity_invitation = invenio_communities.subcommunities.services.request:subcommunity_invitation_request_type membership_request_request_type = invenio_communities.members.services.request:MembershipRequestRequestType invenio_i18n.translations = messages = invenio_communities