diff --git a/CHANGES.rst b/CHANGES.rst index e086f87bc..2c2a84bc1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,7 @@ .. Copyright (C) 2019-2024 CERN. Copyright (C) 2019-2024 Northwestern University. + Copyright (C) 2024 KTH Royal Institute of Technology. Invenio-RDM-Records is free software; you can redistribute it and/or @@ -11,6 +12,93 @@ Changes ======= +Version v15.7.0 (released 2024-11-04) + +- resources: make record error handlers configurable + * Possible via the new `RDM_RECORDS_ERROR_HANDLERS` config variable. +- components: make content moderation configurable + * Closes #1861. + * Adds a new `RRM_CONTENT_MODERATION_HANDLERS` config variable to allow + for configuring multiple handlers for the different write actions. +- user_moderation: use search for faster actions + * Use search results to determine the user's list of records. + * Use a TaskOp and Unit of Work to avoid sending Celery tasks immediately. + * Add a cleanup task that will perform a more thorough check using the + DB to lookup the user's records. +- deposit: add missing fields to record deserializer +- UI/UX: add consistent suggestions display to affiliations +- UI/UX: improve display of ROR information +- collections: move records search into service +- collections: added task to compute number of records for each collection +- services: make file-service components configurable +- access notification: provide correct draft preview link + * Closes inveniosoftware/invenio-app-rdm#2827 + +Version v15.6.0 (released 2024-10-18) + +- community: added myCommunitiesEnabled prop to CommunitySelectionSearch + +Version v15.5.0 (released 2024-10-18) + +- community: added autofocus prop to CommunitySelectionSearch + +Version v15.4.0 (released 2024-10-17) + +- DOI: fix wrong parent DOI link +- community: added props to make CommunitySelectionSearch reusable + +Version v15.3.0 (released 2024-10-16) + +- collections: display pages and REST API +- deposit: add feature flag for required community submission flow +- mappings: disable doc_values for geo_shape fields (#1807) + * Fixes multiple values for ``metadata.locaations.features``. + +Version v15.2.0 (released 2024-10-10) + +- webpack: update axios and react-searchkit(due to axios) major versions + +Version v15.1.0 (released 2024-10-10) + +- jobs: register embargo update job type +- installation: upgrade invenio-jbs + +Version v15.0.0 (released 2024-10-08) + +- installation: bump invenio-communities +- dumper: refactor and updated docstring +- awards: added subjects and orgs, updated mappings +- relations: added subject relation in awards + +Version v14.0.0 (released 2024-10-04) + +- installation: bump invenio-vocabularies & invenio-communities + +Version v13.0.0 (released 2024-10-03) + +- collections: added feature, containing core functionalities and DB models +- ui: fixed propTypes warnings +- dependencies: bump flask-iiif to >1.0.0 + +Version v12.2.2 (released 2024-09-30) + +- Improve handling of draft PID in RecordCommunitiesService +- Revert "deposit: check permission and set disable tooltip for publish button" +- Remove DeprecationWarning for sqlalchemy +- Add compatibility layer to move to flask>=3 + +Version v12.2.1 (released 2024-09-19) + +- file upload: better handling of errors when uploading empty files +- serializers: ensure that the vocab id is set before performing a look up +- deposit: take into account the can_publish permission to control when the + Publish button should be enabled or disabled + +Version v12.1.1 (released 2024-09-11) + +- resource: fix add record to community +- controls: refactored isDisabled function + Version v12.1.0 (released 2024-08-30) - config: added links for thumbnails (#1799) diff --git a/invenio_rdm_records/__init__.py b/invenio_rdm_records/__init__.py index fa764db52..c45ae601c 100644 --- a/invenio_rdm_records/__init__.py +++ b/invenio_rdm_records/__init__.py @@ -2,6 +2,7 @@ # # Copyright (C) 2019-2024 CERN. # Copyright (C) 2019-2024 Northwestern University. +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -10,6 +11,6 @@ from .ext import InvenioRDMRecords -__version__ = "12.1.0" +__version__ = "15.7.0" __all__ = ("__version__", "InvenioRDMRecords") diff --git a/invenio_rdm_records/alembic/425b691f768b_create_collections_tables.py b/invenio_rdm_records/alembic/425b691f768b_create_collections_tables.py new file mode 100644 index 000000000..75068011e --- /dev/null +++ b/invenio_rdm_records/alembic/425b691f768b_create_collections_tables.py @@ -0,0 +1,96 @@ +# +# This file is part of Invenio. +# Copyright (C) 2016-2018 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. + +"""Create collections tables.""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils import UUIDType + +# revision identifiers, used by Alembic. +revision = "425b691f768b" +down_revision = "ff9bec971d30" +branch_labels = () +depends_on = None + + +def upgrade(): + """Upgrade database.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "collections_collection_tree", + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("updated", sa.DateTime(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("community_id", UUIDType(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("order", sa.Integer(), nullable=True), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.ForeignKeyConstraint( + ["community_id"], + ["communities_metadata.id"], + name=op.f( + "fk_collections_collection_tree_community_id_communities_metadata" + ), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_collections_collection_tree")), + sa.UniqueConstraint( + "slug", + "community_id", + name="uq_collections_collection_tree_slug_community_id", + ), + ) + op.create_index( + op.f("ix_collections_collection_tree_community_id"), + "collections_collection_tree", + ["community_id"], + unique=False, + ) + + op.create_table( + "collections_collection", + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("updated", sa.DateTime(), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("slug", sa.String(length=255), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("tree_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("query", sa.Text(), nullable=False), + sa.Column("order", sa.Integer(), nullable=True), + sa.Column("depth", sa.Integer(), nullable=True), + sa.Column("num_records", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["tree_id"], + ["collections_collection_tree.id"], + name=op.f("fk_collections_collection_tree_id_collections_collection_tree"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_collections_collection")), + sa.UniqueConstraint( + "slug", "tree_id", name="uq_collections_collection_slug_tree_id" + ), + ) + op.create_index( + op.f("ix_collections_collection_path"), + "collections_collection", + ["path"], + unique=False, + ) + + +def downgrade(): + """Downgrade database.""" + op.drop_index( + op.f("ix_collections_collection_path"), table_name="collections_collection" + ) + op.drop_table("collections_collection") + op.drop_index( + op.f("ix_collections_collection_tree_community_id"), + table_name="collections_collection_tree", + ) + op.drop_table("collections_collection_tree") diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/package.json b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/package.json index 0b9c95a14..593cca25c 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/package.json +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/package.json @@ -3,7 +3,7 @@ "This package.json is needed to run the JS tests, locally and CI." ], "scripts": { - "test": "react-scripts test" + "test": "react-scripts test --transformIgnorePatterns /\"node_modules/(?!axios)/\"" }, "devDependencies": { "@babel/cli": "^7.5.0", @@ -20,7 +20,7 @@ "@translations/invenio_rdm_records": "../../translations/invenio_rdm_records", "ajv": "^8.0.0", "ajv-keywords": "^5.0.0", - "axios": "^0.21.0", + "axios": "^1.7.7", "coveralls": "^3.0.0", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.0", @@ -40,11 +40,11 @@ "react-dom": "^16.13.0", "react-dropzone": "^11.0.0", "react-i18next": "^11.11.0", - "react-invenio-forms": "^3.0.0", + "react-invenio-forms": "^4.0.0", "react-overridable": "^0.0.3", "react-redux": "^7.2.0", "react-scripts": "^5.0.1", - "react-searchkit": "^2.0.0", + "react-searchkit": "^3.0.0", "redux": "^4.0.0", "redux-thunk": "^2.3.0", "rimraf": "^3.0.0", @@ -59,4 +59,4 @@ "typescript": "^4.9.5", "yup": "^0.32.11" } -} +} \ No newline at end of file diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.js index 48145f1cd..0c0d59a19 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // Copyright (C) 2020-2022 Northwestern University. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it @@ -323,6 +323,9 @@ export class RDMDepositRecordSerializer extends DepositRecordSerializer { "pids", "ui", "custom_fields", + "created", + "updated", + "revision_id", ]); // FIXME: move logic in a more sophisticated PIDField that allows empty values diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.test.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.test.js index 879ca4d03..fea57a5e3 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.test.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositRecordSerializer.test.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // Copyright (C) 2020-2022 Northwestern University. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it @@ -368,6 +368,7 @@ describe("RDMDepositRecordSerializer tests", () => { files: false, metadata: false, }, + created: "2020-10-28 18:35:58.113520", expanded: {}, id: "wk205-00878", links: { @@ -496,9 +497,11 @@ describe("RDMDepositRecordSerializer tests", () => { ], version: "v2.0.0", }, + revision_id: 1, ui: { publication_date_l10n: "Sep 28, 2020", }, + updated: "2020-10-28 18:35:58.125222", }; expect(deserializedRecord).toEqual(expectedRecord); }); diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/CommunitySelectionSearch.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/CommunitySelectionSearch.js index 93797c2e6..bc086e15b 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/CommunitySelectionSearch.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/CommunitySelectionSearch.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // Copyright (C) 2020-2022 Northwestern University. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it @@ -43,11 +43,17 @@ export class CommunitySelectionSearch extends Component { toggleText, }, } = this.state; + const { apiConfigs: { allCommunities, myCommunities }, record, isInitialSubmission, + CommunityListItem, + pagination, + myCommunitiesEnabled, + autofocus, } = this.props; + const searchApi = new InvenioSearchApi(selectedSearchApi); const overriddenComponents = { [`${selectedAppId}.ResultsList.item`]: parametrize(CommunityListItem, { @@ -55,6 +61,7 @@ export class CommunitySelectionSearch extends Component { isInitialSubmission: isInitialSubmission, }), }; + return ( <> - - - - - this.setState({ - selectedConfig: allCommunities, - }) - } - > - {i18next.t("All")} - - - this.setState({ - selectedConfig: myCommunities, - }) - } - > - {i18next.t("My communities")} - - - + + {myCommunitiesEnabled && ( + + + + this.setState({ + selectedConfig: allCommunities, + }) + } + > + {i18next.t("All")} + + + this.setState({ + selectedConfig: myCommunities, + }) + } + > + {i18next.t("My communities")} + + + + )} - - - + {pagination && ( + + + + )} @@ -169,10 +180,18 @@ CommunitySelectionSearch.propTypes = { }), record: PropTypes.object.isRequired, isInitialSubmission: PropTypes.bool, + CommunityListItem: PropTypes.elementType, + pagination: PropTypes.bool, + myCommunitiesEnabled: PropTypes.bool, + autofocus: PropTypes.bool, }; CommunitySelectionSearch.defaultProps = { isInitialSubmission: true, + pagination: true, + myCommunitiesEnabled: true, + autofocus: true, + CommunityListItem: CommunityListItem, apiConfigs: { allCommunities: { initialQueryState: { size: 5, page: 1, sortBy: "bestmatch" }, diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/index.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/index.js index a473e8e66..2884158c4 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/index.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/CommunitySelectionModal/index.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -9,4 +9,6 @@ export { CommunitySelectionModalComponent, } from "./CommunitySelectionModal"; +export { CommunitySelectionSearch } from "./CommunitySelectionSearch"; + export { CommunityContext } from "./CommunityContext"; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/index.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/index.js index 52d27ef3a..4b4ca8521 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/index.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/components/index.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // Copyright (C) 2020-2022 Northwestern University. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it @@ -8,6 +8,7 @@ export { CommunitySelectionModal, CommunitySelectionModalComponent, + CommunitySelectionSearch, } from "./CommunitySelectionModal"; export { DepositStatusBox } from "./DepositStatus"; export { RDMUploadProgressNotifier } from "./UploadProgressNotifier"; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/AccessRightField.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/AccessRightField.js index c77e505c7..47b801192 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/AccessRightField.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/AccessRightField.js @@ -104,7 +104,7 @@ AccessRightFieldCmp.propTypes = { showMetadataAccess: PropTypes.bool, community: PropTypes.object, record: PropTypes.object.isRequired, - recordRestrictionGracePeriod: PropTypes.object.isRequired, + recordRestrictionGracePeriod: PropTypes.number.isRequired, allowRecordRestriction: PropTypes.bool.isRequired, }; @@ -140,7 +140,7 @@ AccessRightField.propTypes = { labelIcon: PropTypes.string, isMetadataOnly: PropTypes.bool, record: PropTypes.object.isRequired, - recordRestrictionGracePeriod: PropTypes.object.isRequired, + recordRestrictionGracePeriod: PropTypes.number.isRequired, allowRecordRestriction: PropTypes.bool.isRequired, }; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/MetadataAccess.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/MetadataAccess.js index fc3a904b0..c146f8762 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/MetadataAccess.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/MetadataAccess.js @@ -62,6 +62,6 @@ MetadataAccess.propTypes = { recordAccess: PropTypes.string.isRequired, communityAccess: PropTypes.string.isRequired, record: PropTypes.object.isRequired, - recordRestrictionGracePeriod: PropTypes.object.isRequired, + recordRestrictionGracePeriod: PropTypes.number.isRequired, allowRecordRestriction: PropTypes.bool.isRequired, }; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/ProtectionButtons.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/ProtectionButtons.js index 6cfe988d4..b3ec39d77 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/ProtectionButtons.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AccessField/components/ProtectionButtons.js @@ -1,7 +1,7 @@ // This file is part of Invenio-RDM-Records // Copyright (C) 2020-2023 CERN. // Copyright (C) 2020-2022 Northwestern University. -// Copyright (C) 2021 Graz University of Technology. +// Copyright (C) 2021-2024 Graz University of Technology. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -109,7 +109,11 @@ export class ProtectionButtons extends Component { } } +ProtectionButtons.defaultProps = { + canRestrictRecord: true, +}; + ProtectionButtons.propTypes = { - canRestrictRecord: PropTypes.bool.isRequired, + canRestrictRecord: PropTypes.bool, fieldPath: PropTypes.string.isRequired, }; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AffiliationsField/AffiliationsField.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AffiliationsField/AffiliationsField.js index 3ac77ca87..eac305cc2 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AffiliationsField/AffiliationsField.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/AffiliationsField/AffiliationsField.js @@ -1,5 +1,5 @@ // This file is part of Invenio-RDM-Records -// Copyright (C) 2020-2023 CERN. +// Copyright (C) 2020-2024 CERN. // Copyright (C) 2020-2022 Northwestern University. // Copyright (C) 2021 Graz University of Technology. // @@ -8,23 +8,16 @@ import PropTypes from "prop-types"; import React, { Component } from "react"; -import { FieldLabel, RemoteSelectField } from "react-invenio-forms"; +import { + FieldLabel, + RemoteSelectField, + AffiliationsSuggestions, +} from "react-invenio-forms"; import { Field, getIn } from "formik"; import { i18next } from "@translations/invenio_rdm_records/i18next"; /**Affiliation input component */ export class AffiliationsField extends Component { - serializeAffiliations = (affiliations) => - affiliations.map((affiliation) => ({ - text: affiliation.acronym - ? `${affiliation.name} (${affiliation.acronym})` - : affiliation.name, - value: affiliation.id || affiliation.name, - key: affiliation.id, - id: affiliation.id, - name: affiliation.name, - })); - render() { const { fieldPath, selectRef } = this.props; return ( @@ -38,7 +31,9 @@ export class AffiliationsField extends Component { Accept: "application/vnd.inveniordm.v1+json", }} initialSuggestions={getIn(values, fieldPath, [])} - serializeSuggestions={this.serializeAffiliations} + serializeSuggestions={(affiliations) => { + return AffiliationsSuggestions(affiliations, true); + }} placeholder={i18next.t("Search or create affiliation")} label={ { - let icon = null; - let link = null; - - if (identifier.scheme === "orcid") { - icon = "/static/images/orcid.svg"; - link = "https://orcid.org/" + identifier.identifier; - } else if (identifier.scheme === "gnd") { - icon = "/static/images/gnd-icon.svg"; - link = "https://d-nb.info/gnd/" + identifier.identifier; - } else if (identifier.scheme === "ror") { - icon = "/static/images/ror-icon.svg"; - link = "https://ror.org/" + identifier.identifier; - } else if (identifier.scheme === "isni" || identifier.scheme === "grid") { - return null; - } else { - return ( - <> - {identifier.scheme}: {identifier.identifier} - - ); - } - - return ( - - - - {identifier.scheme === "orcid" ? identifier.identifier : null} - - - ); - }; - - serializeSuggestions = (creatibutors) => { - const results = creatibutors.map((creatibutor) => { - // ensure `affiliations` and `identifiers` are present - creatibutor.affiliations = creatibutor.affiliations || []; - creatibutor.identifiers = creatibutor.identifiers || []; - - let affNames = ""; - if ("affiliations" in creatibutor) { - creatibutor.affiliations.forEach((affiliation, idx) => { - affNames += affiliation.name; - if (idx < creatibutor.affiliations.length - 1) { - affNames += ", "; - } - }); - } - - const idString = []; - creatibutor.identifiers?.forEach((i) => { - idString.push(this.makeIdEntry(i)); - }); - const { isOrganization } = this.state; - - return { - text: creatibutor.name, - value: creatibutor.id, - extra: creatibutor, - key: creatibutor.id, - content: ( -
- {creatibutor.name} {idString.length ? <>({idString}) : null} - - {isOrganization ? creatibutor.acronym : affNames} - -
- ), - }; - }); + serializeAffiliations = (creatibutors) => { + const { isOrganization } = this.state; - const { showPersonForm } = this.state; - const { autocompleteNames } = this.props; - - const showManualEntry = - autocompleteNames === NamesAutocompleteOptions.SEARCH_ONLY && !showPersonForm; - - if (showManualEntry) { - results.push({ - text: "Manual entry", - value: "Manual entry", - extra: "Manual entry", - key: "manual-entry", - content: ( -
- -

- - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid*/} - Couldn't find your person? You can create a new entry. - -

-
-
- ), - }); - } - return results; + return AffiliationsSuggestions(creatibutors, isOrganization); }; updateIdentifiersAndAffiliations( @@ -559,7 +460,7 @@ export class CreatibutorsModal extends Component { /> {_get(values, typeFieldPath, "") === CREATIBUTOR_TYPE.PERSON ? ( -
+ <> {autocompleteNames !== NamesAutocompleteOptions.OFF && ( options} suggestionAPIUrl="/api/names" - serializeSuggestions={this.serializeSuggestions} + serializeSuggestions={this.serializeAffiliations} onValueChange={this.onPersonSearchChange} ref={this.namesAutocompleteRef} /> )} {showPersonForm && ( -
+ <> -
+ )} -
+ ) : ( <> {autocompleteNames !== NamesAutocompleteOptions.OFF && ( @@ -643,7 +544,7 @@ export class CreatibutorsModal extends Component { // Disable UI-side filtering of search results search={(options) => options} suggestionAPIUrl="/api/affiliations" - serializeSuggestions={this.serializeSuggestions} + serializeSuggestions={this.serializeAffiliations} onValueChange={this.onOrganizationSearchChange} /> )} @@ -678,18 +579,16 @@ export class CreatibutorsModal extends Component { {(_get(values, typeFieldPath) === CREATIBUTOR_TYPE.ORGANIZATION || (showPersonForm && _get(values, typeFieldPath) === CREATIBUTOR_TYPE.PERSON)) && ( -
- -
+ )} diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/FileUploader.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/FileUploader.js index 1fd582bab..eb787e306 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/FileUploader.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/FileUploader.js @@ -3,6 +3,7 @@ // Copyright (C) 2020-2022 Northwestern University. // Copyright (C) 2022 Graz University of Technology. // Copyright (C) 2022 TU Wien. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio-RDM-Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -40,6 +41,7 @@ export const FileUploaderComponent = ({ isFileImportInProgress, decimalSizeDisplay, filesLocked, + allowEmptyFiles, ...uiProps }) => { // We extract the working copy of the draft stored as `values` in formik @@ -80,20 +82,38 @@ export const FileUploaderComponent = ({ const maxFileStorageReached = filesSize + acceptedFilesSize > quota.maxStorage; const filesNames = _map(filesList, "name"); - const duplicateFiles = acceptedFiles.filter((acceptedFile) => - filesNames.includes(acceptedFile.name) + const filesNamesSet = new Set(filesNames); + + const { duplicateFiles, emptyFiles, nonEmptyFiles } = acceptedFiles.reduce( + (accumulators, file) => { + if (filesNamesSet.has(file.name)) { + accumulators.duplicateFiles.push(file); + } else if (file.size === 0) { + accumulators.emptyFiles.push(file); + } else { + accumulators.nonEmptyFiles.push(file); + } + + return accumulators; + }, + { duplicateFiles: [], emptyFiles: [], nonEmptyFiles: [] } ); + const hasEmptyFiles = !_isEmpty(emptyFiles); + const hasDuplicateFiles = !_isEmpty(duplicateFiles); + if (maxFileNumberReached) { setWarningMsg(
); @@ -103,7 +123,7 @@ export const FileUploaderComponent = ({ {i18next.t("Uploading the selected files would result in")}{" "} @@ -118,19 +138,44 @@ export const FileUploaderComponent = ({ /> ); - } else if (!_isEmpty(duplicateFiles)) { - setWarningMsg( -
+ } else { + let warnings = []; + + if (hasDuplicateFiles) { + warnings.push( -
- ); - } else { - uploadFiles(formikDraft, acceptedFiles); + ); + } + + if (!allowEmptyFiles && hasEmptyFiles) { + warnings.push( + + ); + } + + if (!_isEmpty(warnings)) { + setWarningMsg(
{warnings}
); + } + + const filesToUpload = allowEmptyFiles + ? [...nonEmptyFiles, ...emptyFiles] + : nonEmptyFiles; + + // Proceed with uploading files if there are any to upload + if (!_isEmpty(filesToUpload)) { + uploadFiles(formikDraft, filesToUpload); + } } }, multiple: true, @@ -348,6 +393,7 @@ FileUploaderComponent.propTypes = { decimalSizeDisplay: PropTypes.bool, filesLocked: PropTypes.bool, permissions: PropTypes.object, + allowEmptyFiles: PropTypes.bool, }; FileUploaderComponent.defaultProps = { @@ -369,4 +415,5 @@ FileUploaderComponent.defaultProps = { importButtonText: i18next.t("Import files"), decimalSizeDisplay: true, filesLocked: false, + allowEmptyFiles: true, }; diff --git a/invenio_rdm_records/collections/__init__.py b/invenio_rdm_records/collections/__init__.py new file mode 100644 index 000000000..5ec831f0b --- /dev/null +++ b/invenio_rdm_records/collections/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections entrypoint.""" + +from .errors import CollectionNotFound, CollectionTreeNotFound, LogoNotFoundError +from .models import Collection, CollectionTree +from .searchapp import search_app_context + +__all__ = ( + "Collection", + "CollectionNotFound", + "CollectionTree", + "CollectionTreeNotFound", + "LogoNotFoundError", + "search_app_context", +) diff --git a/invenio_rdm_records/collections/api.py b/invenio_rdm_records/collections/api.py new file mode 100644 index 000000000..10a686935 --- /dev/null +++ b/invenio_rdm_records/collections/api.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections programmatic API.""" +from invenio_records.systemfields import ModelField +from luqum.parser import parser as luqum_parser +from werkzeug.utils import cached_property + +from .errors import CollectionNotFound, CollectionTreeNotFound, InvalidQuery +from .models import Collection as CollectionModel +from .models import CollectionTree as CollectionTreeModel + + +class Collection: + """Collection Object.""" + + model_cls = CollectionModel + + id = ModelField() + path = ModelField() + ctree_id = ModelField("collection_tree_id") + order = ModelField() + title = ModelField() + slug = ModelField() + depth = ModelField() + search_query = ModelField() + num_records = ModelField() + + def __init__(self, model=None, max_depth=2): + """Instantiate a Collection object.""" + self.model = model + self.max_depth = max_depth + + @classmethod + def validate_query(cls, query): + """Validate the collection query.""" + try: + luqum_parser.parse(query) + except Exception: + raise InvalidQuery() + + @classmethod + def create(cls, slug, title, query, ctree=None, parent=None, order=None, depth=2): + """Create a new collection.""" + _ctree = None + if parent: + path = f"{parent.path}{parent.id}," + _ctree = parent.collection_tree.model + elif ctree: + path = "," + _ctree = ctree if isinstance(ctree, int) else ctree.model + else: + raise ValueError("Either parent or ctree must be set.") + + Collection.validate_query(query) + return cls( + cls.model_cls.create( + slug=slug, + path=path, + title=title, + search_query=query, + order=order, + ctree_or_id=_ctree, + ), + depth, + ) + + @classmethod + def read(cls, *, id_=None, slug=None, ctree_id=None, depth=2): + """Read a collection by ID or slug. + + To read by slug, the collection tree ID must be provided. + """ + res = None + if id_: + res = cls(cls.model_cls.get(id_), depth) + elif slug and ctree_id: + res = cls(cls.model_cls.get_by_slug(slug, ctree_id), depth) + else: + raise ValueError( + "Either ID or slug and collection tree ID must be provided." + ) + + if res.model is None: + raise CollectionNotFound() + return res + + @classmethod + def read_many(cls, ids_, depth=2): + """Read many collections by ID.""" + return [cls(c, depth) for c in cls.model_cls.read_many(ids_)] + + @classmethod + def read_all(cls, depth=2): + """Read all collections.""" + return [cls(c, depth) for c in cls.model_cls.read_all()] + + def update(self, **kwargs): + """Update the collection.""" + if "search_query" in kwargs: + Collection.validate_query(kwargs["search_query"]) + self.model.update(**kwargs) + return self + + def add(self, slug, title, query, order=None, depth=2): + """Add a subcollection to the collection.""" + return self.create( + slug=slug, title=title, query=query, parent=self, order=order, depth=depth + ) + + @property + def collection_tree(self): + """Get the collection tree object. + + Note: this will execute a query to the collection tree table. + """ + return CollectionTree(self.model.collection_tree) + + @cached_property + def community(self): + """Get the community object.""" + return self.collection_tree.community + + @property + def query(self): + """Get the collection query.""" + import operator + from functools import reduce + + from invenio_search.engine import dsl + + queries = [dsl.Q("query_string", query=a.search_query) for a in self.ancestors] + queries.append(dsl.Q("query_string", query=self.search_query)) + return reduce(operator.and_, queries) + + @cached_property + def ancestors(self): + """Get the collection ancestors.""" + ids_ = self.split_path_to_ids() + if not ids_: + return [] + return Collection.read_many(ids_) + + @cached_property + def subcollections(self): + """Fetch descendants. + + If the max_depth is 1, fetch only direct descendants. + """ + if self.max_depth == 0: + return [] + + if self.max_depth == 1: + return self.get_children() + + return self.get_subcollections() + + @cached_property + def children(self): + """Fetch only direct descendants.""" + return self.get_children() + + def split_path_to_ids(self): + """Return the path as a list of integers.""" + if not self.model: + return None + return [int(part) for part in self.path.split(",") if part.strip()] + + def get_children(self): + """Get the collection first level (direct) children. + + More preformant query to retrieve descendants, executes an exact match query. + """ + if not self.model: + return None + res = self.model_cls.get_children(self.model) + return [type(self)(r) for r in res] + + def get_subcollections(self): + """Get the collection subcollections. + + This query executes a LIKE query on the path column. + """ + if not self.model: + return None + + res = self.model_cls.get_subcollections(self.model, self.max_depth) + return [type(self)(r) for r in res] + + def __repr__(self) -> str: + """Return a string representation of the collection.""" + if self.model: + return f"Collection {self.id} ({self.path})" + else: + return "Collection (None)" + + def __eq__(self, value: object) -> bool: + """Check if the value is equal to the collection.""" + return isinstance(value, Collection) and value.id == self.id + + +class CollectionTree: + """Collection Tree Object.""" + + model_cls = CollectionTreeModel + + id = ModelField("id") + title = ModelField("title") + slug = ModelField("slug") + community_id = ModelField("community_id") + order = ModelField("order") + community = ModelField("community") + collections = ModelField("collections") + + def __init__(self, model=None, max_depth=2): + """Instantiate a CollectionTree object.""" + self.model = model + self.max_depth = max_depth + + @classmethod + def create(cls, title, slug, community_id=None, order=None): + """Create a new collection tree.""" + return cls( + cls.model_cls.create( + title=title, slug=slug, community_id=community_id, order=order + ) + ) + + @classmethod + def resolve(cls, id_=None, slug=None, community_id=None): + """Resolve a CollectionTree.""" + res = None + if id_: + res = cls(cls.model_cls.get(id_)) + elif slug and community_id: + res = cls(cls.model_cls.get_by_slug(slug, community_id)) + else: + raise ValueError("Either ID or slug and community ID must be provided.") + + if res.model is None: + raise CollectionTreeNotFound() + return res + + @cached_property + def collections(self): + """Get the collections under this tree.""" + root_collections = CollectionTreeModel.get_collections(self.model, 1) + return [Collection(c, self.max_depth) for c in root_collections] + + @classmethod + def get_community_trees(cls, community_id, depth=2): + """Get all the collection trees for a community.""" + return [cls(c, depth) for c in cls.model_cls.get_community_trees(community_id)] diff --git a/invenio_rdm_records/collections/errors.py b/invenio_rdm_records/collections/errors.py new file mode 100644 index 000000000..e14a457d9 --- /dev/null +++ b/invenio_rdm_records/collections/errors.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Errors for collections module.""" + + +class CollectionError(Exception): + """Base class for collection errors.""" + + +class CollectionNotFound(CollectionError): + """Collection not found error.""" + + +class CollectionTreeNotFound(CollectionError): + """Collection tree not found error.""" + + +class InvalidQuery(CollectionError): + """Query syntax is invalid.""" + + +class LogoNotFoundError(CollectionError): + """Logo not found error.""" diff --git a/invenio_rdm_records/collections/models.py b/invenio_rdm_records/collections/models.py new file mode 100644 index 000000000..5d9c0820a --- /dev/null +++ b/invenio_rdm_records/collections/models.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Collections models.""" + +from invenio_communities.communities.records.models import CommunityMetadata +from invenio_db import db +from invenio_records.models import Timestamp +from sqlalchemy import UniqueConstraint +from sqlalchemy_utils.types import UUIDType + + +# CollectionTree Table +class CollectionTree(db.Model, Timestamp): + """Collection tree model.""" + + __tablename__ = "collections_collection_tree" + + __table_args__ = ( + # Unique constraint on slug and community_id. Slugs should be unique within a community. + UniqueConstraint( + "slug", + "community_id", + name="uq_collections_collection_tree_slug_community_id", + ), + ) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + community_id = db.Column( + UUIDType, + db.ForeignKey(CommunityMetadata.id, ondelete="SET NULL"), + nullable=True, + index=True, + ) + title = db.Column(db.String(255), nullable=False) + order = db.Column(db.Integer, nullable=True) + slug = db.Column(db.String(255), nullable=False) + + # Relationship to Collection + collections = db.relationship("Collection", back_populates="collection_tree") + community = db.relationship(CommunityMetadata, backref="collection_trees") + + @classmethod + def create(cls, title, slug, community_id=None, order=None): + """Create a new collection tree.""" + with db.session.begin_nested(): + collection_tree = cls( + title=title, slug=slug, community_id=community_id, order=order + ) + db.session.add(collection_tree) + return collection_tree + + @classmethod + def get(cls, id_): + """Get a collection tree by ID.""" + return cls.query.get(id_) + + @classmethod + def get_by_slug(cls, slug, community_id): + """Get a collection tree by slug.""" + return cls.query.filter( + cls.slug == slug, cls.community_id == community_id + ).one_or_none() + + @classmethod + def get_community_trees(cls, community_id): + """Get all collection trees of a community.""" + return cls.query.filter(cls.community_id == community_id).order_by(cls.order) + + @classmethod + def get_collections(cls, model, max_depth): + """Get collections under a tree.""" + return Collection.query.filter( + Collection.tree_id == model.id, Collection.depth < max_depth + ).order_by(Collection.path, Collection.order) + + +# Collection Table +class Collection(db.Model, Timestamp): + """Collection model. + + Indices: + - id + - collection_tree_id + - path + - slug + """ + + __tablename__ = "collections_collection" + __table_args__ = ( + # Unique constraint on slug and tree_id. Slugs should be unique within a tree. + UniqueConstraint( + "slug", "tree_id", name="uq_collections_collection_slug_tree_id" + ), + ) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + slug = db.Column(db.String(255), nullable=False) + path = db.Column(db.Text, nullable=False, index=True) + tree_id = db.Column( + db.Integer, db.ForeignKey("collections_collection_tree.id"), nullable=False + ) + title = db.Column(db.String(255), nullable=False) + search_query = db.Column("query", db.Text, nullable=False) + order = db.Column(db.Integer, nullable=True) + # TODO index depth + depth = db.Column(db.Integer, nullable=False) + num_records = db.Column(db.Integer, nullable=False, default=0) + + # Relationship to CollectionTree + collection_tree = db.relationship("CollectionTree", back_populates="collections") + + @classmethod + def create(cls, slug, path, title, search_query, ctree_or_id, **kwargs): + """Create a new collection.""" + depth = len([int(part) for part in path.split(",") if part.strip()]) + with db.session.begin_nested(): + if isinstance(ctree_or_id, CollectionTree): + collection = cls( + slug=slug, + path=path, + title=title, + search_query=search_query, + collection_tree=ctree_or_id, + depth=depth, + **kwargs, + ) + elif isinstance(ctree_or_id, int): + collection = cls( + slug=slug, + path=path, + title=title, + search_query=search_query, + tree_id=ctree_or_id, + depth=depth, + **kwargs, + ) + else: + raise ValueError( + "Either `collection_tree` or `collection_tree_id` must be provided." + ) + db.session.add(collection) + return collection + + @classmethod + def get(cls, id_): + """Get a collection by ID.""" + return cls.query.get(id_) + + @classmethod + def get_by_slug(cls, slug, tree_id): + """Get a collection by slug.""" + return cls.query.filter(cls.slug == slug, cls.tree_id == tree_id).one_or_none() + + @classmethod + def read_many(cls, ids_): + """Get many collections by ID.""" + return cls.query.filter(cls.id.in_(ids_)).order_by(cls.path, cls.order) + + @classmethod + def read_all(cls): + """Get all collections. + + The collections are ordered by ``path`` and ``order``, which means: + + - By path: the collections are ordered in a breadth-first manner (first come the root collection, then the next level, and so on) + - By order: between the same level collections, they are ordered by the specified order field. + """ + return cls.query.order_by(cls.path, cls.order) + + def update(self, **kwargs): + """Update a collection.""" + for key, value in kwargs.items(): + setattr(self, key, value) + + @classmethod + def get_children(cls, model): + """Get children collections of a collection.""" + return cls.query.filter( + cls.path == f"{model.path}{model.id},", cls.tree_id == model.tree_id + ).order_by(cls.path, cls.order) + + @classmethod + def get_subcollections(cls, model, max_depth): + """Get subcollections of a collection. + + This query will return all subcollections of a collection up to a certain depth. + It can be used for max_depth=1, however it is more efficient to use get_children. + """ + return cls.query.filter( + cls.path.like(f"{model.path}{model.id},%"), + cls.tree_id == model.tree_id, + cls.depth < model.depth + max_depth, + ).order_by(cls.path, cls.order) diff --git a/invenio_rdm_records/collections/resources/__init__.py b/invenio_rdm_records/collections/resources/__init__.py new file mode 100644 index 000000000..d9b40da79 --- /dev/null +++ b/invenio_rdm_records/collections/resources/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection resource module.""" diff --git a/invenio_rdm_records/collections/resources/config.py b/invenio_rdm_records/collections/resources/config.py new file mode 100644 index 000000000..581dfea71 --- /dev/null +++ b/invenio_rdm_records/collections/resources/config.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection resource config.""" + +from flask_resources import ( + HTTPJSONException, + JSONSerializer, + ResourceConfig, + ResponseHandler, + create_error_handler, +) +from invenio_records_resources.resources.records.args import SearchRequestArgsSchema +from invenio_records_resources.resources.records.headers import etag_headers +from marshmallow.fields import Integer + +from invenio_rdm_records.resources.serializers import UIJSONSerializer + +from ..errors import CollectionNotFound + + +class CollectionsResourceConfig(ResourceConfig): + """Configuration for the Collection resource.""" + + blueprint_name = "collections" + url_prefix = "/collections" + + routes = { + "search-records": "//records", + } + + request_view_args = {"id": Integer()} + request_search_args = SearchRequestArgsSchema + error_handlers = { + CollectionNotFound: create_error_handler( + HTTPJSONException( + code=404, + description="Collection was not found.", + ) + ), + } + response_handlers = { + "application/json": ResponseHandler(JSONSerializer(), headers=etag_headers), + "application/vnd.inveniordm.v1+json": ResponseHandler( + UIJSONSerializer(), headers=etag_headers + ), + } diff --git a/invenio_rdm_records/collections/resources/resource.py b/invenio_rdm_records/collections/resources/resource.py new file mode 100644 index 000000000..2b505e184 --- /dev/null +++ b/invenio_rdm_records/collections/resources/resource.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection resource.""" + +from flask import g +from flask_resources import Resource, resource_requestctx, response_handler, route +from invenio_records_resources.resources.records.resource import ( + request_search_args, + request_view_args, +) + + +class CollectionsResource(Resource): + """Collection resource.""" + + def __init__(self, config, service): + """Instantiate the resource.""" + super().__init__(config) + self.service = service + + def create_url_rules(self): + """Create the URL rules for the record resource.""" + routes = self.config.routes + return [ + route("GET", routes["search-records"], self.search_records), + ] + + @request_view_args + @request_search_args + @response_handler(many=True) + def search_records(self): + """Search records in a collection.""" + id_ = resource_requestctx.view_args["id"] + records = self.service.search_collection_records( + g.identity, + id_, + params=resource_requestctx.args, + ) + return records.to_dict(), 200 diff --git a/invenio_rdm_records/collections/searchapp.py b/invenio_rdm_records/collections/searchapp.py new file mode 100644 index 000000000..2a05e26b1 --- /dev/null +++ b/invenio_rdm_records/collections/searchapp.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection search app helpers for React-SearchKit.""" + + +from functools import partial + +from flask import current_app +from invenio_search_ui.searchconfig import search_app_config + + +def search_app_context(): + """Search app context.""" + return { + "search_app_collection_config": partial( + search_app_config, + config_name="RDM_SEARCH", + available_facets=current_app.config["RDM_FACETS"], + sort_options=current_app.config["RDM_SORT_OPTIONS"], + headers={"Accept": "application/vnd.inveniordm.v1+json"}, + pagination_options=(10, 25, 50, 100), + ) + } diff --git a/invenio_rdm_records/collections/services/__init__.py b/invenio_rdm_records/collections/services/__init__.py new file mode 100644 index 000000000..10d60ea45 --- /dev/null +++ b/invenio_rdm_records/collections/services/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection service module.""" diff --git a/invenio_rdm_records/collections/services/config.py b/invenio_rdm_records/collections/services/config.py new file mode 100644 index 000000000..62d6264a4 --- /dev/null +++ b/invenio_rdm_records/collections/services/config.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections service config.""" + +from invenio_communities.permissions import CommunityPermissionPolicy +from invenio_records_resources.services import ConditionalLink +from invenio_records_resources.services.base import ServiceConfig +from invenio_records_resources.services.base.config import ConfiguratorMixin, FromConfig + +from .links import CollectionLink +from .results import CollectionItem, CollectionList +from .schema import CollectionSchema + + +class CollectionServiceConfig(ServiceConfig, ConfiguratorMixin): + """Collections service configuration.""" + + result_item_cls = CollectionItem + result_list_cls = CollectionList + service_id = "collections" + permission_policy_cls = FromConfig( + "COMMUNITIES_PERMISSION_POLICY", default=CommunityPermissionPolicy + ) + schema = CollectionSchema + + links_item = { + "search": CollectionLink("/api/collections/{id}/records"), + "self_html": ConditionalLink( + cond=lambda coll, ctx: coll.community, + if_=CollectionLink( + "/communities/{community}/collections/{tree}/{collection}" + ), + else_=CollectionLink("/collections/{tree}/{collection}"), + ), + } diff --git a/invenio_rdm_records/collections/services/links.py b/invenio_rdm_records/collections/services/links.py new file mode 100644 index 000000000..4ba6f0064 --- /dev/null +++ b/invenio_rdm_records/collections/services/links.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collection links.""" + +from invenio_records_resources.services.base.links import Link, LinksTemplate + + +class CollectionLinkstemplate(LinksTemplate): + """Templates for generating links for a collection object.""" + + def __init__(self, links=None, context=None): + """Initialize the links template.""" + super().__init__(links, context) + + +class CollectionLink(Link): + """Link variables setter for Collection links.""" + + @staticmethod + def vars(collection, vars): + """Variables for the URI template.""" + vars.update( + { + "community": collection.community.slug, + "tree": collection.collection_tree.slug, + "collection": collection.slug, + "id": collection.id, + } + ) diff --git a/invenio_rdm_records/collections/services/results.py b/invenio_rdm_records/collections/services/results.py new file mode 100644 index 000000000..21906002c --- /dev/null +++ b/invenio_rdm_records/collections/services/results.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections service items.""" + +from invenio_records_resources.services.base.results import ( + ServiceItemResult, + ServiceListResult, +) + + +class CollectionItem(ServiceItemResult): + """Collection item.""" + + def __init__(self, identity, collection, schema, links_tpl): + """Instantiate a Collection object. + + Optionally pass a community to cache its information in the collection's instance. + """ + self._identity = identity + self._collection = collection + self._schema = schema + self._links_tpl = links_tpl + + def to_dict(self): + """Serialize the collection to a dictionary and add links. + + It will output a dictionary with the following structure: + + .. code-block:: python + + { + "root": 1, + 1: { + "id": 1, + "title": "Root", + "slug": "root", + "depth": 0, + "order": 1, + "query": "", + "num_records": 0, + "children": [2], + "links": { + "self_html": "..." + "search": "..." + } + }, + 2: { + "id": 2, + "title": "Subcollection", + "slug": "subcollection", + "depth": 1, + "order": 1, + "query": "", + "num_records": 0, + "children": [], + "links": { + "self_html": "..." + "search": "..." + } + } + """ + res = { + "root": self._collection.id, + self._collection.id: { + **self._schema.dump( + self._collection, context={"identity": self._identity} + ), + "children": list(), + "links": self._links_tpl.expand(self._identity, self._collection), + }, + } + + for _c in self._collection.subcollections: + if _c.id not in res: + # Add the subcollection to the dictionary + res[_c.id] = { + **self._schema.dump(_c, context={"identity": self._identity}), + "children": list(), + "links": self._links_tpl.expand(self._identity, _c), + } + # Find the parent ID from the collection's path (last valid ID in the path) + path_parts = _c.split_path_to_ids() + if path_parts: + parent_id = path_parts[-1] + # Add the collection as a child of its parent + res[parent_id]["children"].append(_c.id) + + # Add breadcrumbs, sorted from root to leaf and taking into account the `order` field + res[self._collection.id]["breadcrumbs"] = self.breadcrumbs + + return res + + @property + def breadcrumbs(self): + """Get the collection ancestors.""" + res = [] + for anc in self._collection.ancestors: + _a = { + "title": anc.title, + "link": self._links_tpl.expand(self._identity, anc)["self_html"], + } + res.append(_a) + res.append( + { + "title": self._collection.title, + "link": self._links_tpl.expand(self._identity, self._collection)[ + "self_html" + ], + } + ) + return res + + @property + def query(self): + """Get the collection query.""" + return self._collection.query + + +class CollectionList(ServiceListResult): + """Collection list item.""" + + def __init__(self, identity, collections, schema, links_tpl, links_item_tpl): + """Instantiate a Collection list item.""" + self._identity = identity + self._collections = collections + self._schema = schema + self._links_tpl = links_tpl + self._links_item_tpl = links_item_tpl + + def to_dict(self): + """Serialize the collection list to a dictionary.""" + res = [] + for collection in self._collections: + _r = CollectionItem( + self._identity, collection, self._schema, self._links_item_tpl + ).to_dict() + res.append(_r) + return res + + def __iter__(self): + """Iterate over the collections.""" + return ( + CollectionItem(self._identity, x, self._schema, self._links_item_tpl) + for x in self._collections + ) + + +class CollectionTreeItem: + """Collection tree item.""" + + def __init__(self, identity, tree, collection_link_tpl, collection_schema): + """Instantiate a Collection tree object.""" + self._identity = identity + self._tree = tree + self._collection_link_tpl = collection_link_tpl + self._collection_schema = collection_schema + + def to_dict(self): + """Serialize the collection tree to a dictionary.""" + return { + "title": self._tree.title, + "slug": self._tree.slug, + "community_id": str(self._tree.community_id), + "order": self._tree.order, + "id": self._tree.id, + "collections": [ + CollectionItem( + self._identity, + c, + self._collection_schema, + self._collection_link_tpl, + ).to_dict() + for c in self._tree.collections + ], + } + + +class CollectionTreeList: + """Collection tree list item.""" + + def __init__(self, identity, trees, collection_schema, collection_link_tpl): + """Instantiate a Collection tree list item.""" + self._identity = identity + self._trees = trees + self._collection_link_tpl = collection_link_tpl + self._collection_schema = collection_schema + + def to_dict(self): + """Serialize the collection tree list to a dictionary.""" + res = {} + for tree in self._trees: + # Only root collections + res[tree.id] = CollectionTreeItem( + self._identity, + tree, + self._collection_schema, + self._collection_link_tpl, + ).to_dict() + return res + + def __iter__(self): + """Iterate over the collection trees.""" + return iter(self._trees) diff --git a/invenio_rdm_records/collections/services/schema.py b/invenio_rdm_records/collections/services/schema.py new file mode 100644 index 000000000..f2368368b --- /dev/null +++ b/invenio_rdm_records/collections/services/schema.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections schema.""" + +from marshmallow import Schema, fields + + +class CollectionSchema(Schema): + """Collection schema.""" + + slug = fields.Str() + title = fields.Str() + depth = fields.Int(dump_only=True) + order = fields.Int() + id = fields.Int(dump_only=True) + num_records = fields.Int() + search_query = fields.Str(load_only=True) diff --git a/invenio_rdm_records/collections/services/service.py b/invenio_rdm_records/collections/services/service.py new file mode 100644 index 000000000..d7b312ba4 --- /dev/null +++ b/invenio_rdm_records/collections/services/service.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Collections service.""" + +import os + +from flask import current_app, url_for +from invenio_records_resources.services import ServiceSchemaWrapper +from invenio_records_resources.services.base import Service +from invenio_records_resources.services.uow import ModelCommitOp, unit_of_work + +from invenio_rdm_records.proxies import ( + current_community_records_service, + current_rdm_records_service, +) + +from ..api import Collection, CollectionTree +from ..errors import LogoNotFoundError +from .links import CollectionLinkstemplate +from .results import CollectionItem, CollectionList, CollectionTreeList + + +class CollectionsService(Service): + """Collections service.""" + + def __init__(self, config): + """Instantiate the service with the given config.""" + self.config = config + + collection_cls = Collection + + @property + def collection_schema(self): + """Get the collection schema.""" + return ServiceSchemaWrapper(self, schema=self.config.schema) + + @property + def links_item_tpl(self): + """Get the item links template.""" + return CollectionLinkstemplate( + links=self.config.links_item, + context={ + "permission_policy_cls": self.config.permission_policy_cls, + }, + ) + + @unit_of_work() + def create( + self, identity, community_id, tree_slug, slug, title, query, uow=None, **kwargs + ): + """Create a new collection. + + The created collection will be added to the collection tree as a root collection (no parent). + If a parent is needed, use the ``add`` method. + """ + self.require_permission(identity, "update", community_id=community_id) + ctree = CollectionTree.resolve(slug=tree_slug, community_id=community_id) + collection = self.collection_cls.create( + slug=slug, title=title, query=query, ctree=ctree, **kwargs + ) + uow.register(ModelCommitOp(collection.model)) + return CollectionItem( + identity, collection, self.collection_schema, self.links_item_tpl + ) + + def read( + self, + identity=None, + *, + id_=None, + slug=None, + community_id=None, + tree_slug=None, + depth=2, + **kwargs, + ): + """Get a collection by ID or slug. + + To resolve by slug, the collection tree ID and community ID must be provided. + """ + if id_: + collection = self.collection_cls.read(id_=id_, depth=depth) + elif slug and tree_slug and community_id: + ctree = CollectionTree.resolve(slug=tree_slug, community_id=community_id) + collection = self.collection_cls.read( + slug=slug, ctree_id=ctree.id, depth=depth + ) + else: + raise ValueError( + "ID or slug and tree_slug and community_id must be provided." + ) + + if collection.community: + self.require_permission( + identity, "read", community_id=collection.community.id + ) + + return CollectionItem( + identity, + collection, + self.collection_schema, + self.links_item_tpl, + ) + + def list_trees(self, identity, community_id, **kwargs): + """Get the trees of a community.""" + self.require_permission(identity, "read", community_id=community_id) + if not community_id: + raise ValueError("Community ID must be provided.") + res = CollectionTree.get_community_trees(community_id, **kwargs) + return CollectionTreeList( + identity, res, self.links_item_tpl, self.collection_schema + ) + + @unit_of_work() + def add(self, identity, collection, slug, title, query, uow=None, **kwargs): + """Add a subcollection to a collection.""" + self.require_permission( + identity, "update", community_id=collection.community.id + ) + new_collection = self.collection_cls.create( + parent=collection, slug=slug, title=title, query=query, **kwargs + ) + uow.register(ModelCommitOp(new_collection.model)) + return CollectionItem( + identity, new_collection, self.collection_schema, self.links_item_tpl + ) + + @unit_of_work() + def update(self, identity, collection_or_id, data=None, uow=None): + """Update a collection.""" + if isinstance(collection_or_id, int): + collection = self.collection_cls.read(id_=collection_or_id) + else: + collection = collection_or_id + self.require_permission( + identity, "update", community_id=collection.community.id + ) + + data = data or {} + + valid_data, errors = self.collection_schema.load( + data, context={"identity": identity}, raise_errors=True + ) + + res = collection.update(**valid_data) + + uow.register(ModelCommitOp(res.model)) + + return CollectionItem( + identity, collection, self.collection_schema, self.links_item_tpl + ) + + def read_logo(self, identity, slug): + """Read a collection logo. + + TODO: implement logos as files in the database. For now, we just check if the file exists as a static file. + """ + logo_path = f"images/collections/{slug}.jpg" + _exists = os.path.exists(os.path.join(current_app.static_folder, logo_path)) + if _exists: + return url_for("static", filename=logo_path) + raise LogoNotFoundError() + + def read_many(self, identity, ids_, depth=2): + """Get many collections.""" + self.require_permission(identity, "read") + + if ids_ is None: + raise ValueError("IDs must be provided.") + + if ids_ == []: + raise ValueError("Use read_all to get all collections.") + + res = self.collection_cls.read_many(ids_, depth=depth) + return CollectionList( + identity, res, self.collection_schema, None, self.links_item_tpl + ) + + def read_all(self, identity, depth=2): + """Get all collections.""" + self.require_permission(identity, "read") + res = self.collection_cls.read_all(depth=depth) + return CollectionList( + identity, res, self.collection_schema, None, self.links_item_tpl + ) + + def search_collection_records(self, identity, collection_or_id, params=None): + """Search records in a collection.""" + params = params or {} + + if isinstance(collection_or_id, int): + collection = self.collection_cls.read(id_=collection_or_id) + else: + collection = collection_or_id + + if collection.community: + res = current_community_records_service.search( + identity, + community_id=collection.community.id, + extra_filter=collection.query, + ) + else: + raise NotImplementedError( + "Search for collections without community not supported." + ) + return res diff --git a/invenio_rdm_records/collections/tasks.py b/invenio_rdm_records/collections/tasks.py new file mode 100644 index 000000000..951c9d2d2 --- /dev/null +++ b/invenio_rdm_records/collections/tasks.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Collections celery tasks.""" + +from celery import shared_task +from flask import current_app +from invenio_access.permissions import system_identity + +from invenio_rdm_records.proxies import current_rdm_records + + +@shared_task(ignore_result=True) +def update_collections_size(): + """Calculate and update the size of all the collections.""" + collections_service = current_rdm_records.collections_service + res = collections_service.read_all(system_identity, depth=0) + for citem in res: + try: + collection = citem._collection + res = collections_service.search_collection_records( + system_identity, collection + ) + collections_service.update( + system_identity, collection, data={"num_records": res.total} + ) + except Exception as e: + current_app.logger.exception(str(e)) diff --git a/invenio_rdm_records/config.py b/invenio_rdm_records/config.py index ddc1d1f11..80ee5d106 100644 --- a/invenio_rdm_records/config.py +++ b/invenio_rdm_records/config.py @@ -15,6 +15,9 @@ import idutils from invenio_i18n import lazy_gettext as _ +import invenio_rdm_records.services.communities.moderation as communities_moderation +from invenio_rdm_records.services.components.verified import UserModerationHandler + from . import tokens from .resources.serializers import DataCite43JSONSerializer from .services import facets @@ -131,6 +134,12 @@ def always_valid(identifier): RDM_ALLOW_RESTRICTED_RECORDS = True """Allow users to set restricted/private records.""" +# +# Record communities +# +RDM_COMMUNITY_REQUIRED_TO_PUBLISH = False +"""Enforces at least one community per record.""" + # # Search configuration # @@ -549,6 +558,16 @@ def make_doi(prefix, record): def lock_edit_published_files(service, identity, record=None, draft=None): """ +RDM_CONTENT_MODERATION_HANDLERS = [ + UserModerationHandler(), +] +"""Records content moderation handlers.""" + +RDM_COMMUNITY_CONTENT_MODERATION_HANDLERS = [ + communities_moderation.UserModerationHandler(), +] +"""Community content moderation handlers.""" + # Feature flag to enable/disable user moderation RDM_USER_MODERATION_ENABLED = False """Flag to enable creation of user moderation requests on specific user actions.""" diff --git a/invenio_rdm_records/ext.py b/invenio_rdm_records/ext.py index b9802d80f..be975dd37 100644 --- a/invenio_rdm_records/ext.py +++ b/invenio_rdm_records/ext.py @@ -19,6 +19,10 @@ from invenio_records_resources.resources.files import FileResource from . import config +from .collections.resources.config import CollectionsResourceConfig +from .collections.resources.resource import CollectionsResource +from .collections.services.config import CollectionServiceConfig +from .collections.services.service import CollectionsService from .oaiserver.resources.config import OAIPMHServerResourceConfig from .oaiserver.resources.resources import OAIPMHServerResource from .oaiserver.services.config import OAIPMHServerServiceConfig @@ -158,6 +162,7 @@ class ServiceConfigs: record_communities = RDMRecordCommunitiesConfig.build(app) community_records = RDMCommunityRecordsConfig.build(app) record_requests = RDMRecordRequestsConfig.build(app) + collections = CollectionServiceConfig.build(app) return ServiceConfigs @@ -203,6 +208,10 @@ def init_services(self, app): config=service_configs.oaipmh_server, ) + self.collections_service = CollectionsService( + config=service_configs.collections + ) + def init_resource(self, app): """Initialize resources.""" self.records_resource = RDMRecordResource( @@ -271,6 +280,12 @@ def init_resource(self, app): config=RDMCommunityRecordsResourceConfig.build(app), ) + # Collections + self.collections_resource = CollectionsResource( + service=self.collections_service, + config=CollectionsResourceConfig, + ) + # OAI-PMH self.oaipmh_server_resource = OAIPMHServerResource( service=self.oaipmh_server_service, diff --git a/invenio_rdm_records/fixtures/vocabularies.py b/invenio_rdm_records/fixtures/vocabularies.py index cf7467baa..a802070fc 100644 --- a/invenio_rdm_records/fixtures/vocabularies.py +++ b/invenio_rdm_records/fixtures/vocabularies.py @@ -4,6 +4,7 @@ # Copyright (C) 2021-2022 Northwestern University. # Copyright (C) 2024 TU Wien. # Copyright (C) 2024 KTH Royal Institute of Technology. +# Copyright (C) 2024 Graz University of Technology. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -186,13 +187,19 @@ def load(self, reload=None): Fixtures found later are ignored. """ # Prime with existing (sub)vocabularies - v_type_ids = [v.id for v in VocabularyType.query.options(load_only("id")).all()] + v_type_ids = [ + v.id + for v in VocabularyType.query.options(load_only(VocabularyType.id)).all() + ] v_subtype_ids = [ f"{v.parent_id}.{v.id}" - for v in VocabularyScheme.query.options(load_only("id", "parent_id")).all() + for v in VocabularyScheme.query.options( + load_only(VocabularyScheme.id, VocabularyScheme.parent_id) + ).all() ] self._loaded_vocabularies = set(v_type_ids + v_subtype_ids) - if reload: + # If it's not already loaded it means it's a new one + if reload and reload in self._loaded_vocabularies: self._loaded_vocabularies.remove(reload) # 1- Load from app_data_folder filepath = self._app_data_folder / self._filename diff --git a/invenio_rdm_records/jobs/__init__.py b/invenio_rdm_records/jobs/__init__.py new file mode 100644 index 000000000..b394efcad --- /dev/null +++ b/invenio_rdm_records/jobs/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Jobs module.""" diff --git a/invenio_rdm_records/jobs/jobs.py b/invenio_rdm_records/jobs/jobs.py new file mode 100644 index 000000000..7f0d56a5f --- /dev/null +++ b/invenio_rdm_records/jobs/jobs.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Jobs definition module.""" + +from invenio_jobs.jobs import JobType + +from invenio_rdm_records.services.tasks import update_expired_embargos + +update_expired_embargos_cls = JobType.create( + arguments_schema=None, + job_cls_name="UpdateEmbargoesJob", + id_="update_expired_embargos", + task=update_expired_embargos, + description="Updates expired embargos", + title="Update expired embargoes", +) diff --git a/invenio_rdm_records/oaiserver/services/services.py b/invenio_rdm_records/oaiserver/services/services.py index 392a63db9..516db9093 100644 --- a/invenio_rdm_records/oaiserver/services/services.py +++ b/invenio_rdm_records/oaiserver/services/services.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2022-2023 Graz University of Technology. +# Copyright (C) 2022-2024 Graz University of Technology. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -10,7 +10,6 @@ import re from flask import current_app -from flask_sqlalchemy import Pagination from invenio_db import db from invenio_i18n import lazy_gettext as _ from invenio_oaiserver.models import OAISet @@ -32,6 +31,13 @@ ) from .uow import OAISetCommitOp, OAISetDeleteOp +try: + # flask_sqlalchemy<3.0.0 + from flask_sqlalchemy import Pagination +except ImportError: + # flask_sqlalchemy>=3.0.0 + from flask_sqlalchemy.pagination import Pagination + class OAIPMHServerService(Service): """OAI-PMH service.""" diff --git a/invenio_rdm_records/records/api.py b/invenio_rdm_records/records/api.py index e8f91b4e6..0348f53bb 100644 --- a/invenio_rdm_records/records/api.py +++ b/invenio_rdm_records/records/api.py @@ -52,6 +52,7 @@ EDTFListDumperExt, GrantTokensDumperExt, StatisticsDumperExt, + SubjectHierarchyDumperExt, ) from .systemfields import ( HasDraftCheckField, @@ -122,6 +123,7 @@ class CommonFieldsMixin: CombinedSubjectsDumperExt(), CustomFieldsDumperExt(fields_var="RDM_CUSTOM_FIELDS"), StatisticsDumperExt("stats"), + SubjectHierarchyDumperExt(), ] ) @@ -150,10 +152,25 @@ class CommonFieldsMixin: funding_award=PIDListRelation( "metadata.funding", relation_field="award", - keys=["title", "number", "identifiers", "acronym", "program"], + keys=[ + "title", + "number", + "identifiers", + "acronym", + "program", + "subjects", + "organizations", + ], pid_field=Award.pid, cache_key="awards", ), + funding_award_subjects=PIDNestedListRelation( + "metadata.funding", + relation_field="award.subjects", + keys=["subject", "scheme", "props"], + pid_field=Subject.pid, + cache_key="subjects", + ), languages=PIDListRelation( "metadata.languages", keys=["title"], @@ -169,7 +186,7 @@ class CommonFieldsMixin: ), subjects=PIDListRelation( "metadata.subjects", - keys=["subject", "scheme"], + keys=["subject", "scheme", "props"], pid_field=Subject.pid, cache_key="subjects", ), diff --git a/invenio_rdm_records/records/dumpers/__init__.py b/invenio_rdm_records/records/dumpers/__init__.py index 85515d095..d2b25b92e 100644 --- a/invenio_rdm_records/records/dumpers/__init__.py +++ b/invenio_rdm_records/records/dumpers/__init__.py @@ -13,6 +13,7 @@ from .locations import LocationsDumper from .pids import PIDsDumperExt from .statistics import StatisticsDumperExt +from .subject_hierarchy import SubjectHierarchyDumperExt __all__ = ( "CombinedSubjectsDumperExt", @@ -22,4 +23,5 @@ "GrantTokensDumperExt", "LocationsDumper", "StatisticsDumperExt", + "SubjectHierarchyDumperExt", ) diff --git a/invenio_rdm_records/records/dumpers/subject_hierarchy.py b/invenio_rdm_records/records/dumpers/subject_hierarchy.py new file mode 100644 index 000000000..d10e91c49 --- /dev/null +++ b/invenio_rdm_records/records/dumpers/subject_hierarchy.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Search dumpers for subject hierarchy support.""" + +from invenio_records.dumpers import SearchDumperExt + + +class SubjectHierarchyDumperExt(SearchDumperExt): + """Search dumper extension for subject hierarchy support. + + It parses the values of the `subjects` field in the document, builds hierarchical + parent notations, and adds entries to the `hierarchy` field for each subject in award. + + This dumper needs to be placed after the RelationDumper for subjects as it relies + on dereferenced subjects with scheme, subject, and props. + + Example + "subjects" : + [ + { + "id" : "euroscivoc:425", + "subject" : "Energy and fuels", + "scheme" : "EuroSciVoc", + "props" : { + "parents" : "euroscivoc:25,euroscivoc:67", + }, + "@v" : "ef9f1c4c-b469-4645-a4b2-0db9b1c42096::1" + } + ] + + The above subject is dumped with hierarchy field and is transformed to + + "subjects" : + [ + { + "id" : "euroscivoc:425", + "subject" : "Energy and fuels", + "scheme" : "EuroSciVoc", + "props" : { + "parents" : "euroscivoc:25,euroscivoc:67", + "hierarchy" : [ + "euroscivoc:25", + "euroscivoc:25,euroscivoc:67", + "euroscivoc:25,euroscivoc:67,euroscivoc:425" + ] + }, + "@v" : "ef9f1c4c-b469-4645-a4b2-0db9b1c42096::1" + } + ] + """ + + def __init__(self, splitchar=","): + """Constructor. + + :param splitchar: string to use to combine subject ids in hierarchy + """ + super().__init__() + self._splitchar = splitchar + + def dump(self, record, data): + """Dump the data to secondary storage (OpenSearch-like).""" + awards = data.get("metadata", {}).get("funding", []) + + def build_hierarchy(parents_str, current_subject_id): + """Build the hierarchy by progressively combining parent notations.""" + if not parents_str: + return [ + current_subject_id + ] # No parents, so the hierarchy is just the current ID. + + parents = parents_str.split(self._splitchar) # Split the parent notations + hierarchy = [] + current_hierarchy = parents[0] # Start with the top-level parent + + hierarchy.append(current_hierarchy) + for parent in parents[1:]: + current_hierarchy = f"{current_hierarchy}{self._splitchar}{parent}" + hierarchy.append(current_hierarchy) + + hierarchy.append( + f"{current_hierarchy}{self._splitchar}{current_subject_id}" + ) + return hierarchy + + for award in awards: + subjects = award.get("award", {}).get("subjects", []) + for subject in subjects: + parents = subject.get("props", {}).get("parents", "") + current_subject_id = subject.get("id", "") + if current_subject_id: + subject_hierarchy = build_hierarchy(parents, current_subject_id) + subject.setdefault("props", {})["hierarchy"] = subject_hierarchy + + if awards: + data["metadata"]["funding"] = awards + + def load(self, data, record_cls): + """Load the data from secondary storage (OpenSearch-like). + + This is run against the parent too (for some reason), so presence of any + field cannot be assumed. + """ + pass diff --git a/invenio_rdm_records/records/jsonschemas/records/definitions-v2.0.0.json b/invenio_rdm_records/records/jsonschemas/records/definitions-v2.0.0.json index beb527ae5..56a857ec5 100644 --- a/invenio_rdm_records/records/jsonschemas/records/definitions-v2.0.0.json +++ b/invenio_rdm_records/records/jsonschemas/records/definitions-v2.0.0.json @@ -13,7 +13,7 @@ }, "affiliations": { "type": "array", - "items": {"$ref": "#/affiliation"} + "items": { "$ref": "#/affiliation" } }, "agent": { "description": "An agent (user, software process, community, ...).", @@ -72,16 +72,11 @@ { "title": "GeoJSON Point", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "Point" - ] + "enum": ["Point"] }, "coordinates": { "type": "array", @@ -102,16 +97,11 @@ { "title": "GeoJSON LineString", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "LineString" - ] + "enum": ["LineString"] }, "coordinates": { "type": "array", @@ -136,16 +126,11 @@ { "title": "GeoJSON Polygon", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "Polygon" - ] + "enum": ["Polygon"] }, "coordinates": { "type": "array", @@ -173,16 +158,11 @@ { "title": "GeoJSON MultiPoint", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "MultiPoint" - ] + "enum": ["MultiPoint"] }, "coordinates": { "type": "array", @@ -206,16 +186,11 @@ { "title": "GeoJSON MultiLineString", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "MultiLineString" - ] + "enum": ["MultiLineString"] }, "coordinates": { "type": "array", @@ -243,16 +218,11 @@ { "title": "GeoJSON MultiPolygon", "type": "object", - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "properties": { "type": { "type": "string", - "enum": [ - "MultiPolygon" - ] + "enum": ["MultiPolygon"] }, "coordinates": { "type": "array", @@ -311,10 +281,7 @@ "nameType": { "description": "Type of name.", "type": "string", - "enum": [ - "personal", - "organizational" - ] + "enum": ["personal", "organizational"] }, "person_or_org": { "type": "object", @@ -386,7 +353,7 @@ }, "subjects": { "type": "array", - "items": {"$ref": "#/subject"} + "items": { "$ref": "#/subject" } }, "title_type": { "description": "Type of title.", diff --git a/invenio_rdm_records/records/jsonschemas/records/record-v6.0.0.json b/invenio_rdm_records/records/jsonschemas/records/record-v6.0.0.json index 84d02b795..d426401cc 100644 --- a/invenio_rdm_records/records/jsonschemas/records/record-v6.0.0.json +++ b/invenio_rdm_records/records/jsonschemas/records/record-v6.0.0.json @@ -400,18 +400,12 @@ "record": { "description": "Record visibility (public or restricted)", "type": "string", - "enum": [ - "public", - "restricted" - ] + "enum": ["public", "restricted"] }, "files": { "description": "Files visibility (public or restricted)", "type": "string", - "enum": [ - "public", - "restricted" - ] + "enum": ["public", "restricted"] }, "embargo": { "description": "Description of the embargo on the record.", @@ -420,25 +414,16 @@ "properties": { "active": { "description": "Whether or not the embargo is (still) active.", - "type": [ - "boolean", - "null" - ] + "type": ["boolean", "null"] }, "until": { "description": "Embargo date of record (ISO8601 formatted date time in UTC). At this time both metadata and files will be made public.", - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "format": "date" }, "reason": { "description": "The reason why the record is under embargo.", - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } } } diff --git a/invenio_rdm_records/records/mappings/os-v1/rdmrecords/drafts/draft-v6.0.0.json b/invenio_rdm_records/records/mappings/os-v1/rdmrecords/drafts/draft-v6.0.0.json index 7e7b4cddc..3c532097e 100644 --- a/invenio_rdm_records/records/mappings/os-v1/rdmrecords/drafts/draft-v6.0.0.json +++ b/invenio_rdm_records/records/mappings/os-v1/rdmrecords/drafts/draft-v6.0.0.json @@ -353,6 +353,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -512,6 +545,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -896,6 +962,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -1135,6 +1234,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, diff --git a/invenio_rdm_records/records/mappings/os-v1/rdmrecords/records/record-v7.0.0.json b/invenio_rdm_records/records/mappings/os-v1/rdmrecords/records/record-v7.0.0.json index 3e29366cd..3e3cd6600 100644 --- a/invenio_rdm_records/records/mappings/os-v1/rdmrecords/records/record-v7.0.0.json +++ b/invenio_rdm_records/records/mappings/os-v1/rdmrecords/records/record-v7.0.0.json @@ -18,6 +18,8 @@ "metadata.funding.award.identifiers.identifier", "metadata.funding.award.acronym.text", "metadata.funding.award.number", + "metadata.funding.award.subjects", + "metadata.funding.award.organizations", "metadata.funding.funder.name", "metadata.identifiers.identifier", "metadata.locations.features.place", @@ -409,6 +411,39 @@ } } }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } + }, "funder": { "type": "object", "properties": { @@ -565,6 +600,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -911,6 +979,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -973,7 +1074,8 @@ "type": "geo_point" }, "geometry": { - "type": "geo_shape" + "type": "geo_shape", + "doc_values": false }, "place": { "type": "text" @@ -1150,6 +1252,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, diff --git a/invenio_rdm_records/records/mappings/os-v2/rdmrecords/drafts/draft-v6.0.0.json b/invenio_rdm_records/records/mappings/os-v2/rdmrecords/drafts/draft-v6.0.0.json index 7e7b4cddc..3c532097e 100644 --- a/invenio_rdm_records/records/mappings/os-v2/rdmrecords/drafts/draft-v6.0.0.json +++ b/invenio_rdm_records/records/mappings/os-v2/rdmrecords/drafts/draft-v6.0.0.json @@ -353,6 +353,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -512,6 +545,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -896,6 +962,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -1135,6 +1234,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, diff --git a/invenio_rdm_records/records/mappings/os-v2/rdmrecords/records/record-v7.0.0.json b/invenio_rdm_records/records/mappings/os-v2/rdmrecords/records/record-v7.0.0.json index 3e29366cd..9291eec90 100644 --- a/invenio_rdm_records/records/mappings/os-v2/rdmrecords/records/record-v7.0.0.json +++ b/invenio_rdm_records/records/mappings/os-v2/rdmrecords/records/record-v7.0.0.json @@ -18,6 +18,8 @@ "metadata.funding.award.identifiers.identifier", "metadata.funding.award.acronym.text", "metadata.funding.award.number", + "metadata.funding.award.subjects", + "metadata.funding.award.organizations", "metadata.funding.funder.name", "metadata.identifiers.identifier", "metadata.locations.features.place", @@ -406,6 +408,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -565,6 +600,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -911,6 +979,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -973,7 +1074,8 @@ "type": "geo_point" }, "geometry": { - "type": "geo_shape" + "type": "geo_shape", + "doc_values": false }, "place": { "type": "text" @@ -1150,6 +1252,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, diff --git a/invenio_rdm_records/records/mappings/v7/rdmrecords/drafts/draft-v6.0.0.json b/invenio_rdm_records/records/mappings/v7/rdmrecords/drafts/draft-v6.0.0.json index 4a8feb346..1a901cbfe 100644 --- a/invenio_rdm_records/records/mappings/v7/rdmrecords/drafts/draft-v6.0.0.json +++ b/invenio_rdm_records/records/mappings/v7/rdmrecords/drafts/draft-v6.0.0.json @@ -356,6 +356,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -515,6 +548,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -896,6 +962,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -1135,6 +1234,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, diff --git a/invenio_rdm_records/records/mappings/v7/rdmrecords/records/record-v6.0.0.json b/invenio_rdm_records/records/mappings/v7/rdmrecords/records/record-v6.0.0.json index 3145c3bb6..941fca4e4 100644 --- a/invenio_rdm_records/records/mappings/v7/rdmrecords/records/record-v6.0.0.json +++ b/invenio_rdm_records/records/mappings/v7/rdmrecords/records/record-v6.0.0.json @@ -356,6 +356,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -515,6 +548,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -853,6 +919,39 @@ "type": "keyword" } } + }, + "subjects": { + "properties": { + "@v": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "subject": { + "type": "keyword" + }, + "scheme": { + "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" + } + } + }, + "organizations": { + "properties": { + "scheme": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "organization": { + "type": "keyword" + } + } } } }, @@ -1092,6 +1191,10 @@ }, "scheme": { "type": "keyword" + }, + "props": { + "type": "object", + "dynamic": "true" } } }, @@ -1440,6 +1543,8 @@ "metadata.funding.award.identifiers.identifier", "metadata.funding.award.acronym.text", "metadata.funding.award.number", + "metadata.funding.award.subjects", + "metadata.funding.award.organizations", "metadata.funding.funder.name", "metadata.identifiers.identifier", "metadata.locations.features.place", diff --git a/invenio_rdm_records/requests/user_moderation/actions.py b/invenio_rdm_records/requests/user_moderation/actions.py index 43a1442c4..c443af72c 100644 --- a/invenio_rdm_records/requests/user_moderation/actions.py +++ b/invenio_rdm_records/requests/user_moderation/actions.py @@ -8,38 +8,18 @@ """RDM user moderation action.""" from invenio_access.permissions import system_identity -from invenio_db import db from invenio_pidstore.errors import PIDDoesNotExistError +from invenio_records_resources.services.uow import TaskOp from invenio_vocabularies.proxies import current_service from ...proxies import current_rdm_records_service -from .tasks import delete_record, restore_record - - -def _get_records_for_user(user_id): - """Helper function for getting all the records of the user. - - Note: This function performs DB queries yielding all records for a given - user (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! - """ - record_cls = current_rdm_records_service.record_cls - model_cls = record_cls.model_cls - parent_cls = record_cls.parent_record_cls - parent_model_cls = parent_cls.model_cls - - records = ( - db.session.query(model_cls.json["id"].as_string()) - .join(parent_model_cls) - .filter( - parent_model_cls.json["access"]["owned_by"]["user"].as_string() - == str(user_id), - ) - ).yield_per(1000) - - return records +from .tasks import ( + delete_record, + restore_record, + user_block_cleanup, + user_restore_cleanup, +) +from .utils import get_user_records def on_block(user_id, uow=None, **kwargs): @@ -63,8 +43,18 @@ def on_block(user_id, uow=None, **kwargs): pass # soft-delete all the published records of that user - for (recid,) in _get_records_for_user(user_id): - delete_record.delay(recid, tombstone_data) + for recid in get_user_records(user_id): + uow.register(TaskOp(delete_record, recid=recid, tombstone_data=tombstone_data)) + + # Send cleanup task to make sure all records are deleted + uow.register( + TaskOp.for_async_apply( + user_block_cleanup, + kwargs=dict(user_id=user_id, tombstone_data=tombstone_data), + # wait for 10 minutes before starting the cleanup + countdown=10 * 60, + ) + ) def on_restore(user_id, uow=None, **kwargs): @@ -77,8 +67,18 @@ def on_restore(user_id, uow=None, **kwargs): user_id = str(user_id) # restore all the deleted records of that user - for (recid,) in _get_records_for_user(user_id): - restore_record.delay(recid) + for recid in get_user_records(user_id): + uow.register(TaskOp(restore_record, recid=recid)) + + # Send cleanup task to make sure all records are restored + uow.register( + TaskOp.for_async_apply( + user_restore_cleanup, + kwargs=dict(user_id=user_id), + # wait for 10 minutes before starting the cleanup + countdown=10 * 60, + ) + ) def on_approve(user_id, uow=None, **kwargs): diff --git a/invenio_rdm_records/requests/user_moderation/tasks.py b/invenio_rdm_records/requests/user_moderation/tasks.py index a5c4fde7b..20921cdbb 100644 --- a/invenio_rdm_records/requests/user_moderation/tasks.py +++ b/invenio_rdm_records/requests/user_moderation/tasks.py @@ -9,10 +9,50 @@ from celery import shared_task from invenio_access.permissions import system_identity +from invenio_users_resources.records.api import UserAggregate from invenio_rdm_records.proxies import current_rdm_records_service +from invenio_rdm_records.records.systemfields.deletion_status import ( + RecordDeletionStatusEnum, +) from invenio_rdm_records.services.errors import DeletionStatusException +from .utils import get_user_records + + +@shared_task(ignore_result=True) +def user_block_cleanup(user_id, tombstone_data): + """User block action cleanup.""" + user = UserAggregate.get_record(user_id) + # Bail out if the user is not blocked (i.e. we restored him before the task ran) + if not user.blocked: + return + + for recid in get_user_records( + user_id, + from_db=True, + # Only fetch published records that might have not been deleted yet. + status=[RecordDeletionStatusEnum.PUBLISHED], + ): + delete_record.delay(recid, tombstone_data) + + +@shared_task(ignore_result=True) +def user_restore_cleanup(user_id): + """User restore action cleanup.""" + user = UserAggregate.get_record(user_id) + # Bail out if the user is blocked (i.e. we blocked him before the task ran) + if user.blocked: + return + + for recid in get_user_records( + user_id, + from_db=True, + # Only fetch deleted records that might have not been restored yet. + status=[RecordDeletionStatusEnum.DELETED], + ): + restore_record.delay(recid) + @shared_task(ignore_result=True) def delete_record(recid, tombstone_data): diff --git a/invenio_rdm_records/requests/user_moderation/utils.py b/invenio_rdm_records/requests/user_moderation/utils.py new file mode 100644 index 000000000..319eb9ba6 --- /dev/null +++ b/invenio_rdm_records/requests/user_moderation/utils.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# Copyright (C) 2023 TU Wien. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""RDM user moderation utilities.""" + +from invenio_db import db +from invenio_search.api import RecordsSearchV2 + +from ...proxies import current_rdm_records_service + + +def get_user_records(user_id, from_db=False, status=None): + """Helper function for getting all the records of the user.""" + record_cls = current_rdm_records_service.record_cls + model_cls = record_cls.model_cls + parent_cls = record_cls.parent_record_cls + parent_model_cls = parent_cls.model_cls + + if from_db: + query = ( + db.session.query(model_cls.json["id"].as_string()) + .join(parent_model_cls) + .filter( + parent_model_cls.json["access"]["owned_by"]["user"].as_string() + == str(user_id), + ) + ) + if status: + query = query.filter(model_cls.deletion_status.in_(status)) + + return (row[0] for row in query.yield_per(1000)) + else: + search = ( + RecordsSearchV2(index=record_cls.index._name) + .filter("term", **{"parent.access.owned_by.user": user_id}) + .source(["id"]) + ) + if status: + if not isinstance(status, (tuple, list)): + status = [status] + status = [s.value for s in status] + search = search.filter("terms", deletion_status=status) + return (hit["id"] for hit in search.scan()) diff --git a/invenio_rdm_records/resources/config.py b/invenio_rdm_records/resources/config.py index 3ea7673d3..c34623f4e 100644 --- a/invenio_rdm_records/resources/config.py +++ b/invenio_rdm_records/resources/config.py @@ -36,6 +36,7 @@ from ..services.errors import ( AccessRequestExistsError, + CommunityRequiredError, GrantExistsError, InvalidAccessRestrictions, RecordDeletedException, @@ -134,6 +135,84 @@ def _bibliography_headers(obj_or_list, code, many=False): "application/linkset+json": ResponseHandler(FAIRSignpostingProfileLvl2Serializer()), } +error_handlers = { + **ErrorHandlersMixin.error_handlers, + DeserializerError: create_error_handler( + lambda exc: HTTPJSONException( + code=400, + description=exc.args[0], + ) + ), + StyleNotFoundError: create_error_handler( + HTTPJSONException( + code=400, + description=_("Citation string style not found."), + ) + ), + ReviewNotFoundError: create_error_handler( + HTTPJSONException( + code=404, + description=_("Review for draft not found"), + ) + ), + ReviewStateError: create_error_handler( + lambda e: HTTPJSONException( + code=400, + description=str(e), + ) + ), + ReviewExistsError: create_error_handler( + lambda e: HTTPJSONException( + code=400, + description=str(e), + ) + ), + InvalidRelationValue: create_error_handler( + lambda exc: HTTPJSONException( + code=400, + description=exc.args[0], + ) + ), + InvalidAccessRestrictions: create_error_handler( + lambda exc: HTTPJSONException( + code=400, + description=exc.args[0], + ) + ), + ValidationErrorWithMessageAsList: create_error_handler( + lambda e: HTTPJSONValidationWithMessageAsListException(e) + ), + AccessRequestExistsError: create_error_handler( + lambda e: HTTPJSONException( + code=400, + description=e.description, + ) + ), + RecordDeletedException: create_error_handler( + lambda e: ( + HTTPJSONException(code=404, description=_("Record not found")) + if not e.record.tombstone.is_visible + else HTTPJSONException( + code=410, + description=_("Record deleted"), + tombstone=e.record.tombstone.dump(), + ) + ) + ), + RecordSubmissionClosedCommunityError: create_error_handler( + lambda e: HTTPJSONException( + code=403, + description=e.description, + ) + ), + CommunityRequiredError: create_error_handler( + HTTPJSONException( + code=400, + description=_("Cannot publish without selecting a community."), + ) + ), +} + # # Records and record versions @@ -186,76 +265,10 @@ class RDMRecordResourceConfig(RecordResourceConfig, ConfiguratorMixin): default=record_serializers, ) - error_handlers = { - DeserializerError: create_error_handler( - lambda exc: HTTPJSONException( - code=400, - description=exc.args[0], - ) - ), - StyleNotFoundError: create_error_handler( - HTTPJSONException( - code=400, - description=_("Citation string style not found."), - ) - ), - ReviewNotFoundError: create_error_handler( - HTTPJSONException( - code=404, - description=_("Review for draft not found"), - ) - ), - ReviewStateError: create_error_handler( - lambda e: HTTPJSONException( - code=400, - description=str(e), - ) - ), - ReviewExistsError: create_error_handler( - lambda e: HTTPJSONException( - code=400, - description=str(e), - ) - ), - InvalidRelationValue: create_error_handler( - lambda exc: HTTPJSONException( - code=400, - description=exc.args[0], - ) - ), - InvalidAccessRestrictions: create_error_handler( - lambda exc: HTTPJSONException( - code=400, - description=exc.args[0], - ) - ), - ValidationErrorWithMessageAsList: create_error_handler( - lambda e: HTTPJSONValidationWithMessageAsListException(e) - ), - AccessRequestExistsError: create_error_handler( - lambda e: HTTPJSONException( - code=400, - description=e.description, - ) - ), - RecordDeletedException: create_error_handler( - lambda e: ( - HTTPJSONException(code=404, description=_("Record not found")) - if not e.record.tombstone.is_visible - else HTTPJSONException( - code=410, - description=_("Record deleted"), - tombstone=e.record.tombstone.dump(), - ) - ) - ), - RecordSubmissionClosedCommunityError: create_error_handler( - lambda e: HTTPJSONException( - code=403, - description=e.description, - ) - ), - } + error_handlers = FromConfig( + "RDM_RECORDS_ERROR_HANDLERS", + default=error_handlers, + ) # diff --git a/invenio_rdm_records/resources/serializers/csl/schema.py b/invenio_rdm_records/resources/serializers/csl/schema.py index a0cce3056..e6d0f24ca 100644 --- a/invenio_rdm_records/resources/serializers/csl/schema.py +++ b/invenio_rdm_records/resources/serializers/csl/schema.py @@ -13,6 +13,7 @@ from flask_resources.serializers import BaseSerializerSchema from marshmallow import Schema, fields, missing, pre_dump from marshmallow_utils.fields import SanitizedUnicode, StrippedHTML +from pydash import py_ from ..schemas import CommonFieldsMixin from ..utils import get_vocabulary_props @@ -62,19 +63,27 @@ class CSLJSONSchema(BaseSerializerSchema, CommonFieldsMixin): def get_type(self, obj): """Get resource type.""" + resource_type_id = py_.get(obj, "metadata.resource_type.id") + if not resource_type_id: + return missing + props = get_vocabulary_props( "resourcetypes", [ "props.csl", ], - obj["metadata"]["resource_type"]["id"], + resource_type_id, ) return props.get("csl", "article") # article is CSL "Other" def get_issued(self, obj): """Get issued dates.""" + publication_date = py_.get(obj, "metadata.publication_date") + if not publication_date: + return missing + try: - parsed = parse_edtf(obj["metadata"].get("publication_date")) + parsed = parse_edtf(publication_date) except EDTFParseException: return missing diff --git a/invenio_rdm_records/resources/serializers/datacite/schema.py b/invenio_rdm_records/resources/serializers/datacite/schema.py index 17ed58d33..82d685e41 100644 --- a/invenio_rdm_records/resources/serializers/datacite/schema.py +++ b/invenio_rdm_records/resources/serializers/datacite/schema.py @@ -20,6 +20,7 @@ from marshmallow import Schema, ValidationError, fields, missing, post_dump, validate from marshmallow_utils.fields import SanitizedUnicode from marshmallow_utils.html import strip_html +from pydash import py_ from ....proxies import current_rdm_records_service from ...serializers.ui.schema import current_default_locale @@ -206,10 +207,14 @@ class DataCite43Schema(BaseSerializerSchema): def get_type(self, obj): """Get resource type.""" + resource_type_id = py_.get(obj, "metadata.resource_type.id") + if not resource_type_id: + return missing + props = get_vocabulary_props( "resourcetypes", ["props.datacite_general", "props.datacite_type"], - obj["metadata"]["resource_type"]["id"], + resource_type_id, ) return { "resourceTypeGeneral": props.get("datacite_general", "Other"), @@ -261,8 +266,11 @@ def get_descriptions(self, obj): def get_publication_year(self, obj): """Get publication year from edtf date.""" + publication_date = py_.get(obj, "metadata.publication_date") + if not publication_date: + return missing + try: - publication_date = obj["metadata"]["publication_date"] parsed_date = parse_edtf(publication_date) return str(parsed_date.lower_strict().tm_year) except ParseException: @@ -274,7 +282,8 @@ def get_publication_year(self, obj): def get_dates(self, obj): """Get dates.""" - dates = [{"date": obj["metadata"]["publication_date"], "dateType": "Issued"}] + pub_date = py_.get(obj, "metadata.publication_date") + dates = [{"date": pub_date, "dateType": "Issued"}] if pub_date else [] updated = False @@ -428,7 +437,7 @@ def get_related_identifiers(self, obj): if hasattr(obj, "parent"): parent_record = obj.parent else: - parent_record = obj["parent"] + parent_record = obj.get("parent", {}) parent_doi = parent_record.get("pids", {}).get("doi") if parent_doi: diff --git a/invenio_rdm_records/resources/serializers/dublincore/schema.py b/invenio_rdm_records/resources/serializers/dublincore/schema.py index 48cbc2745..541b762a7 100644 --- a/invenio_rdm_records/resources/serializers/dublincore/schema.py +++ b/invenio_rdm_records/resources/serializers/dublincore/schema.py @@ -12,6 +12,7 @@ from flask import current_app from flask_resources.serializers import BaseSerializerSchema from marshmallow import fields, missing +from pydash import py_ from ..schemas import CommonFieldsMixin from ..ui.schema import current_default_locale @@ -91,22 +92,22 @@ def get_relations(self, obj): # FIXME: Add after UI support is there # Alternate identifiers - for a in obj["metadata"].get("alternate_identifiers", []): + for a in obj.get("metadata", {}).get("alternate_identifiers", []): rels.append(self._transform_identifier(a["identifier"], a["scheme"])) # Related identifiers - for a in obj["metadata"].get("related_identifiers", []): + for a in obj.get("metadata", {}).get("related_identifiers", []): rels.append(self._transform_identifier(a["identifier"], a["scheme"])) # Communities - communities = obj["parent"].get("communities", {}).get("entries", []) + communities = obj.get("parent", {}).get("communities", {}).get("entries", []) for community in communities: slug = community["slug"] url = f"{current_app.config['SITE_UI_URL']}/communities/{slug}" rels.append(self._transform_identifier(url, "url")) # Parent doi - parent_pids = obj["parent"].get("pids", {}) + parent_pids = obj.get("parent", {}).get("pids", {}) for key, value in parent_pids.items(): if key == "doi": rels.append(self._transform_identifier(value["identifier"], key)) @@ -117,13 +118,14 @@ def get_rights(self, obj): """Get rights.""" rights = [] - access_right = obj["access"]["status"] - if access_right == "metadata-only": - access_right = "closed" + access_right = py_.get(obj, "access.status") + if access_right: + if access_right == "metadata-only": + access_right = "closed" - rights.append(f"info:eu-repo/semantics/{access_right}Access") + rights.append(f"info:eu-repo/semantics/{access_right}Access") - for right in obj["metadata"].get("rights", []): + for right in obj.get("metadata", {}).get("rights", []): rights.append(right.get("title").get(current_default_locale())) if right.get("id"): license_url = right.get("props", {}).get("url") @@ -138,9 +140,14 @@ def get_rights(self, obj): def get_dates(self, obj): """Get dates.""" - dates = [obj["metadata"]["publication_date"]] + dates = [] - if obj["access"]["status"] == "embargoed": + publication_date = py_.get(obj, "metadata.publication_date") + if publication_date: + dates.append(publication_date) + + access_right = py_.get(obj, "access.status") + if access_right == "embargoed": date = obj["access"]["embargo"]["until"] dates.append(f"info:eu-repo/date/embargoEnd/{date}") @@ -181,12 +188,16 @@ def get_subjects(self, obj): def get_types(self, obj): """Get resource type.""" + resource_type_id = py_.get(obj, "metadata.resource_type.id") + if not resource_type_id: + return missing + props = get_vocabulary_props( "resourcetypes", [ "props.eurepo", ], - obj["metadata"]["resource_type"]["id"], + resource_type_id, ) t = props.get("eurepo") return [t] if t else missing diff --git a/invenio_rdm_records/resources/serializers/marcxml/schema.py b/invenio_rdm_records/resources/serializers/marcxml/schema.py index f2e70cebe..4af87ea17 100644 --- a/invenio_rdm_records/resources/serializers/marcxml/schema.py +++ b/invenio_rdm_records/resources/serializers/marcxml/schema.py @@ -14,6 +14,7 @@ from flask_resources.serializers import BaseSerializerSchema from marshmallow import fields, missing from marshmallow_utils.html import sanitize_unicode +from pydash import py_ from ..schemas import CommonFieldsMixin from ..ui.schema import current_default_locale @@ -491,30 +492,33 @@ def get_types_and_communities(self, obj): if communities: slugs = [community.get("slug") for community in communities] output += [{"a": f"user-{slug}"} for slug in slugs] - props = get_vocabulary_props( - "resourcetypes", - [ - "props.eurepo", - "props.marc21_type", - "props.marc21_subtype", - ], - obj["metadata"]["resource_type"]["id"], - ) - props_eurepo = props.get("eurepo") - if props_eurepo: - eurepo = {"a": props_eurepo} - output.append(eurepo) - - resource_types = {} - - resource_type = props.get("marc21_type") - if resource_type: - resource_types["a"] = resource_type - resource_subtype = props.get("marc21_subtype") - if resource_subtype: - resource_types["b"] = resource_subtype - - if resource_types: - output.append(resource_types) + + resource_type_id = py_.get(obj, "metadata.resource_type.id") + if resource_type_id: + props = get_vocabulary_props( + "resourcetypes", + [ + "props.eurepo", + "props.marc21_type", + "props.marc21_subtype", + ], + resource_type_id, + ) + props_eurepo = props.get("eurepo") + if props_eurepo: + eurepo = {"a": props_eurepo} + output.append(eurepo) + + resource_types = {} + + resource_type = props.get("marc21_type") + if resource_type: + resource_types["a"] = resource_type + resource_subtype = props.get("marc21_subtype") + if resource_subtype: + resource_types["b"] = resource_subtype + + if resource_types: + output.append(resource_types) return output or missing diff --git a/invenio_rdm_records/resources/serializers/schemaorg/schema.py b/invenio_rdm_records/resources/serializers/schemaorg/schema.py index 15d25f1f5..af1cdcad0 100644 --- a/invenio_rdm_records/resources/serializers/schemaorg/schema.py +++ b/invenio_rdm_records/resources/serializers/schemaorg/schema.py @@ -209,10 +209,14 @@ def get_id(self, obj): def get_type(self, obj): """Get type. Use the vocabulary service to get the schema.org type.""" + resource_type_id = py_.get(obj, "metadata.resource_type.id") + if not resource_type_id: + return missing + props = get_vocabulary_props( "resourcetypes", ["props.schema.org"], - py_.get(obj, "metadata.resource_type.id"), + resource_type_id, ) ret = props.get("schema.org", "https://schema.org/CreativeWork") return ret @@ -232,8 +236,12 @@ def get_format(self, obj): def get_publication_date(self, obj): """Get publication date.""" + publication_date = py_.get(obj, "metadata.publication_date") + if not publication_date: + return missing + try: - parsed_date = parse_edtf(py_.get(obj, "metadata.publication_date")) + parsed_date = parse_edtf(publication_date) except ParseException: return missing diff --git a/invenio_rdm_records/resources/serializers/schemas.py b/invenio_rdm_records/resources/serializers/schemas.py index 6d6b661b4..0a7034d56 100644 --- a/invenio_rdm_records/resources/serializers/schemas.py +++ b/invenio_rdm_records/resources/serializers/schemas.py @@ -8,6 +8,7 @@ """Base parsing functions for the various serializers.""" from marshmallow import missing +from pydash import py_ class CommonFieldsMixin: @@ -55,7 +56,8 @@ def get_locations(self, obj): def get_titles(self, obj): """Get titles.""" - return [obj["metadata"]["title"]] + title = py_.get(obj, "metadata.title") + return [title] if title else missing def get_identifiers(self, obj): """Get identifiers.""" @@ -67,7 +69,9 @@ def get_identifiers(self, obj): def get_creators(self, obj): """Get creators.""" - return [c["person_or_org"]["name"] for c in obj["metadata"].get("creators", [])] + return [ + c["person_or_org"]["name"] for c in obj["metadata"].get("creators", []) + ] or missing def get_publishers(self, obj): """Get publishers.""" diff --git a/invenio_rdm_records/resources/serializers/ui/schema.py b/invenio_rdm_records/resources/serializers/ui/schema.py index 8f6fa6557..05395550a 100644 --- a/invenio_rdm_records/resources/serializers/ui/schema.py +++ b/invenio_rdm_records/resources/serializers/ui/schema.py @@ -14,6 +14,7 @@ from functools import partial from babel_edtf import parse_edtf +from edtf.parser.grammar import ParseException from flask import current_app, g from flask_resources import BaseObjectSchema from invenio_communities.communities.resources.ui_schema import ( @@ -30,6 +31,7 @@ from marshmallow_utils.fields import FormatEDTF as FormatEDTF_ from marshmallow_utils.fields import SanitizedHTML, SanitizedUnicode, StrippedHTML from marshmallow_utils.fields.babel import gettext_from_dict +from pyparsing import ParseException from .fields import AccessStatusField @@ -218,12 +220,18 @@ def _format_journal(journal, publication_date): journal_issue = journal.get("issue") journal_volume = journal.get("volume") journal_pages = journal.get("pages") - publication_date_edtf = ( - parse_edtf(publication_date).lower_strict() if publication_date else None - ) - publication_date_formatted = ( - f"{publication_date_edtf.tm_year}" if publication_date_edtf else None - ) + + try: + publication_date_edtf = ( + parse_edtf(publication_date).lower_strict() + if publication_date + else None + ) + publication_date_formatted = ( + f"{publication_date_edtf.tm_year}" if publication_date_edtf else None + ) + except ParseException: + publication_date_formatted = None title = f"{journal_title}" if journal_title else None vol_issue = f"{journal_volume}" if journal_volume else None diff --git a/invenio_rdm_records/services/communities/components.py b/invenio_rdm_records/services/communities/components.py index d1e450ffc..8c2f4c5a9 100644 --- a/invenio_rdm_records/services/communities/components.py +++ b/invenio_rdm_records/services/communities/components.py @@ -7,8 +7,6 @@ """Record communities service components.""" -from flask import current_app -from invenio_access.permissions import system_identity from invenio_communities.communities.records.systemfields.access import VisibilityEnum from invenio_communities.communities.services.components import ChildrenComponent from invenio_communities.communities.services.components import ( @@ -24,17 +22,16 @@ OwnershipComponent, PIDComponent, ) -from invenio_drafts_resources.services.records.components import ServiceComponent from invenio_i18n import lazy_gettext as _ from invenio_records_resources.services.records.components import ( MetadataComponent, RelationsComponent, ) -from invenio_requests.tasks import request_moderation from invenio_search.engine import dsl from ...proxies import current_community_records_service from ..errors import InvalidCommunityVisibility +from .moderation import ContentModerationComponent class CommunityAccessComponent(BaseAccessComponent): @@ -65,25 +62,6 @@ def update(self, identity, data=None, record=None, **kwargs): self._check_visibility(identity, record) -class ContentModerationComponent(ServiceComponent): - """Service component for content moderation.""" - - def create(self, identity, data=None, record=None, **kwargs): - """Create a moderation request if the user is not verified.""" - if current_app.config["RDM_USER_MODERATION_ENABLED"]: - # If the publisher is the system process, we don't want to create a moderation request. - # Even if the record being published is owned by a user that is not system - if identity == system_identity: - return - - # resolve current user and check if they are verified - is_verified = identity.user.verified_at is not None - - if not is_verified: - # Spawn a task to request moderation. - request_moderation.delay(identity.id) - - CommunityServiceComponents = [ MetadataComponent, CommunityThemeComponent, @@ -94,8 +72,8 @@ def create(self, identity, data=None, record=None, **kwargs): OwnershipComponent, FeaturedCommunityComponent, OAISetComponent, - ContentModerationComponent, CommunityDeletionComponent, ChildrenComponent, CommunityParentComponent, + ContentModerationComponent, ] diff --git a/invenio_rdm_records/services/communities/moderation.py b/invenio_rdm_records/services/communities/moderation.py new file mode 100644 index 000000000..52e7e4342 --- /dev/null +++ b/invenio_rdm_records/services/communities/moderation.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Content moderation for communities.""" + +from flask import current_app +from invenio_access.permissions import system_identity +from invenio_records_resources.services.records.components import ServiceComponent +from invenio_records_resources.services.uow import TaskOp +from invenio_requests.tasks import request_moderation + + +class BaseHandler: + """Base class for content moderation handlers.""" + + def create(self, identity, record=None, data=None, uow=None, **kwargs): + """Create handler.""" + pass + + def update(self, identity, record=None, data=None, uow=None, **kwargs): + """Update handler.""" + pass + + def delete(self, identity, data=None, record=None, uow=None, **kwargs): + """Delete handler.""" + pass + + +class UserModerationHandler(BaseHandler): + """Creates user moderation request if the user publishing is not verified.""" + + @property + def enabled(self): + """Check if user moderation is enabled.""" + return current_app.config["RDM_USER_MODERATION_ENABLED"] + + def run(self, identity, record=None, uow=None): + """Calculate the moderation score for a given record or draft.""" + if self.enabled: + # If the publisher is the system process, we don't want to create a moderation request. + # Even if the record being published is owned by a user that is not system + if identity == system_identity: + return + + # resolve current user and check if they are verified + is_verified = identity.user.verified_at is not None + if not is_verified: + # Spawn a task to request moderation. + self.uow.register(TaskOp(request_moderation, user_id=identity.id)) + + def create(self, identity, record=None, data=None, uow=None, **kwargs): + """Handle create.""" + self.run(identity, record=record, uow=uow) + + def update(self, identity, record=None, data=None, uow=None, **kwargs): + """Handle update.""" + self.run(identity, record=record, uow=uow) + + +class ContentModerationComponent(ServiceComponent): + """Service component for content moderation.""" + + def handler_for(action): + """Get the handlers for an action.""" + + def _handler_method(self, *args, **kwargs): + handlers = current_app.config.get( + "RDM_COMMUNITY_CONTENT_MODERATION_HANDLERS", [] + ) + for handler in handlers: + action_method = getattr(handler, action, None) + if action_method: + action_method(*args, **kwargs, uow=self.uow) + + return _handler_method + + create = handler_for("create") + update = handler_for("update") + delete = handler_for("delete") + + del handler_for diff --git a/invenio_rdm_records/services/communities/service.py b/invenio_rdm_records/services/communities/service.py index d8d4d7747..613d168c8 100644 --- a/invenio_rdm_records/services/communities/service.py +++ b/invenio_rdm_records/services/communities/service.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2023-2024 CERN. -# Copyright (C) 2024 Graz University of Technology. +# Copyright (C) 2024 Graz University of Technology. +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-RDM-Records 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,13 +12,10 @@ from flask import current_app from invenio_access.permissions import system_identity from invenio_communities.proxies import current_communities -from invenio_drafts_resources.services.records.uow import ( - ParentRecordCommitOp, - RecordCommitOp, -) +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_pidstore.errors import PIDDoesNotExistError +from invenio_pidstore.errors import PIDDoesNotExistError, PIDUnregistered from invenio_records_resources.services import ( RecordIndexerMixin, Service, @@ -38,6 +36,7 @@ from ...proxies import current_rdm_records, current_rdm_records_service from ...requests import CommunityInclusion from ..errors import ( + CannotRemoveCommunityError, CommunityAlreadyExists, InvalidAccessRestrictions, OpenRequestAlreadyExists, @@ -67,6 +66,11 @@ def record_cls(self): """Factory for creating a record class.""" return self.config.record_cls + @property + def draft_cls(self): + """Factory for creating a draft class.""" + return self.config.draft_cls + def _exists(self, community_id, record): """Return the request id if an open request already exists, else None.""" results = current_requests_service.search( @@ -205,10 +209,20 @@ def _remove(self, identity, community_id, record): if community_id not in record.parent.communities.ids: raise RecordCommunityMissing(record.id, community_id) - # check permission here, per community: curator cannot remove another community - self.require_permission( - identity, "remove_community", record=record, community_id=community_id - ) + try: + self.require_permission( + identity, "remove_community", record=record, community_id=community_id + ) + # By default, admin/superuser has permission to do everything, so PermissionDeniedError won't be raised for admin in any case + except PermissionDeniedError as exc: + # If permission is denied, determine which error to raise, based on config + community_required = current_app.config["RDM_COMMUNITY_REQUIRED_TO_PUBLISH"] + is_last_community = len(record.parent.communities.ids) <= 1 + if community_required and is_last_community: + raise CannotRemoveCommunityError() + else: + # If the config wasn't enabled, then raise the PermissionDeniedError + raise exc # Default community is deleted when the exact same community is removed from the record record.parent.communities.remove(community_id) @@ -233,7 +247,11 @@ def remove(self, identity, id_, data, uow): try: self._remove(identity, community_id, record) processed.append({"community": community_id}) - except (RecordCommunityMissing, PermissionDeniedError) as ex: + except ( + RecordCommunityMissing, + PermissionDeniedError, + CannotRemoveCommunityError, + ) as ex: errors.append( { "community": community_id, @@ -264,7 +282,10 @@ def search( **kwargs, ): """Search for record's communities.""" - record = self.record_cls.pid.resolve(id_) + try: + record = self.record_cls.pid.resolve(id_) + except PIDUnregistered: + record = self.draft_cls.pid.resolve(id_, registered_only=False) self.require_permission(identity, "read", record=record) communities_ids = record.parent.communities.ids diff --git a/invenio_rdm_records/services/community_records/service.py b/invenio_rdm_records/services/community_records/service.py index 8ed6c4c85..7209f676b 100644 --- a/invenio_rdm_records/services/community_records/service.py +++ b/invenio_rdm_records/services/community_records/service.py @@ -18,7 +18,7 @@ from invenio_records_resources.services.uow import unit_of_work from invenio_search.engine import dsl -from ...proxies import current_record_communities_service +from ...proxies import current_rdm_records, current_record_communities_service from ...records.systemfields.deletion_status import RecordDeletionStatusEnum diff --git a/invenio_rdm_records/services/components/verified.py b/invenio_rdm_records/services/components/verified.py index 34e848875..7b700f992 100644 --- a/invenio_rdm_records/services/components/verified.py +++ b/invenio_rdm_records/services/components/verified.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2023 CERN. +# Copyright (C) 2023-2024 CERN. # # Invenio-RDM-records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -9,22 +9,91 @@ from flask import current_app from invenio_access.permissions import system_identity from invenio_drafts_resources.services.records.components import ServiceComponent +from invenio_records_resources.services.uow import TaskOp from invenio_requests.tasks import request_moderation -class ContentModerationComponent(ServiceComponent): - """Service component for content moderation.""" +class BaseHandler: + """Base class for content moderation handlers.""" + + def update_draft( + self, identity, data=None, record=None, errors=None, uow=None, **kwargs + ): + """Update draft handler.""" + pass + + def delete_draft( + self, identity, draft=None, record=None, force=False, uow=None, **kwargs + ): + """Delete draft handler.""" + pass + + def edit(self, identity, draft=None, record=None, uow=None, **kwargs): + """Edit a record handler.""" + pass + + def new_version(self, identity, draft=None, record=None, uow=None, **kwargs): + """New version handler.""" + pass + + def publish(self, identity, draft=None, record=None, uow=None, **kwargs): + """Publish handler.""" + pass + + def post_publish( + self, identity, record=None, is_published=False, uow=None, **kwargs + ): + """Post publish handler.""" + pass + + +class UserModerationHandler(BaseHandler): + """Creates user moderation request if the user publishing is not verified.""" + + @property + def enabled(self): + """Check if user moderation is enabled.""" + return current_app.config["RDM_USER_MODERATION_ENABLED"] - def publish(self, identity, draft=None, record=None): - """Create a moderation request if the user is not verified.""" - if current_app.config["RDM_USER_MODERATION_ENABLED"]: + def run(self, identity, record=None, uow=None): + """Calculate the moderation score for a given record or draft.""" + if self.enabled: # If the publisher is the system process, we don't want to create a moderation request. # Even if the record being published is owned by a user that is not system if identity == system_identity: return is_verified = record.parent.is_verified - if not is_verified: # Spawn a task to request moderation. - request_moderation.delay(record.parent.access.owner.owner_id) + user_id = record.parent.access.owner.owner_id + uow.register(TaskOp(request_moderation, user_id=user_id)) + + def publish(self, identity, draft=None, record=None, uow=None, **kwargs): + """Handle publish.""" + self.run(identity, record=record, uow=uow) + + +class ContentModerationComponent(ServiceComponent): + """Service component for content moderation.""" + + def handler_for(action): + """Get the handlers for an action.""" + + def _handler_method(self, *args, **kwargs): + handlers = current_app.config.get("RDM_CONTENT_MODERATION_HANDLERS", []) + for handler in handlers: + action_method = getattr(handler, action, None) + if action_method: + action_method(*args, **kwargs, uow=self.uow) + + return _handler_method + + update_draft = handler_for("update_draft") + delete_draft = handler_for("delete_draft") + edit = handler_for("edit") + publish = handler_for("publish") + post_publish = handler_for("post_publish") + new_version = handler_for("new_version") + + del handler_for diff --git a/invenio_rdm_records/services/config.py b/invenio_rdm_records/services/config.py index c387b483d..b95a54d52 100644 --- a/invenio_rdm_records/services/config.py +++ b/invenio_rdm_records/services/config.py @@ -4,7 +4,8 @@ # Copyright (C) 2020-2021 Northwestern University. # Copyright (C) 2021 TU Wien. # Copyright (C) 2021-2023 Graz University of Technology. -# Copyright (C) 2022 Universität Hamburg +# Copyright (C) 2022 Universität Hamburg +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -260,6 +261,7 @@ class RDMRecordCommunitiesConfig(ServiceConfig, ConfiguratorMixin): service_id = "record-communities" record_cls = FromConfig("RDM_RECORD_CLS", default=RDMRecord) + draft_cls = FromConfig("RDM_DRAFT_CLS", default=RDMDraft) permission_policy_cls = FromConfig( "RDM_PERMISSION_POLICY", default=RDMRecordPermissionPolicy, import_string=True ) @@ -339,6 +341,10 @@ class RDMFileRecordServiceConfig(FileServiceConfig, ConfiguratorMixin): file_schema = FileSchema + components = FromConfig( + "RDM_FILES_SERVICE_COMPONENTS", default=FileServiceConfig.components + ) + class ThumbnailLinks(RecordLink): """RDM thumbnail links dictionary.""" @@ -379,7 +385,7 @@ def expand(self, obj, context): when=is_record_or_draft_and_has_parent_doi, ), else_=RecordPIDLink( - "https://doi.org/{+pid_doi}", when=is_record_or_draft_and_has_parent_doi + "https://doi.org/{+parent_pid_doi}", when=is_record_or_draft_and_has_parent_doi ), ) parent_doi_html_link = RecordPIDLink( @@ -749,6 +755,10 @@ class RDMFileDraftServiceConfig(FileServiceConfig, ConfiguratorMixin): file_schema = FileSchema + components = FromConfig( + "RDM_DRAFT_FILES_SERVICE_COMPONENTS", default=FileServiceConfig.components + ) + class RDMMediaFileDraftServiceConfig(FileServiceConfig, ConfiguratorMixin): """Configuration for draft media files.""" diff --git a/invenio_rdm_records/services/errors.py b/invenio_rdm_records/services/errors.py index 024ab39ee..916cd25a0 100644 --- a/invenio_rdm_records/services/errors.py +++ b/invenio_rdm_records/services/errors.py @@ -205,4 +205,18 @@ def description(self): class RecordSubmissionClosedCommunityError(PermissionDenied): """Record submission policy forbids non-members from submitting records to community.""" - description = "Submission to this community is only allowed to community members." + description = _( + "Submission to this community is only allowed to community members." + ) + + +class CommunityRequiredError(Exception): + """Error thrown when a record is being created/updated with less than 1 community.""" + + description = _("Cannot publish without a community.") + + +class CannotRemoveCommunityError(Exception): + """Error thrown when the last community is being removed from the record.""" + + description = _("A record should be part of at least 1 community.") diff --git a/invenio_rdm_records/services/generators.py b/invenio_rdm_records/services/generators.py index 270476b27..154fddc09 100644 --- a/invenio_rdm_records/services/generators.py +++ b/invenio_rdm_records/services/generators.py @@ -414,3 +414,19 @@ def needs(self, request=None, **kwargs): return [AccessRequestTokenNeed(request["payload"]["token"])] return [] + + +class IfOneCommunity(ConditionalGenerator): + """Conditional generator for records always in communities case.""" + + def _condition(self, record=None, **kwargs): + """Check if the record is associated with one community.""" + return bool(record and len(record.parent.communities.ids) == 1) + + +class IfAtLeastOneCommunity(ConditionalGenerator): + """Conditional generator for records always in communities case.""" + + def _condition(self, record=None, **kwargs): + """Check if the record is associated with at least one community.""" + return bool(record and record.parent.communities.ids) diff --git a/invenio_rdm_records/services/permissions.py b/invenio_rdm_records/services/permissions.py index 52b7f6417..5cc3243cb 100644 --- a/invenio_rdm_records/services/permissions.py +++ b/invenio_rdm_records/services/permissions.py @@ -30,11 +30,13 @@ AccessGrant, CommunityInclusionReviewers, GuestAccessRequestToken, + IfAtLeastOneCommunity, IfCreate, IfDeleted, IfExternalDOIRecord, IfFileIsLocal, IfNewRecord, + IfOneCommunity, IfRecordDeleted, IfRequestType, IfRestricted, @@ -199,7 +201,18 @@ class RDMRecordPermissionPolicy(RecordPermissionPolicy): ), ] # Allow publishing a new record or changes to an existing record. - can_publish = can_review + can_publish = [ + IfConfig( + "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", + then_=[ + IfAtLeastOneCommunity( + then_=can_review, + else_=[Administration(), SystemProcess()], + ), + ], + else_=can_review, + ) + ] # Allow lifting a record or draft. can_lift_embargo = can_manage @@ -209,13 +222,25 @@ class RDMRecordPermissionPolicy(RecordPermissionPolicy): # Who can add record to a community can_add_community = can_manage # Who can remove a community from a record - can_remove_community = [ + can_remove_community_ = [ RecordOwners(), CommunityCurators(), SystemProcess(), ] + can_remove_community = [ + IfConfig( + "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", + then_=[ + IfOneCommunity( + then_=[Administration(), SystemProcess()], + else_=can_remove_community_, + ), + ], + else_=can_remove_community_, + ), + ] # Who can remove records from a community - can_remove_record = [CommunityCurators()] + can_remove_record = [CommunityCurators(), Administration(), SystemProcess()] # Who can add records to a community in bulk can_bulk_add = [SystemProcess()] diff --git a/invenio_rdm_records/services/services.py b/invenio_rdm_records/services/services.py index 3841fdb29..e99d0761a 100644 --- a/invenio_rdm_records/services/services.py +++ b/invenio_rdm_records/services/services.py @@ -20,6 +20,7 @@ from invenio_drafts_resources.services.records import RecordService from invenio_drafts_resources.services.records.uow import ParentRecordCommitOp from invenio_records_resources.services import LinksTemplate, ServiceSchemaWrapper +from invenio_records_resources.services.errors import PermissionDeniedError from invenio_records_resources.services.uow import ( RecordCommitOp, RecordIndexDeleteOp, @@ -37,6 +38,7 @@ from ..records.systemfields.deletion_status import RecordDeletionStatusEnum from .errors import ( + CommunityRequiredError, DeletionStatusException, EmbargoNotLiftedError, RecordDeletedException, @@ -404,6 +406,28 @@ def purge_record(self, identity, id_, uow=None): raise NotImplementedError() + @unit_of_work() + def publish(self, identity, id_, uow=None, expand=False): + """Publish a draft. + + Check for permissions to publish a draft and then call invenio_drafts_resourcs.services.records.services.publish() + """ + try: + draft = self.draft_cls.pid.resolve(id_, registered_only=False) + self.require_permission(identity, "publish", record=draft) + # By default, admin/superuser has permission to do everything, so PermissionDeniedError won't be raised for admin in any case + except PermissionDeniedError as exc: + # If user doesn't have permission to publish, determine which error to raise, based on config + community_required = current_app.config["RDM_COMMUNITY_REQUIRED_TO_PUBLISH"] + is_community_missing = len(draft.parent.communities.ids) < 1 + if community_required and is_community_missing: + raise CommunityRequiredError() + else: + # If the config wasn't enabled, then raise the PermissionDeniedError + raise exc + + return super().publish(identity, id_, uow=uow, expand=expand) + # # Search functions # diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/grant-user-access.create.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/grant-user-access.create.jinja index 50974e9cd..b5fe7db36 100644 --- a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/grant-user-access.create.jinja +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/grant-user-access.create.jinja @@ -4,7 +4,12 @@ {% set permission = notification.context.permission %} {% set record_title = record.metadata.title %} -{% set record_link = record.links.self_html %} +{# Determine shared link #} +{%- if not record.is_published and permission == "preview" -%} + {% set shared_link = record.links.record_html + "?preview=1" %} +{%- else -%} + {% set shared_link = record.links.self_html %} +{%- endif -%} {% set account_settings_link = "{ui}/account/settings/notifications".format( ui=config.SITE_UI_URL ) @@ -49,11 +54,11 @@ {% endif %} - {%- if record.is_published -%} - {{ _("View the record")}} - {%- else -%} - {{ _("View the draft")}} - {%- endif -%} + + + {{ _("View the record") if record.is_published else _("View the draft") }} + + _ @@ -64,29 +69,29 @@ {%- endblock html_body -%} +{# + Because of whitespace interpretation for plain text we have to: indent, format and strip jinja blocks (-) + just so to get the right output. This is unfortunate for code readability but required for output. +#} {%- block plain_body -%} - {%- if record.is_published -%} - {{ _("You have now permission to {permission} all versions of the record '{record_title}'.").format(record_title=record_title, permission=permission)}} +{%- if record.is_published -%} + {{ _("You have now permission to {permission} all versions of the record '{record_title}'.").format(record_title=record_title, permission=permission)}} +{%- else -%} + {%- if record_title -%} + {{ _("You have now permission to {permission} the draft '{record_title}'.").format(record_title=record_title, permission=permission)}} {%- else -%} - {%- if record_title -%} - {{ _("You have now permission to {permission} the draft '{record_title}'.").format(record_title=record_title, permission=permission)}} - {%- else -%} - {{ _("You have now permission to {permission} the draft.").format(permission=permission)}} - {%- endif -%} + {{ _("You have now permission to {permission} the draft.").format(permission=permission)}} {%- endif -%} +{%- endif -%} {% if message %} -
-
-{{ _("Message:")}} -{{message}} -{% endif %} -{%- if record.is_published -%} - {{ _("View the record: ") }}{{ record_link }} -{%- else -%} - {{ _("View the draft: ") }}{{ record_link }} -{%- endif -%} +{{ _("Message:") }} + +{{ message }} +{%- endif %} + +{{ _("View the record: ") if record.is_published else _("View the draft: ") }}{{ shared_link }} -{{ _("This is an auto-generated message. To manage notifications, visit your account settings: ")}}{{ account_settings_link }} +{{ _("This is an auto-generated message. To manage notifications, visit your account settings: ")}}{{ account_settings_link }} . {%- endblock plain_body -%} diff --git a/invenio_rdm_records/views.py b/invenio_rdm_records/views.py index 800d7a121..0956c770a 100644 --- a/invenio_rdm_records/views.py +++ b/invenio_rdm_records/views.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020-2022 CERN. +# Copyright (C) 2020-2024 CERN. # Copyright (C) 2021 TU Wien. # Copyright (C) 2022 Universität Hamburg. # Copyright (C) 2024 Graz University of Technology. @@ -102,3 +102,9 @@ def create_iiif_bp(app): """Create IIIF blueprint.""" ext = app.extensions["invenio-rdm-records"] return ext.iiif_resource.as_blueprint() + + +def create_collections_bp(app): + """Create collections blueprint.""" + ext = app.extensions["invenio-rdm-records"] + return ext.collections_resource.as_blueprint() diff --git a/invenio_rdm_records/webpack.py b/invenio_rdm_records/webpack.py index 31e9f2cc7..4d06b72e7 100644 --- a/invenio_rdm_records/webpack.py +++ b/invenio_rdm_records/webpack.py @@ -37,11 +37,11 @@ "react-dropzone": "^11.0.0", "react-i18next": "^11.11.0", "react-invenio-forms": "^4.0.0", - "react-searchkit": "^2.0.0", + "react-searchkit": "^3.0.0", "tinymce": "^6.7.2", "yup": "^0.32.0", "@semantic-ui-react/css-patch": "^1.0.0", - "axios": "^0.21.0", + "axios": "^1.7.7", "react": "^16.13.0", "react-dom": "^16.13.0", "react-redux": "^7.2.0", diff --git a/setup.cfg b/setup.cfg index e53590945..2e1f9052c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,19 +36,19 @@ install_requires = datacite>=1.1.1,<2.0.0 dcxml>=0.1.2,<1.0.0 Faker>=2.0.3 - flask-iiif>=0.6.2,<1.0.0 + flask-iiif>=1.0.0,<2.0.0 ftfy>=4.4.3,<5.0.0 invenio-administration>=2.0.0,<3.0.0 - invenio-communities>=15.0.0,<16.0.0 + invenio-communities>=17.0.0,<18.0.0 invenio-drafts-resources>=5.0.0,<6.0.0 invenio-records-resources>=6.0.0,<7.0.0 invenio-github>=1.0.0,<2.0.0 invenio-i18n>=2.0.0,<3.0.0 - invenio-jobs>=0.1.0,<1.0.0 + invenio-jobs>=1.0.0,<2.0.0 invenio-oaiserver>=2.0.0,<3.0.0 invenio-oauth2server>=2.0.0 invenio-stats>=4.0.0,<5.0.0 - invenio-vocabularies>=5.0.0,<6.0.0 + invenio-vocabularies>=6.0.0,<7.0.0 nameparser>=1.1.1 pycountry>=22.3.5 pydash>=6.0.0,<7.0.0 @@ -97,6 +97,7 @@ invenio_base.api_blueprints = invenio_rdm_record_communities = invenio_rdm_records.views:create_record_communities_bp invenio_rdm_record_requests = invenio_rdm_records.views:create_record_requests_bp invenio_iiif = invenio_rdm_records.views:create_iiif_bp + invenio_rdm_records_collections = invenio_rdm_records.views:create_collections_bp invenio_base.api_finalize_app = invenio_rdm_records = invenio_rdm_records.ext:api_finalize_app invenio_base.blueprints = @@ -109,8 +110,10 @@ invenio_celery.tasks = invenio_rdm_records_access_requests = invenio_rdm_records.requests.access.tasks invenio_rdm_records_iiif = invenio_rdm_records.services.iiif.tasks invenio_rdm_records_user_moderation = invenio_rdm_records.requests.user_moderation.tasks + invenio_rdm_records_collections = invenio_rdm_records.collections.tasks invenio_db.models = invenio_rdm_records = invenio_rdm_records.records.models + invenio_rdm_records_collections = invenio_rdm_records.collections.models invenio_db.alembic = invenio_rdm_records = invenio_rdm_records:alembic invenio_jsonschemas.schemas = @@ -140,6 +143,8 @@ invenio_users_resources.moderation.actions = block = invenio_rdm_records.requests.user_moderation.actions:on_block restore = invenio_rdm_records.requests.user_moderation.actions:on_restore approve = invenio_rdm_records.requests.user_moderation.actions:on_approve +invenio_jobs.jobs = + update_expired_embargos = invenio_rdm_records.jobs.jobs:update_expired_embargos_cls [build_sphinx] source-dir = docs/ diff --git a/tests/collections/__init__.py b/tests/collections/__init__.py new file mode 100644 index 000000000..03664b765 --- /dev/null +++ b/tests/collections/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Tests for collections.""" diff --git a/tests/collections/test_collections_api.py b/tests/collections/test_collections_api.py new file mode 100644 index 000000000..ddb91e10b --- /dev/null +++ b/tests/collections/test_collections_api.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Test suite for the collections programmatic API.""" + +from invenio_search.engine import dsl + +from invenio_rdm_records.collections.api import Collection, CollectionTree + + +def test_create(running_app, db, community, community_owner): + """Test collection creation via API.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + community_id=community.id, + slug="tree-1", + ) + + collection = Collection.create( + title="My Collection", + query="*:*", + slug="my-collection", + ctree=tree, + ) + + read_c = Collection.read(id_=collection.id) + assert read_c.id == collection.id + assert read_c.title == "My Collection" + assert read_c.collection_tree.id == tree.id + + # Use collection tree id + collection = Collection.create( + title="My Collection 2", + query="*:*", + slug="my-collection-2", + ctree=tree.id, + ) + + read_c = Collection.read(id_=collection.id) + assert read_c.id == collection.id + assert collection.title == "My Collection 2" + assert collection.collection_tree.id == tree.id + + +def test_resolve(running_app, db, community): + """Test collection resolution.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + community_id=community.id, + slug="tree-1", + ) + + collection = Collection.create( + title="My Collection", + query="*:*", + slug="my-collection", + ctree=tree, + ) + + # Read by ID + read_by_id = Collection.read(id_=collection.id) + assert read_by_id.id == collection.id + + # Read by slug + read_by_slug = Collection.read(slug="my-collection", ctree_id=tree.id) + assert read_by_slug.id == read_by_id.id == collection.id + + +def test_query_build(running_app, db): + """Test query building.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + slug="tree-1", + ) + c1 = Collection.create( + title="My Collection", + query="metadata.title:hello", + slug="my-collection", + ctree=tree, + ) + c2 = Collection.create( + title="My Collection 2", + query="metadata.creators.name:john", + slug="my-collection-2", + parent=c1, + ) + assert c2.query == c1.query & dsl.Q("query_string", query=c2.search_query) + + +def test_children(running_app, db): + """Test children property.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + slug="tree-1", + ) + c1 = Collection.create( + title="My Collection", + query="*:*", + slug="my-collection", + ctree=tree, + ) + c2 = Collection.create( + title="My Collection 2", + query="*:*", + slug="my-collection-2", + parent=c1, + ) + c3 = Collection.create( + title="My Collection 3", + query="*:*", + slug="my-collection-3", + parent=c2, + ) + assert c1.children == [c2] + assert c2.children == [c3] + assert c3.children == [] diff --git a/tests/collections/test_collections_tasks.py b/tests/collections/test_collections_tasks.py new file mode 100644 index 000000000..85bd63d99 --- /dev/null +++ b/tests/collections/test_collections_tasks.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-RDM-Records is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. +"""Test celery tasks of collections.""" + +from copy import deepcopy + +from invenio_rdm_records.collections.api import Collection, CollectionTree +from invenio_rdm_records.collections.tasks import update_collections_size + + +def test_update_collections_size(app, db, record_factory, minimal_record, community): + """Test update_collections_size task.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + community_id=community.id, + slug="tree-1", + ) + + collection = Collection.create( + title="My Collection", + query="metadata.title:foo", + slug="my-collection", + ctree=tree, + ) + update_collections_size() + + # Check that the collections have been updated + collection = Collection.read(id_=collection.id) + assert collection.num_records == 0 + + # Add a record that matches the collection + rec = deepcopy(minimal_record) + rec["metadata"]["title"] = "foo" + record = record_factory.create_record(record_dict=rec, community=community) + + update_collections_size() + + collection = Collection.read(id_=collection.id) + assert collection.num_records == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 29580de56..2f54cfa3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,7 @@ from invenio_records_resources.proxies import current_service_registry from invenio_records_resources.references.entity_resolvers import ServiceResultResolver from invenio_records_resources.services.custom_fields import TextCF +from invenio_records_resources.services.uow import UnitOfWork from invenio_requests.notifications.builders import ( CommentRequestEventCreateNotificationBuilder, ) @@ -918,6 +919,19 @@ def minimal_record(): } +@pytest.fixture() +def empty_record(): + """Almost empty record data as dict coming from the external world.""" + return { + "pids": {}, + "access": {}, + "files": { + "enabled": False, # Most tests don't care about files + }, + "metadata": {}, + } + + @pytest.fixture() def minimal_restricted_record(minimal_record): """Data for restricted record.""" @@ -2013,20 +2027,22 @@ def create_record( """Creates new record that belongs to the same community.""" # create draft draft = current_rdm_records_service.create(uploader.identity, record_dict) - # publish and get record - result_item = current_rdm_records_service.publish( - uploader.identity, draft.id - ) - record = result_item._record + record = draft._record if community: # add the record to the community community_record = community._record record.parent.communities.add(community_record, default=False) record.parent.commit() db.session.commit() - current_rdm_records_service.indexer.index( - record, arguments={"refresh": True} - ) + + # publish and get record + result_item = current_rdm_records_service.publish( + uploader.identity, draft.id + ) + record = result_item._record + current_rdm_records_service.indexer.index( + record, arguments={"refresh": True} + ) return record diff --git a/tests/requests/conftest.py b/tests/requests/conftest.py index 6a1248a63..27d1d8d29 100644 --- a/tests/requests/conftest.py +++ b/tests/requests/conftest.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2019-2021 CERN. +# Copyright (C) 2019-2024 CERN. # Copyright (C) 2019-2021 Northwestern University. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify @@ -15,6 +15,8 @@ import pytest from invenio_records_permissions.generators import AuthenticatedUser, SystemProcess +import invenio_rdm_records.services.communities.moderation as communities_moderation +from invenio_rdm_records.services.components.verified import UserModerationHandler from invenio_rdm_records.services.permissions import RDMRecordPermissionPolicy @@ -35,4 +37,9 @@ def app_config(app_config): app_config["RDM_PERMISSION_POLICY"] = CustomRDMRecordPermissionPolicy # Enable user moderation app_config["RDM_USER_MODERATION_ENABLED"] = True + # Enable content moderation handlers + app_config["RDM_CONTENT_MODERATION_HANDLERS"] = [UserModerationHandler()] + app_config["RDM_COMMUNITY_CONTENT_MODERATION_HANDLERS"] = [ + communities_moderation.UserModerationHandler(), + ] return app_config diff --git a/tests/requests/test_user_moderation_actions.py b/tests/requests/test_user_moderation_actions.py index 984b0d85b..cb024fb0c 100644 --- a/tests/requests/test_user_moderation_actions.py +++ b/tests/requests/test_user_moderation_actions.py @@ -21,8 +21,8 @@ class MockRequestModerationTask(Task): """Mock celery task for moderation request.""" - def delay(*args): - user_id = args[0] + def apply_async(self, args=None, kwargs=None, **kwargs_): + user_id = kwargs["user_id"] with db.session.begin_nested(): try: current_user_moderation_service.request_moderation( @@ -55,7 +55,7 @@ def test_user_moderation_approve( # This is a patch for tests only. mocker.patch( "invenio_rdm_records.services.components.verified.request_moderation", - MockRequestModerationTask, + MockRequestModerationTask(), ) new_record = records_service.publish( identity=unverified_user.identity, id_=new_version.id diff --git a/tests/resources/serializers/test_csl_serializer.py b/tests/resources/serializers/test_csl_serializer.py index 97abe2378..319f63a3c 100644 --- a/tests/resources/serializers/test_csl_serializer.py +++ b/tests/resources/serializers/test_csl_serializer.py @@ -148,3 +148,14 @@ def test_citation_string_serializer_record( # in case of error, the response is JSON assert response.headers["content-type"] == "application/json" assert f"Citation string style not found." in body + + +def test_citation_string_serializer_empty_record(running_app, empty_record): + """Test Citation String Serializer for an empty record.""" + + expected_data = {} + + serializer = CSLJSONSchema() + serialized_record = serializer.dump(empty_record) + + assert serialized_record == expected_data diff --git a/tests/resources/serializers/test_datacite_serializer.py b/tests/resources/serializers/test_datacite_serializer.py index 12a7432c9..967829aec 100644 --- a/tests/resources/serializers/test_datacite_serializer.py +++ b/tests/resources/serializers/test_datacite_serializer.py @@ -513,3 +513,14 @@ def test_datacite43_serializer_updated_date(running_app, full_modified_date_reco assert expected_dates == serialized_record["dates"] assert len(serialized_record["dates"]) == 3 + + +def test_datacite43_serializer_empty_record(running_app, empty_record): + """Test if the DataCite 4.3 JSON serializer handles an empty record.""" + + expected_data = {"schemaVersion": "http://datacite.org/schema/kernel-4"} + + serializer = DataCite43JSONSerializer() + serialized_record = serializer.dump_obj(empty_record) + + assert serialized_record == expected_data diff --git a/tests/resources/serializers/test_dublincore_serializer.py b/tests/resources/serializers/test_dublincore_serializer.py index 129914d4a..52c327d3d 100644 --- a/tests/resources/serializers/test_dublincore_serializer.py +++ b/tests/resources/serializers/test_dublincore_serializer.py @@ -90,6 +90,17 @@ def test_dublincorejson_serializer_minimal(running_app, updated_minimal_record): assert serialized_record == expected_data +def test_dublincorejson_serializer_empty_record(running_app, empty_record): + """Test serializer to Dublin Core JSON with an empty record""" + + expected_data = {} + + serializer = DublinCoreJSONSerializer() + serialized_record = serializer.dump_obj(empty_record) + + assert serialized_record == expected_data + + def test_vocabulary_type_error(running_app, updated_minimal_record): """Test error thrown on missing resource type.""" updated_minimal_record["metadata"]["resource_type"]["id"] = "invalid" diff --git a/tests/resources/serializers/test_schemaorg_serializer.py b/tests/resources/serializers/test_schemaorg_serializer.py index b61122f64..6569a91ab 100644 --- a/tests/resources/serializers/test_schemaorg_serializer.py +++ b/tests/resources/serializers/test_schemaorg_serializer.py @@ -178,3 +178,16 @@ def test_schemaorg_serializer_minimal_record(running_app, minimal_record): serialized_record = serializer.dump_obj(minimal_record) assert serialized_record == expected_data + + +def test_schemaorg_serializer_empty_record(running_app, empty_record): + """Test Schemaorg JSON-LD serializer with minimal record.""" + + expected_data = { + "@context": "http://schema.org", + } + + serializer = SchemaorgJSONLDSerializer() + serialized_record = serializer.dump_obj(empty_record) + + assert serialized_record == expected_data diff --git a/tests/resources/test_resources_communities.py b/tests/resources/test_resources_communities.py index f4ad9c4dd..db56027e2 100644 --- a/tests/resources/test_resources_communities.py +++ b/tests/resources/test_resources_communities.py @@ -7,6 +7,7 @@ """Tests record's communities resources.""" +from contextlib import contextmanager from copy import deepcopy import pytest @@ -18,7 +19,10 @@ ) from invenio_rdm_records.records.api import RDMDraft, RDMRecord from invenio_rdm_records.requests.community_inclusion import CommunityInclusion -from invenio_rdm_records.services.errors import InvalidAccessRestrictions +from invenio_rdm_records.services.errors import ( + CommunityRequiredError, + InvalidAccessRestrictions, +) def _add_to_community(db, record, community): @@ -889,3 +893,221 @@ def test_add_record_to_restricted_community_submission_open_member( assert not response.json.get("errors") processed = response.json["processed"] assert len(processed) == 1 + + +# Assure Records community exists tests +# ------------------------------------- + + +def test_restricted_record_creation( + app, + record_community, + uploader, + curator, + community_owner, + test_user, + superuser, + monkeypatch, +): + """Verify CommunityRequiredError is raised when direct publish a record""" + # You can directly publish a record when the config is disabled + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", False) + rec = record_community.create_record(community=None) + assert rec.id + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", True) + # You can't directly publish + users = [ + curator, + test_user, + uploader, + community_owner, + ] + for user in users: + with pytest.raises(CommunityRequiredError): + record_community.create_record(uploader=user, community=None) + + # Super user can! + super_user_rec = record_community.create_record(uploader=superuser, community=None) + assert super_user_rec.id + + +def test_republish_with_mulitple_communities( + app, + db, + headers, + client, + minimal_record, + open_review_community, + record_community, + community2, + uploader, + monkeypatch, +): + """Verify new version of record with multiple communities can be re-published""" + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", True) + client = uploader.login(client) + comm = [ + community2, + open_review_community, + ] + record = record_community.create_record() + record_pid = record.pid.pid_value + for com in comm: + _add_to_community(db, record, com) + assert len(record.parent.communities.ids) == 3 + response = client.post( + f"/records/{record_pid}/versions", + headers=headers, + ) + assert response.is_json + assert response.status_code == 201 + current_rdm_records_service.update_draft( + uploader.identity, response.json["id"], minimal_record + ) + result_item = current_rdm_records_service.publish( + uploader.identity, response.json["id"] + ) + new_record_pid = result_item._record.pid.pid_value + + new_record = client.get(f"/records/{new_record_pid}", headers=headers) + assert len(new_record.json["parent"]["communities"]["ids"]) == 3 + + +def test_remove_last_existing_non_existing_community( + app, + client, + uploader, + record_community, + headers, + community, + monkeypatch, +): + """Test removal of an existing and non-existing community from the record, + while ensuring at least one community exists.""" + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", True) + data = { + "communities": [ + {"id": "wrong-id"}, + {"id": community.id}, + {"id": "wrong-id2"}, + ] + } + + client = uploader.login(client) + record = record_community.create_record() + record_pid = record.pid.pid_value + response = client.delete( + f"/records/{record_pid}/communities", + headers=headers, + json=data, + ) + assert response.is_json + assert response.status_code == 400 + # Should get 3 errors: Can't remove community, 2 bad IDs + assert len(response.json["errors"]) == 3 + record_saved = client.get(f"/records/{record_pid}", headers=headers) + assert record_saved.json["parent"]["communities"] + + +def test_remove_last_community_api_error_handling( + record_community, + community, + uploader, + headers, + curator, + client, + app, + monkeypatch, +): + """Testing error message when trying to remove last community.""" + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", True) + record = record_community.create_record() + record_pid = record.pid.pid_value + data = {"communities": [{"id": community.id}]} + for user in [uploader, curator]: + client = user.login(client) + response = client.get( + f"/communities/{community.id}/records", + headers=headers, + json=data, + ) + assert ( + len(response.json["hits"]["hits"][0]["parent"]["communities"]["ids"]) == 1 + ) + response = client.delete( + f"/records/{record_pid}/communities", + headers=headers, + json=data, + ) + assert response.is_json + assert response.status_code == 400 + + record_saved = client.get(f"/records/{record_pid}", headers=headers) + assert record_saved.json["parent"]["communities"] + assert len(response.json["errors"]) == 1 + + client = user.logout(client) + # check communities number + response = client.get( + f"/communities/{community.id}/records", + headers=headers, + json=data, + ) + assert ( + len(response.json["hits"]["hits"][0]["parent"]["communities"]["ids"]) == 1 + ) + + +def test_remove_record_last_community_with_multiple_communities( + closed_review_community, + open_review_community, + record_community, + community2, + uploader, + headers, + client, + app, + db, + monkeypatch, +): + """Testing correct removal of multiple communities""" + monkeypatch.setitem(app.config, "RDM_COMMUNITY_REQUIRED_TO_PUBLISH", True) + client = uploader.login(client) + + record = record_community.create_record() + record_pid = record.pid.pid_value + comm = [ + community2, + open_review_community, + closed_review_community, + ] # one more in the rec fixture so it's 4 + for com in comm: + _add_to_community(db, record, com) + assert len(record.parent.communities.ids) == 4 + + data = {"communities": [{"id": x} for x in record.parent.communities.ids]} + + response = client.delete( + f"/records/{record_pid}/communities", + headers=headers, + json=data, + ) + # You get res 200 with error msg if all communities you are deleting + assert response.status_code == 200 + assert "error" in str(response.data) + + rec_com_left = client.get(f"/records/{record_pid}", headers=headers) + assert len(rec_com_left.json["parent"]["communities"]["ids"]) == 1 + + # You get res 400 with error msg if you Delete the last one only. + response = client.delete( + f"/records/{record_pid}/communities", + headers=headers, + json={"communities": [{"id": str(record.parent.communities.ids[0])}]}, + ) + assert response.status_code == 400 + assert "error" in str(response.data) + + record_saved = client.get(f"/records/{record_pid}", headers=headers) + # check that only one community ID is associated with the record + assert len(record_saved.json["parent"]["communities"]["ids"]) == 1 diff --git a/tests/resources/vocabularies/test_names_vocabulary.py b/tests/resources/vocabularies/test_names_vocabulary.py index 6ff6df70b..0bb7c32bc 100644 --- a/tests/resources/vocabularies/test_names_vocabulary.py +++ b/tests/resources/vocabularies/test_names_vocabulary.py @@ -39,20 +39,20 @@ def example_name(app, db, search_clear, superuser_identity, names_service): names_service.delete(superuser_identity, name.id) -def test_names_get(client, example_name, headers): +def test_names_get(client_with_login, example_name, headers): """Test the endpoint to retrieve a single item.""" id_ = example_name.id - res = client.get(f"/names/{id_}", headers=headers) + res = client_with_login.get(f"/names/{id_}", headers=headers) assert res.status_code == 200 assert res.json["id"] == id_ # Test links assert res.json["links"] == {"self": f"https://127.0.0.1:5000/api/names/{id_}"} -def test_names_search(client, example_name, headers): +def test_names_search(client_with_login, example_name, headers): """Test a successful search.""" - res = client.get("/names", headers=headers) + res = client_with_login.get("/names", headers=headers) assert res.status_code == 200 assert res.json["hits"]["total"] == 1 diff --git a/tests/services/test_collections_service.py b/tests/services/test_collections_service.py new file mode 100644 index 000000000..8915b02b7 --- /dev/null +++ b/tests/services/test_collections_service.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 CERN. +# +# Invenio-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. +"""Test suite for the collections service.""" + +import dictdiffer +import pytest + +from invenio_rdm_records.collections.api import Collection, CollectionTree +from invenio_rdm_records.proxies import current_rdm_records + + +@pytest.fixture() +def collections_service(): + """Get collections service fixture.""" + return current_rdm_records.collections_service + + +@pytest.fixture(autouse=True) +def add_collections(running_app, db, community): + """Create collections on demand.""" + + def _inner(): + """Add collections to the app.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + community_id=community.id, + slug="tree-1", + ) + c1 = Collection.create( + title="Collection 1", + query="metadata.title:foo", + slug="collection-1", + ctree=tree, + ) + c2 = Collection.create( + title="Collection 2", + query="metadata.title:bar", + slug="collection-2", + ctree=tree, + parent=c1, + ) + return [c1, c2] + + return _inner + + +def test_collections_read( + running_app, db, add_collections, collections_service, community, community_owner +): + """Test collections service.""" + collections = add_collections() + c0 = collections[0] + c1 = collections[1] + + # Read by id + res = collections_service.read(identity=community_owner.identity, id_=c0.id) + assert res._collection.id == c0.id + + # Read by slug + res = collections_service.read( + identity=community_owner.identity, + community_id=community.id, + tree_slug=c0.collection_tree.slug, + slug=c0.slug, + ) + assert res._collection.id == c0.id + + +def test_collections_create( + running_app, db, collections_service, community, community_owner +): + """Test collection creation via service.""" + tree = CollectionTree.create( + title="Tree 1", + order=10, + community_id=community.id, + slug="tree-1", + ) + collection = collections_service.create( + community_owner.identity, + community.id, + tree_slug="tree-1", + slug="my-collection", + title="My Collection", + query="*:*", + ) + + # Get the API object, just for the sake of testing + collection = collection._collection + + assert collection.title == "My Collection" + assert collection.collection_tree.id == tree.id + + read_collection = collections_service.read( + identity=community_owner.identity, id_=collection.id + ) + assert read_collection._collection.id == collection.id + assert read_collection._collection.title == "My Collection" + + +def test_collections_add( + running_app, db, collections_service, add_collections, community_owner +): + """Test adding a collection to another via service.""" + collections = add_collections() + c1 = collections[0] + c2 = collections[1] + + c3 = collections_service.add( + community_owner.identity, + collection=c2, + slug="collection-3", + title="Collection 3", + query="metadata.title:baz", + ) + + # Get the API object, just for the sake of testing + c3 = c3._collection + + # Read the collection + res = collections_service.read(identity=community_owner.identity, id_=c3.id) + assert res._collection.id == c3.id + assert res._collection.title == "Collection 3" + + # Read the parent collection + res = collections_service.read(identity=community_owner.identity, id_=c2.id) + assert res.to_dict()[c2.id]["children"] == [c3.id] + + +def test_collections_results( + running_app, db, add_collections, collections_service, community_owner +): + """Test collection results. + + The goal is to test the format returned by the service, based on the required depth. + """ + collections = add_collections() + c0 = collections[0] + c1 = collections[1] + c3 = collections_service.add( + community_owner.identity, + c1, + slug="collection-3", + title="Collection 3", + query="metadata.title:baz", + ) + # Read the collection tree up to depth 2 + res = collections_service.read( + identity=community_owner.identity, id_=c0.id, depth=2 + ) + r_dict = res.to_dict() + + expected = { + "root": c0.id, + c0.id: { + "breadcrumbs": [ + { + "link": "/communities/blr/collections/tree-1/collection-1", + "title": "Collection 1", + } + ], + "children": [c1.id], + "depth": 0, + "id": c0.id, + "links": { + "search": "/api/communities/blr/records", + "self_html": "/communities/blr/collections/tree-1/collection-1", + "search": f"/api/collections/{c0.id}/records", + }, + "num_records": 0, + "order": c0.order, + "slug": "collection-1", + "title": "Collection 1", + }, + c1.id: { + "children": [], + "depth": 1, + "id": c1.id, + "links": { + "search": "/api/communities/blr/records", + "self_html": "/communities/blr/collections/tree-1/collection-2", + "search": f"/api/collections/{c1.id}/records", + }, + "num_records": 0, + "order": c1.order, + "slug": "collection-2", + "title": "Collection 2", + }, + } + assert not list(dictdiffer.diff(expected, r_dict)) + + # Read the collection tree up to depth 3 + res = collections_service.read( + identity=community_owner.identity, id_=c0.id, depth=3 + ) + r_dict = res.to_dict() + + # Get the API object, just for the sake of testing + c3 = c3._collection + expected = { + "root": c0.id, + c0.id: { + "breadcrumbs": [ + { + "link": "/communities/blr/collections/tree-1/collection-1", + "title": "Collection 1", + } + ], + "children": [c1.id], + "depth": 0, + "id": c0.id, + "links": { + "search": "/api/communities/blr/records", + "self_html": "/communities/blr/collections/tree-1/collection-1", + "search": f"/api/collections/{c0.id}/records", + }, + "num_records": 0, + "order": c0.order, + "slug": "collection-1", + "title": "Collection 1", + }, + c1.id: { + "children": [c3.id], + "depth": 1, + "id": c1.id, + "links": { + "search": "/api/communities/blr/records", + "self_html": "/communities/blr/collections/tree-1/collection-2", + "search": f"/api/collections/{c1.id}/records", + }, + "num_records": 0, + "order": c1.order, + "slug": "collection-2", + "title": "Collection 2", + }, + c3.id: { + "children": [], + "depth": 2, + "id": c3.id, + "links": { + "search": "/api/communities/blr/records", + "self_html": "/communities/blr/collections/tree-1/collection-3", + "search": f"/api/collections/{c3.id}/records", + }, + "num_records": 0, + "order": c3.order, + "slug": "collection-3", + "title": "Collection 3", + }, + } + + assert not list(dictdiffer.diff(expected, r_dict)) + + +def test_update(running_app, db, add_collections, collections_service, community_owner): + """Test updating a collection.""" + collections = add_collections() + c0 = collections[0] + + # Update by ID + collections_service.update( + community_owner.identity, + c0.id, + data={"slug": "New slug"}, + ) + + res = collections_service.read( + identity=community_owner.identity, + id_=c0.id, + ) + + assert res.to_dict()[c0.id]["slug"] == "New slug" + + # Update by object + collections_service.update( + community_owner.identity, + c0, + data={"slug": "New slug 2"}, + ) + + res = collections_service.read( + identity=community_owner.identity, + id_=c0.id, + ) + assert res.to_dict()[c0.id]["slug"] == "New slug 2" + + +def test_read_many( + running_app, db, add_collections, collections_service, community_owner +): + """Test reading multiple collections.""" + collections = add_collections() + c0 = collections[0] + c1 = collections[1] + + # Read two collections + res = collections_service.read_many( + community_owner.identity, + ids_=[c0.id, c1.id], + depth=0, + ) + + res = res.to_dict() + assert len(res) == 2 + assert res[0]["root"] == c0.id + assert res[1]["root"] == c1.id + + +def test_read_all( + running_app, db, add_collections, collections_service, community_owner +): + """Test reading all collections.""" + collections = add_collections() + c0 = collections[0] + c1 = collections[1] + + # Read all collections + res = collections_service.read_all(community_owner.identity, depth=0) + + res = res.to_dict() + assert len(res) == 2 + assert res[0]["root"] == c0.id + assert res[1]["root"] == c1.id + + +def test_read_invalid(running_app, db, collections_service, community_owner): + """Test reading a non-existing collection.""" + with pytest.raises(ValueError): + collections_service.read( + identity=community_owner.identity, + ) diff --git a/tests/services/test_service_access.py b/tests/services/test_service_access.py index 820a3d94e..83aac3dbc 100644 --- a/tests/services/test_service_access.py +++ b/tests/services/test_service_access.py @@ -679,3 +679,36 @@ def test_delete_grant_by_subject_permissions( # assert that now user can not create a new version with pytest.raises(PermissionDeniedError): records_service.new_version(user_with_grant.identity, id_=record.id) + + +def test_preview_draft_link_in_email( + running_app, uploader, minimal_record, community_owner +): + # services + records_service = current_rdm_records.records_service + access_service = records_service.access + # instances + draft = records_service.create(uploader.identity, minimal_record) + + # community_owner is not used because this has anything to do with + # communities. It is used simply because it is a user with "visibility": "public" + # like it is in test_resources_user_access.py + user_id = str(community_owner.id) + grant_payload = { + "grants": [ + { + "subject": {"type": "user", "id": user_id}, + "permission": "preview", + "notify": True, + } + ] + } + mail = running_app.app.extensions.get("mail") + + with mail.record_messages() as outbox: + # This triggers an email notification because of "notify": True + access_service.bulk_create_grants(uploader.identity, draft.id, grant_payload) + + sent_mail = outbox[0] + assert f"/records/{draft.id}?preview=1" in sent_mail.html + assert f"/records/{draft.id}?preview=1" in sent_mail.body diff --git a/tests/services/test_service_community_records.py b/tests/services/test_service_community_records.py index 9a8695a7d..67885f4bb 100644 --- a/tests/services/test_service_community_records.py +++ b/tests/services/test_service_community_records.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2023 CERN. +# Copyright (C) 2023-2024 CERN. # # Invenio-RDM-Records is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. """Test community records service.""" +from copy import deepcopy + import pytest from invenio_records_resources.services.errors import PermissionDeniedError from marshmallow import ValidationError +from invenio_rdm_records.collections.api import Collection, CollectionTree from invenio_rdm_records.proxies import ( current_community_records_service, current_rdm_records_service,