diff --git a/invenio_communities/communities/resources/config.py b/invenio_communities/communities/resources/config.py index e494e6f0a..d7618c5ce 100644 --- a/invenio_communities/communities/resources/config.py +++ b/invenio_communities/communities/resources/config.py @@ -2,6 +2,7 @@ # # This file is part of Invenio. # Copyright (C) 2016-2021 CERN. +# Copyright (C) 2023 TU Wien. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -14,6 +15,7 @@ ResponseHandler, create_error_handler, ) +from invenio_i18n import lazy_gettext as _ from invenio_records_resources.resources import RecordResourceConfig from invenio_records_resources.services.base.config import ConfiguratorMixin, FromConfig from invenio_requests.resources.requests.config import RequestSearchRequestArgsSchema @@ -22,6 +24,7 @@ UICommunityJSONSerializer, ) from invenio_communities.errors import ( + CommunityDeletedError, CommunityFeaturedEntryDoesNotExistError, LogoSizeLimitError, OpenRequestsForCommunityDeletionError, @@ -54,6 +57,17 @@ description=str(e), ) ), + CommunityDeletedError: create_error_handler( + lambda e: ( + HTTPJSONException(code=404, description=_("Community not found")) + if not e.community.tombstone.is_visible + else HTTPJSONException( + code=410, + description=_("Community deleted"), + tombstone=e.community.tombstone.dump(), + ) + ) + ), } ) diff --git a/invenio_communities/communities/resources/ui_schema.py b/invenio_communities/communities/resources/ui_schema.py index c9ab1d922..250a3f452 100644 --- a/invenio_communities/communities/resources/ui_schema.py +++ b/invenio_communities/communities/resources/ui_schema.py @@ -2,6 +2,7 @@ # # This file is part of Invenio. # Copyright (C) 2022 CERN. +# Copyright (C) 2023 TU Wien. # # Invenio is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -12,11 +13,14 @@ from flask import g from flask_resources import BaseObjectSchema +from invenio_i18n import get_locale +from invenio_i18n import lazy_gettext as _ from invenio_records_resources.services.custom_fields import CustomFieldsSchemaUI from invenio_vocabularies.contrib.awards.serializer import AwardL10NItemSchema from invenio_vocabularies.contrib.funders.serializer import FunderL10NItemSchema from invenio_vocabularies.resources import VocabularyL10Schema -from marshmallow import Schema, fields +from marshmallow import Schema, fields, post_dump +from marshmallow_utils.fields import FormatEDTF as FormatEDTF_ from invenio_communities.proxies import current_communities @@ -30,6 +34,44 @@ def _community_permission_check(action, community, identity): ).allows(identity) +def mask_removed_by(obj): + """Mask information about who removed the community.""" + return_value = _("Unknown") + removed_by = obj.get("removed_by", None) + + if removed_by is not None: + user = removed_by.get("user", None) + + if user == "system": + return_value = _("System (automatic)") + elif user is not None: + return_value = _("User") + + return return_value + + +# Partial to make short definitions in below schema. +FormatEDTF = partial(FormatEDTF_, locale=get_locale) + + +class TombstoneSchema(Schema): + """Schema for a record tombstone.""" + + removal_reason = fields.Nested(VocabularyL10Schema, attribute="removal_reason") + + note = fields.String(attribute="note") + + removed_by = fields.Function(mask_removed_by) + + removal_date_l10n_medium = FormatEDTF(attribute="removal_date", format="medium") + + removal_date_l10n_long = FormatEDTF(attribute="removal_date", format="long") + + citation_text = fields.String(attribute="citation_text") + + is_visible = fields.Boolean(attribute="is_visible") + + class FundingSchema(Schema): """Schema for dumping types in the UI.""" @@ -47,6 +89,8 @@ class UICommunitySchema(BaseObjectSchema): attribute="metadata.funding", ) + tombstone = fields.Nested(TombstoneSchema, attribute="tombstone") + # Custom fields custom_fields = fields.Nested( partial(CustomFieldsSchemaUI, fields_var="COMMUNITIES_CUSTOM_FIELDS") @@ -64,6 +108,14 @@ def get_permissions(self, obj): ) return {"can_include_directly": can_include_directly, "can_update": can_update} + @post_dump + def hide_tombstone(self, obj, **kwargs): + """Hide the tombstone information if it's not visible.""" + if not obj.get("tombstone", {}).get("is_visible", False): + obj.pop("tombstone", None) + + return obj + class TypesSchema(Schema): """Schema for dumping types in the UI.""" diff --git a/invenio_communities/communities/services/components.py b/invenio_communities/communities/services/components.py index 247cb788a..49247942e 100644 --- a/invenio_communities/communities/services/components.py +++ b/invenio_communities/communities/services/components.py @@ -14,6 +14,7 @@ from invenio_access.permissions import system_identity, system_process from invenio_db import db from invenio_i18n import lazy_gettext as _ +from invenio_i18n.proxies import current_i18n from invenio_oaiserver.models import OAISet from invenio_pidstore.errors import PIDDeletedError, PIDDoesNotExistError from invenio_records_resources.services.records.components import ( @@ -26,6 +27,7 @@ from ...proxies import current_roles from ...utils import on_user_membership_change from ..records.systemfields.access import VisibilityEnum +from ..records.systemfields.deletion_status import CommunityDeletionStatusEnum class PIDComponent(ServiceComponent): @@ -232,6 +234,44 @@ def update(self, identity, data=None, record=None, **kwargs): record.custom_fields = data.get("custom_fields", {}) +class CommunityDeletionComponent(ServiceComponent): + """Service component for record deletion.""" + + def delete_community(self, identity, data=None, record=None, **kwargs): + """Set the community's deletion status and tombstone information.""" + # Set the record's deletion status and tombstone information + record.deletion_status = CommunityDeletionStatusEnum.DELETED + record.tombstone = data + + # Set `removed_by` information for the tombstone + record.tombstone.removed_by = identity.id + + def update_tombstone(self, identity, data=None, record=None, **kwargs): + """Update the community's tombstone information.""" + record.tombstone = data + + def restore_community(self, identity, data=None, record=None, **kwargs): + """Reset the community's deletion status and tombstone information.""" + record.deletion_status = CommunityDeletionStatusEnum.PUBLISHED + + # Remove the tombstone information + record.tombstone = None + + # Set a record to 'metadata only' if its files got cleaned up + if not record.files.entries: + record.files.enabled = False + + def mark_community(self, identity, data=None, record=None, **kwargs): + """Mark the community for purge.""" + record.deletion_status = CommunityDeletionStatusEnum.MARKED + record.tombstone = record.tombstone + + def unmark_community(self, identity, data=None, record=None, **kwargs): + """Unmark the community for purge, resetting it to soft-deleted state.""" + record.deletion_status = CommunityDeletionStatusEnum.DELETED + record.tombstone = record.tombstone + + DefaultCommunityComponents = [ MetadataComponent, CustomFieldsComponent, @@ -241,4 +281,5 @@ def update(self, identity, data=None, record=None, **kwargs): OwnershipComponent, FeaturedCommunityComponent, OAISetComponent, + CommunityDeletionComponent, ] diff --git a/invenio_communities/communities/services/config.py b/invenio_communities/communities/services/config.py index 6ef3eae5b..0b0516f74 100644 --- a/invenio_communities/communities/services/config.py +++ b/invenio_communities/communities/services/config.py @@ -40,7 +40,7 @@ ) from ...permissions import CommunityPermissionPolicy, can_perform_action -from ..schema import CommunityFeaturedSchema, CommunitySchema +from ..schema import CommunityFeaturedSchema, CommunitySchema, TombstoneSchema from .components import DefaultCommunityComponents from .links import CommunityLink from .sort import CommunitiesSortParam @@ -95,6 +95,7 @@ class CommunityServiceConfig(RecordServiceConfig, ConfiguratorMixin): # Service schema schema = CommunitySchema schema_featured = CommunityFeaturedSchema + schema_tombstone = TombstoneSchema result_list_cls_featured = CommunityFeaturedList result_item_cls_featured = FeaturedCommunityItem diff --git a/invenio_communities/communities/services/service.py b/invenio_communities/communities/services/service.py index f6d3b15f5..9da03c142 100644 --- a/invenio_communities/communities/services/service.py +++ b/invenio_communities/communities/services/service.py @@ -40,6 +40,9 @@ ) from invenio_communities.generators import CommunityMembers +from ...errors import CommunityDeletedError, DeletionStatusError +from ..records.systemfields.deletion_status import CommunityDeletionStatusEnum + class CommunityService(RecordService): """community Service.""" @@ -85,6 +88,11 @@ def schema_featured(self): """Returns the featured data schema instance.""" return ServiceSchemaWrapper(self, schema=self.config.schema_featured) + @property + def schema_tombstone(self): + """Returns the tombstone data schema instance.""" + return ServiceSchemaWrapper(self, schema=self.config.schema_tombstone) + @property def expandable_fields(self): """Get expandable fields.""" @@ -130,7 +138,7 @@ def search_user_communities( search_preference, permission_action=None, extra_filter=current_user_filter, - **kwargs + **kwargs, ).execute() return self.result_list( @@ -151,7 +159,7 @@ def search_community_requests( params=None, search_preference=None, expand=False, - **kwargs + **kwargs, ): """Search for requests of a specific community.""" self.require_permission(identity, "search_requests", community_id=community_id) @@ -171,7 +179,7 @@ def search_community_requests( ~dsl.Q("term", **{"status": "created"}), ], ), - **kwargs + **kwargs, ).execute() return current_requests_service.result_list( @@ -323,7 +331,7 @@ def featured_search(self, identity, params=None, search_preference=None, **kwarg ], ), permission_action="featured_search", - **kwargs + **kwargs, ).execute() return self.result_list( self, @@ -451,6 +459,252 @@ def featured_delete( return + # + # Deletion workflows + # + @unit_of_work() + def delete_community(self, identity, id_, data, expand=False, uow=None): + """(Soft) delete a published community.""" + record = self.record_cls.pid.resolve(id_) + if record.deletion_status.is_deleted: + raise DeletionStatusError(record, CommunityDeletionStatusEnum.PUBLISHED) + + # Check permissions + self.require_permission(identity, "delete", record=record) + + # Load tombstone data with the schema + data, errors = self.schema_tombstone.load( + data, + context={ + "identity": identity, + "pid": record.pid, + "record": record, + }, + raise_errors=True, + ) + + # Run components + self.run_components( + "delete_community", identity, data=data, record=record, uow=uow + ) + + # Commit and reindex record + uow.register(RecordCommitOp(record, indexer=self.indexer)) + + return self.result_item( + self, + identity, + record, + links_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + @unit_of_work() + def update_tombstone(self, identity, id_, data, expand=False, uow=None): + """Update the tombstone information for the (soft) deleted community.""" + record = self.record_cls.pid.resolve(id_) + if not record.deletion_status.is_deleted: + # strictly speaking, it's two expected statuses: DELETED or MARKED + raise DeletionStatusError(record, CommunityDeletionStatusEnum.DELETED) + + # Check permissions + self.require_permission(identity, "delete", record=record) + + # Load tombstone data with the schema and set it + data, errors = self.schema_tombstone.load( + data, + context={ + "identity": identity, + "pid": record.pid, + "record": record, + }, + raise_errors=True, + ) + + # Run components + self.run_components( + "update_tombstone", identity, data=data, record=record, uow=uow + ) + + # Commit and reindex record + uow.register(RecordCommitOp(record, indexer=self.indexer)) + + return self.result_item( + self, + identity, + record, + links_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + @unit_of_work() + def restore_community(self, identity, id_, expand=False, uow=None): + """Restore a record that has been (soft) deleted.""" + record = self.record_cls.pid.resolve(id_) + if record.deletion_status != CommunityDeletionStatusEnum.DELETED: + raise DeletionStatusError(CommunityDeletionStatusEnum.DELETED, record) + + # Check permissions + self.require_permission(identity, "delete", record=record) + + # Run components + self.run_components("restore_community", identity, record=record, uow=uow) + + # Commit and reindex record + uow.register(RecordCommitOp(record, indexer=self.indexer)) + + return self.result_item( + self, + identity, + record, + links_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + @unit_of_work() + def mark_community_for_purge(self, identity, id_, expand=False, uow=None): + """Mark a (soft) deleted record for purge.""" + record = self.record_cls.pid.resolve(id_) + if record.deletion_status != CommunityDeletionStatusEnum.DELETED: + raise DeletionStatusError(record, CommunityDeletionStatusEnum.DELETED) + + # Check permissions + self.require_permission(identity, "purge", record=record) + + # Run components + self.run_components("mark_community", identity, record=record, uow=uow) + + # Commit and reindex record + uow.register(RecordCommitOp(record, indexer=self.indexer)) + + return self.result_item( + self, + identity, + record, + links_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + @unit_of_work() + def unmark_community_for_purge(self, identity, id_, expand=False, uow=None): + """Remove the mark for deletion from a record, returning it to deleted state.""" + record = self.record_cls.pid.resolve(id_) + if record.deletion_status != CommunityDeletionStatusEnum.MARKED: + raise DeletionStatusError(record, CommunityDeletionStatusEnum.MARKED) + + # Check permissions + self.require_permission(identity, "purge", record=record) + + # Run components + self.run_components("unmark_community", identity, record=record, uow=uow) + + # Commit and reindex the record + uow.register(RecordCommitOp(record, indexer=self.indexer)) + + return self.result_item( + self, + identity, + record, + links_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + @unit_of_work() + def purge_record(self, identity, id_, uow=None): + """Purge a record that has been marked.""" + record = self.record_cls.pid.resolve(id_) + if record.deletion_status != CommunityDeletionStatusEnum.MARKED: + raise DeletionStatusError(record, CommunityDeletionStatusEnum.MARKED) + + raise NotImplementedError() + + # + # Search functions + # + def search( + self, + identity, + params=None, + search_preference=None, + expand=False, + extra_filter=None, + **kwargs, + ): + """Search for published records matching the querystring.""" + status = CommunityDeletionStatusEnum.PUBLISHED.value + search_filter = dsl.Q("term", **{"deletion_status": status}) + if extra_filter: + search_filter = search_filter & extra_filter + + return super().search( + identity, + params, + search_preference, + expand, + extra_filter=search_filter, + **kwargs, + ) + + def search_all( + self, + identity, + params=None, + search_preference=None, + expand=False, + extra_filter=None, + **kwargs, + ): + """Search for all (published and deleted) records matching the querystring.""" + self.require_permission(identity, "search_all") + + # exclude drafts filter (drafts have no deletion status) + search_filter = dsl.Q( + "terms", + **{"deletion_status": [v.value for v in CommunityDeletionStatusEnum]}, + ) + if extra_filter: + search_filter &= extra_filter + search_result = self._search( + "search_all", + identity, + params, + search_preference, + record_cls=self.draft_cls, + search_opts=self.config.search_all, + extra_filter=search_filter, + permission_action="read", # TODO this probably should be read_deleted + **kwargs, + ).execute() + + return self.result_list( + self, + identity, + search_result, + params, + links_tpl=LinksTemplate(self.config.links_search, context={"args": params}), + links_item_tpl=self.links_item_tpl, + expandable_fields=self.expandable_fields, + expand=expand, + ) + + # + # Base methods, extended with handling of deleted records + # + def read(self, identity, id_, expand=False, with_deleted=False): + """Retrieve a record.""" + record = self.record_cls.pid.resolve(id_) + result = super().read(identity, id_, expand=expand) + + if record.deletion_status.is_deleted and not with_deleted: + raise CommunityDeletedError(record, result_item=result) + + return result + # # notification handlers # diff --git a/invenio_communities/errors.py b/invenio_communities/errors.py index c43c85d6f..9acc05d59 100644 --- a/invenio_communities/errors.py +++ b/invenio_communities/errors.py @@ -57,3 +57,21 @@ def __init__(self, requests): requests, ) ) + + +class CommunityDeletedError(CommunityError): + """Error denoting that the community was deleted.""" + + def __init__(self, community, result_item=None): + """Constructor.""" + self.community = community + self.result_item = result_item + + +class DeletionStatusError(CommunityError): + """Indicator for the record being in the wrong deletion status for the action.""" + + def __init__(self, community, expected_status): + """Constructor.""" + self.community = community + self.expected_status = expected_status diff --git a/invenio_communities/generators.py b/invenio_communities/generators.py index b8699a9de..f2c1f7526 100644 --- a/invenio_communities/generators.py +++ b/invenio_communities/generators.py @@ -19,7 +19,7 @@ from flask import current_app from flask_principal import UserNeed from invenio_access.permissions import any_user, system_process -from invenio_records_permissions.generators import Generator +from invenio_records_permissions.generators import ConditionalGenerator, Generator from invenio_search.engine import dsl from .proxies import current_roles @@ -142,6 +142,19 @@ def __init__(self, field, then_, else_): ) +class IfDeleted(ConditionalGenerator): + """Conditional generator for deleted communities.""" + + def _condition(self, record=None, **kwargs): + """Check if the community is deleted.""" + try: + return record.deletion_status.is_deleted + + except AttributeError: + # if the community doesn't have the attribute, we assume it's not deleted + return False + + # # Community membership generators # diff --git a/invenio_communities/members/resources/config.py b/invenio_communities/members/resources/config.py index 4694272e6..009e9dd05 100644 --- a/invenio_communities/members/resources/config.py +++ b/invenio_communities/members/resources/config.py @@ -3,6 +3,7 @@ # Copyright (C) 2022 KTH Royal Institute of Technology # Copyright (C) 2022 Northwestern University. # Copyright (C) 2022 CERN. +# Copyright (C) 2023 TU Wien. # # Invenio-Communities is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -11,8 +12,10 @@ import marshmallow as ma from flask_resources import HTTPJSONException, create_error_handler +from invenio_i18n import lazy_gettext as _ from invenio_records_resources.resources import RecordResourceConfig +from ...errors import CommunityDeletedError from ..errors import AlreadyMemberError, InvalidMemberError @@ -45,4 +48,15 @@ class MemberResourceConfig(RecordResourceConfig): description="A member was already added or invited.", ) ), + CommunityDeletedError: create_error_handler( + lambda e: ( + HTTPJSONException(code=404, description=_("Community not found")) + if not e.community.tombstone.is_visible + else HTTPJSONException( + code=410, + description=_("Community deleted"), + tombstone=e.community.tombstone.dump(), + ) + ) + ), } diff --git a/invenio_communities/permissions.py b/invenio_communities/permissions.py index 508b66752..2e4b07426 100644 --- a/invenio_communities/permissions.py +++ b/invenio_communities/permissions.py @@ -30,6 +30,7 @@ CommunityOwners, CommunitySelfMember, GroupsEnabled, + IfDeleted, IfPolicyClosed, IfRestricted, ) @@ -47,10 +48,14 @@ class CommunityPermissionPolicy(BasePermissionPolicy): SystemProcess(), ] - can_update = [CommunityOwners(), SystemProcess()] + can_update = [ + IfDeleted(then_=[Disable()], else_=[CommunityOwners(), SystemProcess()]) + ] can_delete = [CommunityOwners(), SystemProcess()] + can_purge = [CommunityOwners(), SystemProcess()] + can_manage_access = [ IfConfig("COMMUNITIES_ALLOW_RESTRICTED", then_=can_update, else_=[]), ] diff --git a/tests/communities/test_services.py b/tests/communities/test_services.py index f59e08117..acdc671c8 100644 --- a/tests/communities/test_services.py +++ b/tests/communities/test_services.py @@ -12,13 +12,20 @@ from copy import deepcopy from datetime import datetime, timedelta +import arrow import pytest from invenio_access.permissions import system_identity from invenio_cache import current_cache from invenio_records_resources.services.errors import PermissionDeniedError from marshmallow import ValidationError -from invenio_communities.errors import CommunityFeaturedEntryDoesNotExistError +from invenio_communities.communities.records.systemfields.deletion_status import ( + CommunityDeletionStatusEnum, +) +from invenio_communities.errors import ( + CommunityFeaturedEntryDoesNotExistError, + DeletionStatusError, +) from invenio_communities.fixtures.tasks import reindex_featured_entries @@ -391,3 +398,85 @@ def test_search_community_requests( community_service.search_community_requests( identity=anon_identity, community_id=community.id ) + + +# +# Deletion workflows +# + + +def test_community_deletion(community_service, users, comm): + """Test simple community deletion of a community.""" + user = users["owner"].user + community = comm + + assert community._obj.deletion_status == CommunityDeletionStatusEnum.PUBLISHED + + # delete the community + tombstone_info = {"note": "no specific reason, tbh"} + community = community_service.delete_community( + system_identity, community.id, tombstone_info + ) + tombstone = community._obj.tombstone + + # check if the tombstone information got added as expected + assert community._obj.deletion_status == CommunityDeletionStatusEnum.DELETED + assert tombstone.is_visible + assert tombstone.removed_by == {"user": "system"} + assert tombstone.removal_reason is None + assert tombstone.note == tombstone_info["note"] + assert isinstance(tombstone.citation_text, str) + assert arrow.get(tombstone.removal_date).date() == datetime.utcnow().date() + + # mark the community for purge + community = community_service.mark_community_for_purge( + system_identity, community.id + ) + assert community._obj.deletion_status == CommunityDeletionStatusEnum.MARKED + assert community._obj.deletion_status.is_deleted + assert community._obj.tombstone is not None + + # remove the mark again, we don't wanna purge it after all + community = community_service.unmark_community_for_purge( + system_identity, community.id + ) + assert community._obj.deletion_status == CommunityDeletionStatusEnum.DELETED + assert community._obj.deletion_status.is_deleted + assert community._obj.tombstone is not None + + # restore the community, it wasn't so bad after all + community = community_service.restore_community(system_identity, community.id) + assert community._obj.deletion_status == CommunityDeletionStatusEnum.PUBLISHED + assert not community._obj.deletion_status.is_deleted + assert community._obj.tombstone is None + + +def test_invalid_community_deletion_workflows(community_service, comm): + """Test the wrong order of deletion operations.""" + assert comm._obj.deletion_status == CommunityDeletionStatusEnum.PUBLISHED + + # we cannot restore a published community + with pytest.raises(DeletionStatusError): + community_service.restore_community(system_identity, comm.id) + + # we cannot mark a published community for purge + with pytest.raises(DeletionStatusError): + community_service.mark_community_for_purge(system_identity, comm.id) + + # we cannot unmark a published community + with pytest.raises(DeletionStatusError): + community_service.unmark_community_for_purge(system_identity, comm.id) + + comm = community_service.delete_community(system_identity, comm.id, {}) + assert comm._obj.deletion_status == CommunityDeletionStatusEnum.DELETED + + # we cannot unmark a deleted community + with pytest.raises(DeletionStatusError): + community_service.unmark_community_for_purge(system_identity, comm.id) + + comm = community_service.mark_community_for_purge(system_identity, comm.id) + assert comm._obj.deletion_status == CommunityDeletionStatusEnum.MARKED + + # we cannot directly restore a community marked for purge + with pytest.raises(DeletionStatusError): + community_service.restore_community(system_identity, comm.id)