+
+
+
+
+
+
+
+
+ >
+ );
+ }
+}
+
+RecordSearchLayout.propTypes = {
+ config: PropTypes.object.isRequired,
+ appName: PropTypes.string,
+};
+
+RecordSearchLayout.defaultProps = {
+ appName: "",
+};
diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/DeletionStatusFilter.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/DeletionStatusFilter.js
new file mode 100644
index 000000000..66d3a8417
--- /dev/null
+++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/DeletionStatusFilter.js
@@ -0,0 +1,115 @@
+/*
+ * // This file is part of Invenio-Communities
+ * // Copyright (C) 2023 CERN.
+ * //
+ * // 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.
+ */
+
+import { i18next } from "@translations/invenio_requests/i18next";
+import PropTypes from "prop-types";
+import React, { Component } from "react";
+import { withState } from "react-searchkit";
+import { Button } from "semantic-ui-react";
+
+class DeletionStatusFilterComponent extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ selected: "P",
+ };
+ }
+
+ componentDidMount() {
+ const { currentQueryState } = this.props;
+ const userSelectionFilters = currentQueryState.filters;
+ const openFilter = userSelectionFilters.find((obj) => obj.includes("status"));
+ if (openFilter) {
+ // eslint-disable-next-line react/no-did-mount-set-state
+ this.setState({
+ selected: openFilter[1],
+ });
+ }
+ }
+
+ /**
+ * Updates queryFilters based on selection and removing previous filters
+ * @param {string} selectedFilter indicates which button was clicked
+ * @param {string} value true if open requests and false if closed requests
+ */
+ filterRecords = (value = "P") => {
+ const { currentQueryState, updateQueryState, keepFiltersOnUpdate } = this.props;
+ const { selected } = this.state;
+
+ if (selected === value) {
+ return;
+ } else {
+ // remove other filters on change
+ currentQueryState.filters = [];
+ }
+ this.setState({
+ selected: value,
+ });
+
+ currentQueryState.filters = keepFiltersOnUpdate
+ ? currentQueryState.filters.filter((element) => element[0] !== "status")
+ : [];
+
+ currentQueryState.filters.push(["status", value]);
+ updateQueryState(currentQueryState);
+ };
+
+ retrievePublished = () => {
+ this.filterRecords("P");
+ };
+
+ retrieveDeleted = () => {
+ this.filterRecords("D");
+ };
+
+ retrieveMarked = () => {
+ this.filterRecords("X");
+ };
+
+ render() {
+ const { selected } = this.state;
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+DeletionStatusFilterComponent.propTypes = {
+ updateQueryState: PropTypes.func.isRequired,
+ currentQueryState: PropTypes.object.isRequired,
+ keepFiltersOnUpdate: PropTypes.bool,
+};
+
+DeletionStatusFilterComponent.defaultProps = {
+ keepFiltersOnUpdate: false,
+};
+
+export const DeletionStatusFilter = withState(DeletionStatusFilterComponent);
diff --git a/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/index.js b/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/index.js
new file mode 100644
index 000000000..c9eaf304b
--- /dev/null
+++ b/invenio_communities/assets/semantic-ui/js/invenio_communities/administration/search/filters/index.js
@@ -0,0 +1,9 @@
+/*
+ * // This file is part of Invenio-App-Rdm
+ * // Copyright (C) 2023 CERN.
+ * //
+ * // Invenio-App-Rdm is free software; you can redistribute it and/or modify it
+ * // under the terms of the MIT License; see LICENSE file for more details.
+ */
+
+export { DeletionStatusFilter } from "./DeletionStatusFilter";
diff --git a/invenio_communities/communities/records/systemfields/deletion_status.py b/invenio_communities/communities/records/systemfields/deletion_status.py
index 0165b0eda..4fce924ae 100644
--- a/invenio_communities/communities/records/systemfields/deletion_status.py
+++ b/invenio_communities/communities/records/systemfields/deletion_status.py
@@ -109,6 +109,9 @@ def pre_commit(self, record):
def pre_dump(self, record, data, **kwargs):
"""Dump the deletion status information."""
status = CommunityDeletionStatus(record.model.deletion_status)
+ # mitigation of deletion_status.is_deleted missing from the mapping
+ # currently it is a string
+ # don't confuse with record.model.is_deleted!
data["is_deleted"] = status.is_deleted
data["deletion_status"] = status.status
@@ -116,5 +119,8 @@ def post_load(self, record, data, **kwargs):
"""After loading, set the deletion status."""
deletion_status = data.get("deletion_status", None)
self.__set__(record, deletion_status)
+ # mitigation of deletion_status.is_deleted missing from the mapping
+ # currently it is a string
+ # don't confuse with record.model.is_deleted!
record.pop("is_deleted", None)
record.pop("deletion_status", None)
diff --git a/invenio_communities/communities/resources/args.py b/invenio_communities/communities/resources/args.py
new file mode 100644
index 000000000..d7f679aed
--- /dev/null
+++ b/invenio_communities/communities/resources/args.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023 CERN.
+#
+# 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.
+
+"""Schemas for parameter parsing."""
+
+
+from invenio_records_resources.resources.records.args import SearchRequestArgsSchema
+from marshmallow import fields
+
+
+class CommunitiesSearchRequestArgsSchema(SearchRequestArgsSchema):
+ """Extend schema with CSL fields."""
+
+ status = fields.Str()
+ include_deleted = fields.Bool()
diff --git a/invenio_communities/communities/resources/config.py b/invenio_communities/communities/resources/config.py
index fe093e143..e9a21cde1 100644
--- a/invenio_communities/communities/resources/config.py
+++ b/invenio_communities/communities/resources/config.py
@@ -21,6 +21,9 @@
from invenio_records_resources.services.base.config import ConfiguratorMixin, FromConfig
from invenio_requests.resources.requests.config import RequestSearchRequestArgsSchema
+from invenio_communities.communities.resources.args import (
+ CommunitiesSearchRequestArgsSchema,
+)
from invenio_communities.communities.resources.serializer import (
UICommunityJSONSerializer,
)
@@ -60,12 +63,8 @@
),
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(),
+ HTTPJSONException(
+ code=410, description=_("The record has been deleted.")
)
)
),
@@ -89,8 +88,11 @@ class CommunityResourceConfig(RecordResourceConfig, ConfiguratorMixin):
"featured-item": "/communities//featured/",
"user-communities": "/user/communities",
"community-requests": "/communities//requests",
+ "restore-community": "/communities//restore",
}
+ request_search_args = CommunitiesSearchRequestArgsSchema
+
request_view_args = {
**RecordResourceConfig.request_view_args,
"featured_id": ma.fields.Int(),
diff --git a/invenio_communities/communities/resources/resource.py b/invenio_communities/communities/resources/resource.py
index c1938088a..0e014d491 100644
--- a/invenio_communities/communities/resources/resource.py
+++ b/invenio_communities/communities/resources/resource.py
@@ -59,6 +59,7 @@ def create_url_rules(self):
route("PUT", routes["featured-item"], self.featured_update),
route("DELETE", routes["featured-item"], self.featured_delete),
route("GET", routes["community-requests"], self.search_community_requests),
+ route("POST", routes["restore-community"], self.restore_community),
]
@request_search_args
@@ -136,6 +137,36 @@ def update_logo(self):
)
return item.to_dict(), 200
+ #
+ # Deletion workflows
+ #
+ @request_headers
+ @request_view_args
+ @request_data
+ def delete(self):
+ """Read the related review request."""
+ self.service.delete_community(
+ g.identity,
+ resource_requestctx.view_args["pid_value"],
+ resource_requestctx.data,
+ revision_id=resource_requestctx.headers.get("if_match"),
+ )
+
+ return "", 204
+
+ @request_headers
+ @request_view_args
+ @request_data
+ def restore_community(self):
+ """Read the related review request."""
+ item = self.service.restore_community(
+ g.identity,
+ resource_requestctx.view_args["pid_value"],
+ resource_requestctx.data,
+ )
+
+ return item.to_dict(), 200
+
@request_view_args
def delete_logo(self):
"""Delete logo."""
diff --git a/invenio_communities/communities/services/components.py b/invenio_communities/communities/services/components.py
index 49247942e..14e9e7d6e 100644
--- a/invenio_communities/communities/services/components.py
+++ b/invenio_communities/communities/services/components.py
@@ -237,7 +237,7 @@ def update(self, identity, data=None, record=None, **kwargs):
class CommunityDeletionComponent(ServiceComponent):
"""Service component for record deletion."""
- def delete_community(self, identity, data=None, record=None, **kwargs):
+ def delete(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
@@ -250,23 +250,19 @@ 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):
+ def restore(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):
+ def mark(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):
+ def unmark(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
diff --git a/invenio_communities/communities/services/config.py b/invenio_communities/communities/services/config.py
index 0b0516f74..08b361858 100644
--- a/invenio_communities/communities/services/config.py
+++ b/invenio_communities/communities/services/config.py
@@ -43,6 +43,7 @@
from ..schema import CommunityFeaturedSchema, CommunitySchema, TombstoneSchema
from .components import DefaultCommunityComponents
from .links import CommunityLink
+from .search_params import IncludeDeletedCommunitiesParam, StatusParam
from .sort import CommunitiesSortParam
@@ -66,6 +67,8 @@ class SearchOptions(SearchOptionsBase, SearchOptionsMixin):
PaginationParam,
CommunitiesSortParam,
FacetsParam,
+ StatusParam,
+ IncludeDeletedCommunitiesParam,
]
diff --git a/invenio_communities/communities/services/search_params.py b/invenio_communities/communities/services/search_params.py
new file mode 100644
index 000000000..8a759167d
--- /dev/null
+++ b/invenio_communities/communities/services/search_params.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of Invenio.
+# Copyright (C) 2023 CERN.
+#
+# 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.
+
+"""Invenio Communities search params module."""
+from invenio_records_resources.services.records.params import ParamInterpreter
+
+from invenio_communities.communities.records.systemfields.deletion_status import (
+ CommunityDeletionStatusEnum,
+)
+
+
+class StatusParam(ParamInterpreter):
+ """Evaluates the 'status' parameter."""
+
+ def apply(self, identity, search, params):
+ """Evaluate the status parameter on the search."""
+ value = params.pop("status", None)
+ if value is not None and value in [
+ x.value for x in CommunityDeletionStatusEnum
+ ]:
+ search = search.filter("term", **{"deletion_status": value})
+ return search
+
+
+class IncludeDeletedCommunitiesParam(ParamInterpreter):
+ """Evaluates the include_deleted parameter."""
+
+ def apply(self, identity, search, params):
+ """Evaluate the include_deleted parameter on the search."""
+ value = params.pop("include_deleted", None)
+ # Filter prevents from displaying deleted records on main site search
+ # deleted records should appear only in admins panel
+ if value is None:
+ search = search.filter(
+ "term",
+ **{"deletion_status": CommunityDeletionStatusEnum.PUBLISHED.value}
+ )
+ return search
diff --git a/invenio_communities/communities/services/service.py b/invenio_communities/communities/services/service.py
index a9bbb19d8..98f6461b5 100644
--- a/invenio_communities/communities/services/service.py
+++ b/invenio_communities/communities/services/service.py
@@ -101,23 +101,6 @@ def expandable_fields(self):
EntityResolverExpandableField("receiver"),
]
- def delete(self, identity, id_, revision_id=None):
- """Delete a record from database and search indexes."""
- # we assert that record exists
- # when calling super() the method will resolve again the record. We take that
- # compromisation as not resolving it here results to a permission error instead
- # of not-found
- record = self.record_cls.pid.resolve(id_)
-
- # check if requests are open
- requests = self.search_community_requests(
- identity, record.id, {"is_open": True}
- )
- if len(requests) > 0:
- raise OpenRequestsForCommunityDeletionError(len(requests))
-
- return super().delete(identity, record.id, revision_id)
-
def search_user_communities(
self, identity, params=None, search_preference=None, extra_filter=None, **kwargs
):
@@ -128,16 +111,22 @@ def search_user_communities(
params = params or {}
current_user_filter = CommunityMembers().query_filter(identity)
+ undeleted_filter = dsl.Q(
+ "term", **{"deletion_status": CommunityDeletionStatusEnum.PUBLISHED.value}
+ )
+
+ search_filter = current_user_filter & undeleted_filter
+
if extra_filter:
- current_user_filter = current_user_filter & extra_filter
+ search_filter &= extra_filter
search_result = self._search(
"search",
identity,
params,
search_preference,
- permission_action=None,
- extra_filter=current_user_filter,
+ extra_filter=search_filter,
+ permission_action="read",
**kwargs,
).execute()
@@ -463,18 +452,28 @@ def featured_delete(
# Deletion workflows
#
@unit_of_work()
- def delete_community(self, identity, id_, data, expand=False, uow=None):
+ def delete_community(
+ self, identity, id_, data=None, revision_id=None, expand=False, uow=None
+ ):
"""(Soft) delete a published community."""
record = self.record_cls.pid.resolve(id_)
+ # Check permissions
+ self.require_permission(identity, "delete", record=record)
+ self.check_revision_id(record, revision_id)
+
if record.deletion_status.is_deleted:
raise DeletionStatusError(record, CommunityDeletionStatusEnum.PUBLISHED)
- # Check permissions
- self.require_permission(identity, "delete", record=record)
+ # check if requests are open
+ requests = self.search_community_requests(
+ identity, record.id, {"is_open": True}
+ )
+ if len(requests) > 0:
+ raise OpenRequestsForCommunityDeletionError(len(requests))
# Load tombstone data with the schema
data, errors = self.schema_tombstone.load(
- data,
+ data or {},
context={
"identity": identity,
"pid": record.pid,
@@ -484,9 +483,7 @@ def delete_community(self, identity, id_, data, expand=False, uow=None):
)
# Run components
- self.run_components(
- "delete_community", identity, data=data, record=record, uow=uow
- )
+ self.run_components("delete", identity, data=data, record=record, uow=uow)
# Commit and reindex record
uow.register(RecordCommitOp(record, indexer=self.indexer))
@@ -500,6 +497,15 @@ def delete_community(self, identity, id_, data, expand=False, uow=None):
expand=expand,
)
+ @unit_of_work()
+ def delete(
+ self, identity, id_, data=None, expand=False, revision_id=None, uow=None
+ ):
+ """Alias for ``delete_community()``."""
+ return self.delete_community(
+ identity, id_, data, revision_id=revision_id, expand=expand, uow=uow
+ )
+
@unit_of_work()
def update_tombstone(self, identity, id_, data, expand=False, uow=None):
"""Update the tombstone information for the (soft) deleted community."""
@@ -550,7 +556,7 @@ def restore_community(self, identity, id_, expand=False, uow=None):
self.require_permission(identity, "delete", record=record)
# Run components
- self.run_components("restore_community", identity, record=record, uow=uow)
+ self.run_components("restore", identity, record=record, uow=uow)
# Commit and reindex record
uow.register(RecordCommitOp(record, indexer=self.indexer))
@@ -575,7 +581,7 @@ def mark_community_for_purge(self, identity, id_, expand=False, uow=None):
self.require_permission(identity, "purge", record=record)
# Run components
- self.run_components("mark_community", identity, record=record, uow=uow)
+ self.run_components("mark", identity, record=record, uow=uow)
# Commit and reindex record
uow.register(RecordCommitOp(record, indexer=self.indexer))
@@ -600,7 +606,7 @@ def unmark_community_for_purge(self, identity, id_, expand=False, uow=None):
self.require_permission(identity, "purge", record=record)
# Run components
- self.run_components("unmark_community", identity, record=record, uow=uow)
+ self.run_components("unmark", identity, record=record, uow=uow)
# Commit and reindex the record
uow.register(RecordCommitOp(record, indexer=self.indexer))
@@ -635,76 +641,61 @@ def search(
extra_filter=None,
**kwargs,
):
- """Search for active communities matching the querystring."""
- status = CommunityDeletionStatusEnum.PUBLISHED.value
- search_filter = dsl.Q("term", **{"deletion_status": status})
- if extra_filter:
- search_filter = search_filter & extra_filter
-
+ """Search for published communities matching the querystring."""
return super().search(
identity,
params,
search_preference,
expand,
- extra_filter=search_filter,
+ extra_filter=extra_filter,
+ # injects deleted records when user is permitted to see them
+ permission_action="read_deleted",
**kwargs,
)
- def search_all(
- self,
- identity,
- params=None,
- search_preference=None,
- expand=False,
- extra_filter=None,
- **kwargs,
- ):
- """Search for all (active and deleted) communities 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):
+ def read(self, identity, id_, expand=False, include_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:
+ if not include_deleted and record.deletion_status.is_deleted:
raise CommunityDeletedError(record, result_item=result)
+ if include_deleted and record.deletion_status.is_deleted:
+ can_read_deleted = self.check_permission(
+ identity, "read_deleted", record=record
+ )
+
+ if not can_read_deleted:
+ # displays tombstone
+ raise CommunityDeletedError(record, result_item=result)
return result
+ @unit_of_work()
+ def update(self, identity, id_, data, revision_id=None, uow=None, expand=False):
+ """Replace a record."""
+ record = self.record_cls.pid.resolve(id_)
+
+ if record.deletion_status.is_deleted:
+ raise CommunityDeletedError(
+ record,
+ result_item=self.result_item(
+ self,
+ identity,
+ record,
+ links_tpl=self.links_item_tpl,
+ expandable_fields=self.expandable_fields,
+ expand=expand,
+ ),
+ )
+
+ return super().update(
+ identity, id_, data, revision_id=revision_id, uow=uow, expand=expand
+ )
+
#
# notification handlers
#
diff --git a/invenio_communities/generators.py b/invenio_communities/generators.py
index f2c1f7526..af72a499a 100644
--- a/invenio_communities/generators.py
+++ b/invenio_communities/generators.py
@@ -22,6 +22,9 @@
from invenio_records_permissions.generators import ConditionalGenerator, Generator
from invenio_search.engine import dsl
+from .communities.records.systemfields.deletion_status import (
+ CommunityDeletionStatusEnum,
+)
from .proxies import current_roles
_Need = namedtuple("Need", ["method", "value", "role"])
@@ -142,17 +145,56 @@ def __init__(self, field, then_, else_):
)
-class IfDeleted(ConditionalGenerator):
+class IfCommunityDeleted(Generator):
"""Conditional generator for deleted communities."""
- def _condition(self, record=None, **kwargs):
- """Check if the community is deleted."""
- try:
- return record.deletion_status.is_deleted
+ def __init__(self, then_, else_):
+ """Constructor."""
+ self.then_ = then_
+ self.else_ = else_
+
+ def generators(self, record):
+ """Get the "then" or "else" generators."""
+ if record is None:
+ # if no records, we assume it returns standard else response
+ return self.else_
+
+ is_deleted = record.deletion_status.is_deleted
+ return self.then_ if is_deleted else self.else_
+
+ def needs(self, record=None, **kwargs):
+ """Set of Needs granting permission."""
+ needs = [g.needs(record=record, **kwargs) for g in self.generators(record)]
+ return set(chain.from_iterable(needs))
+
+ def excludes(self, record=None, **kwargs):
+ """Set of Needs denying permission."""
+ needs = [g.excludes(record=record, **kwargs) for g in self.generators(record)]
+ return set(chain.from_iterable(needs))
- except AttributeError:
- # if the community doesn't have the attribute, we assume it's not deleted
- return False
+ def make_query(self, generators, **kwargs):
+ """Make a query for one set of generators."""
+ queries = [g.query_filter(**kwargs) for g in generators]
+ queries = [q for q in queries if q]
+ return reduce(operator.or_, queries) if queries else None
+
+ def query_filter(self, **kwargs):
+ """Filters for current identity."""
+ q_then = dsl.Q("match_all")
+ q_else = dsl.Q(
+ "term", **{"deletion_status": CommunityDeletionStatusEnum.PUBLISHED.value}
+ )
+ then_query = self.make_query(self.then_, **kwargs)
+ else_query = self.make_query(self.else_, **kwargs)
+
+ if then_query and else_query:
+ return (q_then & then_query) | (q_else & else_query)
+ elif then_query:
+ return (q_then & then_query) | q_else
+ elif else_query:
+ return q_else & else_query
+ else:
+ return q_else
#
diff --git a/invenio_communities/members/resources/config.py b/invenio_communities/members/resources/config.py
index 009e9dd05..8e63d54df 100644
--- a/invenio_communities/members/resources/config.py
+++ b/invenio_communities/members/resources/config.py
@@ -50,12 +50,8 @@ class MemberResourceConfig(RecordResourceConfig):
),
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(),
+ HTTPJSONException(
+ code=410, description=_("The record has been deleted.")
)
)
),
diff --git a/invenio_communities/permissions.py b/invenio_communities/permissions.py
index 2e4b07426..6bf3059f7 100644
--- a/invenio_communities/permissions.py
+++ b/invenio_communities/permissions.py
@@ -20,6 +20,7 @@
SystemProcess,
)
from invenio_records_permissions.policies import BasePermissionPolicy
+from invenio_users_resources.services.permissions import UserManager
from .generators import (
AllowedMemberTypes,
@@ -30,7 +31,7 @@
CommunityOwners,
CommunitySelfMember,
GroupsEnabled,
- IfDeleted,
+ IfCommunityDeleted,
IfPolicyClosed,
IfRestricted,
)
@@ -48,10 +49,15 @@ class CommunityPermissionPolicy(BasePermissionPolicy):
SystemProcess(),
]
- can_update = [
- IfDeleted(then_=[Disable()], else_=[CommunityOwners(), SystemProcess()])
+ # Used for search filtering of deleted records
+ # cannot be implemented inside can_read - otherwise permission will
+ # kick in before tombstone renders
+ can_read_deleted = [
+ IfCommunityDeleted(then_=[UserManager, SystemProcess()], else_=can_read)
]
+ can_update = [CommunityOwners(), SystemProcess()]
+
can_delete = [CommunityOwners(), SystemProcess()]
can_purge = [CommunityOwners(), SystemProcess()]
diff --git a/invenio_communities/requests/user_moderation/actions.py b/invenio_communities/requests/user_moderation/actions.py
index 48c429ac5..0519d8d4b 100644
--- a/invenio_communities/requests/user_moderation/actions.py
+++ b/invenio_communities/requests/user_moderation/actions.py
@@ -1,26 +1,114 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 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.
"""Communities user moderation actions."""
+from collections import defaultdict
+
from invenio_access.permissions import Identity, system_identity
-from invenio_records_resources.services.uow import RecordIndexOp, unit_of_work
+from invenio_i18n import lazy_gettext as _
+from invenio_pidstore.errors import PIDDoesNotExistError
from invenio_search.engine import dsl
+from invenio_vocabularies.proxies import current_service as vocab_service
+from invenio_communities.communities.records.systemfields.deletion_status import (
+ CommunityDeletionStatusEnum,
+)
from invenio_communities.proxies import current_communities
+def _get_communities_for_user(user_id):
+ """Helper function for getting all communities with the user as the sole owner.
+
+ Note: This function performs DB queries yielding all communities with the given
+ user as the sole owner (which is not hard-limited in the system) and performs
+ service calls on each of them. Thus, this function has the potential of being a very
+ heavy operation, and should not be called as part of the handling of an
+ HTTP request!
+ """
+ comm_cls = current_communities.service.record_cls
+ comm_model_cls = comm_cls.model_cls
+ mem_cls = current_communities.service.members.record_cls
+ mem_model_cls = mem_cls.model_cls
+
+ # collect the owners for each community
+ comm_owners = defaultdict(list)
+ for comm_owner in [
+ mem_cls(m.data, model=m)
+ for m in mem_model_cls.query.filter(mem_model_cls.role == "owner").all()
+ ]:
+ comm_owners[comm_owner.community_id].append(comm_owner)
+
+ # filter for communities that are owned solely by the user in question
+ relevant_comm_ids = [
+ comm_id
+ for comm_id, owners in comm_owners.items()
+ if len(owners) == 1 and str(owners[0].user_id) == user_id
+ ]
+
+ # resolve the communities in question
+ communities = [
+ comm_cls(m.data, model=m)
+ for m in comm_model_cls.query.filter(
+ comm_model_cls.id.in_(relevant_comm_ids)
+ ).all()
+ ]
+
+ return communities
+
+
def on_block(user_id, uow=None, **kwargs):
- """Removes communities that belong to a user."""
- pass
+ """Removes records that belong to a user.
+
+ Note: This function operates on all records of a user and thus has the potential
+ to be a very heavy operation! Thus it should not be called as part of the handling
+ of an HTTP request!
+ """
+ user_id = str(user_id)
+ tombstone_data = {"note": _("User was blocked")}
+
+ # set the removal reason if the vocabulary item exists
+ try:
+ removal_reason_id = kwargs.get("removal_reason_id", "misconduct")
+ vocab = vocab_service.read(
+ identity=system_identity, id_=("removalreasons", removal_reason_id)
+ )
+ tombstone_data["removal_reason"] = {"id": vocab.id}
+ except PIDDoesNotExistError:
+ pass
+
+ # soft-delete all the communities of that user (only if they are the only owner)
+ for comm in _get_communities_for_user(user_id):
+ if not comm.deletion_status.is_deleted:
+ current_communities.service.delete_community(
+ system_identity,
+ comm.pid.pid_value,
+ tombstone_data,
+ uow=uow,
+ )
def on_restore(user_id, uow=None, **kwargs):
- """Restores communities that belong to a user."""
- pass
+ """Restores records that belong to a user.
+
+ Note: This function operates on all records of a user and thus has the potential
+ to be a very heavy operation! Thus it should not be called as part of the handling
+ of an HTTP request!
+ """
+ user_id = str(user_id)
+
+ # restore all the deleted records of that user
+ for comm in _get_communities_for_user(user_id):
+ if comm.deletion_status == CommunityDeletionStatusEnum.DELETED:
+ current_communities.service.restore_community(
+ system_identity,
+ comm.pid.pid_value,
+ uow=uow,
+ )
def on_approve(user_id, uow=None, **kwargs):
diff --git a/invenio_communities/templates/semantic-ui/invenio_communities/tombstone.html b/invenio_communities/templates/semantic-ui/invenio_communities/tombstone.html
index 91254e245..ecf2d30ed 100644
--- a/invenio_communities/templates/semantic-ui/invenio_communities/tombstone.html
+++ b/invenio_communities/templates/semantic-ui/invenio_communities/tombstone.html
@@ -1,27 +1,52 @@
-{# -*- coding: utf-8 -*-
+{#
+ Copyright (C) 2023 CERN.
+ Copyright (C) 2023 TU Wien.
- This file is part of Invenio.
- Copyright (C) 2016-2020 CERN.
-
- Invenio is free software; you can redistribute it and/or modify it
+ Invenio App RDM is free software; you can redistribute it and/or modify it
under the terms of the MIT License; see LICENSE file for more details.
#}
-
{% extends config.THEME_ERROR_TEMPLATE %}
{%- set title = _("Tombstone") + " | " + config.THEME_SITENAME -%}
+{%- set removal_reason = _("Reason unknown") %}
+{%- if record.ui.tombstone.removal_reason %}
+ {%- set removal_reason = record.ui.tombstone.removal_reason.title_l10n %}
+{%- endif %}
+
+{%- block message %}
+
+
+
+
+ {{ _('Gone') }}
+
+
-{% block message %}
-
{{_('Gone')}}
-
- {% trans sitename=config.THEME_SITENAME %}
- The community you are trying to access was removed from {{sitename}}. The
- metadata of the community is kept for archival purposes.
- {% endtrans %}
-
- {% if community and community.removal_reason %}
- Reason for removal: {{community.removal_reason}}
+ {%- trans sitename=config.THEME_SITENAME %}
+ The record you are trying to access was removed from {{ sitename }}. The
+ metadata of the record is kept for archival purposes.
+ {%- endtrans %}
- {% endif %}
+
+
+
+
+ {{ _("Reason for removal:") }} {{ removal_reason }}
+