diff --git a/invenio_rdm_records/notifications/vcs.py b/invenio_rdm_records/notifications/vcs.py new file mode 100644 index 0000000000..99da2a56a2 --- /dev/null +++ b/invenio_rdm_records/notifications/vcs.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2026 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. + +""" +Notification builders for VCS. + +These are kept in a separate file as invenio-vcs is an optional dependency, so imports would fail if the package isn't available. +""" + +from invenio_notifications.models import Notification +from invenio_notifications.registry import EntityResolverRegistry +from invenio_notifications.services.builders import NotificationBuilder +from invenio_notifications.services.generators import EntityResolve, UserEmailBackend +from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter +from invenio_vcs.generic_models import GenericRelease, GenericRepository +from invenio_vcs.notifications.generators import RepositoryUsersRecipient + + +class RepositoryReleaseNotificationBuilder(NotificationBuilder): + """Notification builder for repository release events.""" + + type = "repository-release" + + @classmethod + def build( + cls, + provider: str, + generic_repository: GenericRepository, + generic_release: GenericRelease, + ): + """Build the notification.""" + return Notification( + type=cls.type, + context={ + "provider": provider, + "repository_provider_id": generic_repository.id, + "repository_full_name": generic_repository.full_name, + "release_tag": generic_release.tag_name, + }, + ) + + context = [] + + recipients = [RepositoryUsersRecipient("provider", "repository_provider_id")] + + recipient_filters = [UserPreferencesRecipientFilter()] + + recipient_backends = [UserEmailBackend()] + + +class RepositoryReleaseSuccessNotificationBuilder(RepositoryReleaseNotificationBuilder): + """Notification builder for successful repository release events.""" + + type = f"{RepositoryReleaseNotificationBuilder.type}.success" + + @classmethod + def build( + cls, + provider: str, + generic_repository: GenericRepository, + generic_release: GenericRelease, + record, + ): + """Build the notification.""" + notification = super().build(provider, generic_repository, generic_release) + notification.context["record"] = EntityResolverRegistry.reference_entity(record) + return notification + + context = [EntityResolve(key="record")] + + +class RepositoryReleaseFailureNotificationBuilder(RepositoryReleaseNotificationBuilder): + """ + Notification builder for failed repository release events. + + The failure might occur before or after a draft has been successfully saved, so `draft` is allowed + to be `None`. The notification message should include a link to edit the draft if it's available. + """ + + type = f"{RepositoryReleaseNotificationBuilder.type}.failure" + + @classmethod + def build( + cls, + provider: str, + generic_repository: GenericRepository, + generic_release: GenericRelease, + error_message: str, + draft=None, + ): + """Build the notification.""" + notification = super().build(provider, generic_repository, generic_release) + notification.context["error_message"] = error_message + if draft is not None: + notification.context["draft"] = EntityResolverRegistry.reference_entity( + draft + ) + else: + notification.context["draft"] = None + return notification + + context = [EntityResolve(key="draft")] + + +class RepositoryReleaseCommunityRequiredNotificationBuilder( + RepositoryReleaseNotificationBuilder +): + """ + Release is saved as a draft but the user needs to add a community. + + Notification builder for when a release is saved as a draft but + fails to be published because the user needs to manually select + a community for the draft. + """ + + type = f"{RepositoryReleaseNotificationBuilder.type}.community-required" + + @classmethod + def build( + cls, + provider: str, + generic_repository: GenericRepository, + generic_release: GenericRelease, + draft, + ): + """Build the notification.""" + notification = super().build(provider, generic_repository, generic_release) + notification.context["draft"] = EntityResolverRegistry.reference_entity(draft) + return notification + + context = [EntityResolve(key="draft")] + + +class RepositoryReleaseCommunitySubmittedNotificationBuilder( + RepositoryReleaseNotificationBuilder +): + """Notification builder for when a release is submitted for review by a community.""" + + type = f"{RepositoryReleaseNotificationBuilder.type}.community-submitted" + + @classmethod + def build( + cls, + provider: str, + generic_repository: GenericRepository, + generic_release: GenericRelease, + request, + community, + ): + """Build the notification.""" + notification = super().build(provider, generic_repository, generic_release) + notification.context["request"] = EntityResolverRegistry.reference_entity( + request + ) + notification.context["community"] = EntityResolverRegistry.reference_entity( + community + ) + return notification + + context = [EntityResolve(key="request"), EntityResolve(key="community")] diff --git a/invenio_rdm_records/services/components/vcs.py b/invenio_rdm_records/services/components/vcs.py new file mode 100644 index 0000000000..c0906c5fe1 --- /dev/null +++ b/invenio_rdm_records/services/components/vcs.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2026 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. +"""RDM service component for updating VCS release models when drafts are published.""" + +from invenio_drafts_resources.services.records.components import ServiceComponent +from invenio_vcs.models import Release, ReleaseStatus + + +class VCSComponent(ServiceComponent): + """Service component for VCS.""" + + def publish(self, identity, draft=None, record=None): + """Publish.""" + if record is None: + return + + record_model_id = record.model.id + # See if there's a release that originally failed to publish but was saved in a draft state + db_release = Release.get_for_record(record_model_id, only_draft=True) + # If this record didn't come from a VCS release or the release originally succeeded, we won't find anything. + if db_release is None: + return + if ( + db_release.status != ReleaseStatus.FAILED + and db_release.status != ReleaseStatus.PUBLISH_PENDING + ): + return + + # We are now publishing it, so we can correct the release's status + db_release.status = ReleaseStatus.PUBLISHED + db_release.record_is_draft = False + # We can delete the error that originally happened during publish + db_release.errors = None diff --git a/invenio_rdm_records/services/github/__init__.py b/invenio_rdm_records/services/github/__init__.py index e459e101f4..39f16de010 100644 --- a/invenio_rdm_records/services/github/__init__.py +++ b/invenio_rdm_records/services/github/__init__.py @@ -1,7 +1,13 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2023 CERN. +# Copyright (C) 2023-2026 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. -"""RDM records implementation of Github.""" +"""RDM records implementation of the legacy Invenio-GitHub module. + +This module is now deprecated. For now, to allow for an optional migration, the RDM bindings for +Invenio-GitHub have been retained. Please migrate to Invenio-VCS. +""" + +# TODO: add link to migration docs. diff --git a/invenio_rdm_records/services/vcs/__init__.py b/invenio_rdm_records/services/vcs/__init__.py new file mode 100644 index 0000000000..a0e93ef70f --- /dev/null +++ b/invenio_rdm_records/services/vcs/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023-2025 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. +"""RDM records implementation of Invenio-VCS.""" diff --git a/invenio_rdm_records/services/vcs/metadata.py b/invenio_rdm_records/services/vcs/metadata.py new file mode 100644 index 0000000000..ff619c7f16 --- /dev/null +++ b/invenio_rdm_records/services/vcs/metadata.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023-2025 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. +"""RDM VCS release metadata.""" + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import yaml +from flask import current_app +from invenio_i18n import _ +from invenio_vcs.errors import CustomVCSReleaseNoRetryError +from invenio_vcs.generic_models import GenericContributor +from marshmallow import Schema, ValidationError +from mistune import markdown + +if TYPE_CHECKING: + from invenio_rdm_records.services.vcs.release import RDMVCSRelease + + +class RDMReleaseMetadata(object): + """Wraps a realease object to extract its data to meet RDM specific needs.""" + + def __init__(self, rdm_vcs_release: "RDMVCSRelease"): + """Constructor.""" + self.rdm_release = rdm_vcs_release + + @property + def related_identifiers(self): + """Return related identifiers.""" + repo_name = self.rdm_release.generic_repo.full_name + release_tag_name = self.rdm_release.generic_release.tag_name + return { + "identifier": self.rdm_release.provider.factory.url_for_tag( + repo_name, release_tag_name + ), + "scheme": "url", + "relation_type": {"id": "issupplementto"}, + "resource_type": {"id": "software"}, + } + + @property + def title(self): + """Generate a title from a release and its repository name.""" + repo_name = self.rdm_release.generic_repo.full_name + release_name = ( + self.rdm_release.generic_release.name + or self.rdm_release.generic_release.tag_name + ) + return f"{repo_name}: {release_name}" + + @property + def description(self): + """Extract description from a release. + + If the relesae does not have any body, the repository description is used. + Falls back for "No description provided". + """ + if self.rdm_release.generic_release.body: + return markdown(self.rdm_release.generic_release.body) + elif self.rdm_release.generic_repo.description: + return self.rdm_release.generic_repo.description + return _("No description provided.") + + @property + def default_metadata(self): + """Return default metadata for a release.""" + # Get default right from app config or use cc-by-4.0 if default is not set in app + # TODO use the default software license + version = self.rdm_release.generic_release.tag_name + + publication_date = self.rdm_release.generic_release.published_at + if publication_date is None: + publication_date = datetime.now(tz=timezone.utc) + publication_date = publication_date.date().isoformat() + + return dict( + description=self.description, + publication_date=publication_date, + related_identifiers=[self.related_identifiers], + version=version, + title=self.title, + resource_type={"id": "software"}, + creators=self.contributors, + publisher=current_app.config.get("APP_RDM_DEPOSIT_FORM_DEFAULTS").get( + "publisher", "CERN" + ), + ) + + @property + def repo_license(self): + """Get license from repository, if any.""" + return self.rdm_release.generic_repo.license_spdx + + @property + def contributors(self): + """Serializes contributors retrieved from github. + + .. note:: + + `self.rdm_release.contributors` might fail with a `UnexpectedGithubResponse`. This is an error from which the RDM release + processing can't recover since `creators` is a mandatory field. + """ + + def serialize_author(contributor: GenericContributor): + """Serializes github contributor data into RDM author.""" + # Default name to the user's login + name = contributor.display_name or contributor.username + company = contributor.company + + rdm_contributor: dict = { + "person_or_org": {"type": "personal", "family_name": name}, + } + if company: + rdm_contributor.update({"affiliations": [{"name": company}]}) + return rdm_contributor + + contributors = [] + provider_contributors = self.rdm_release.contributors + + if provider_contributors is not None: + # Get contributors from api + for c in provider_contributors: + rdm_author = serialize_author(c) + if rdm_author: + contributors.append(rdm_author) + + return contributors + + @property + def citation_metadata(self): + """Get citation metadata for file in repository.""" + citation_file_path = current_app.config.get("VCS_CITATION_FILE") + + if not citation_file_path: + return {} + + try: + # Read raw data from file + data = self.load_citation_file(citation_file_path) + + # Load metadata from citation file and serialize it + return self.load_citation_metadata(data) + except ValidationError as e: + # Wrap the error into CustomVCSReleaseNoRetryError() so it can be handled upstream. + # This also ensures the release isn't retried without user action. + raise CustomVCSReleaseNoRetryError(message=e.messages) + + @property + def extra_metadata(self): + """Get extra metadata for the release.""" + return self.load_extra_metadata() + + def load_extra_metadata(self): + """Get extra metadata for the release.""" + return {} + + def load_citation_file(self, citation_file_name): + """Returns the citation file data.""" + if not citation_file_name: + return {} + + # Fetch the citation file and load it + content = self.rdm_release.provider.retrieve_remote_file( + self.rdm_release.generic_repo.id, + self.rdm_release.generic_release.tag_name, + citation_file_name, + ) + + data = yaml.safe_load(content.decode("utf-8")) if content is not None else None + + return data + + def load_citation_metadata(self, citation_data): + """Get the metadata file.""" + if not citation_data: + return {} + + citation_schema = current_app.config.get("VCS_CITATION_METADATA_SCHEMA") + + assert issubclass(citation_schema, Schema), _( + "Citation schema is needed to load citation metadata." + ) + + data = citation_schema().load(citation_data) + + return data diff --git a/invenio_rdm_records/services/vcs/release.py b/invenio_rdm_records/services/vcs/release.py new file mode 100644 index 0000000000..cdcdeeed4f --- /dev/null +++ b/invenio_rdm_records/services/vcs/release.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023-2025 CERN. +# 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. +"""VCS release API implementation.""" + +from flask import current_app +from invenio_access.permissions import authenticated_user, system_identity +from invenio_access.utils import get_identity +from invenio_db import db +from invenio_drafts_resources.resources.records.errors import DraftNotCreatedError +from invenio_i18n import lazy_gettext as _ +from invenio_notifications.services.uow import NotificationOp +from invenio_records_resources.services.uow import UnitOfWork +from invenio_vcs.api import VCSRelease +from invenio_vcs.errors import CustomVCSReleaseNoRetryError +from invenio_vcs.models import ReleaseStatus + +from invenio_rdm_records.notifications.vcs import ( + RepositoryReleaseCommunityRequiredNotificationBuilder, + RepositoryReleaseCommunitySubmittedNotificationBuilder, + RepositoryReleaseFailureNotificationBuilder, + RepositoryReleaseSuccessNotificationBuilder, +) +from invenio_rdm_records.requests.community_submission import CommunitySubmission + +from ...proxies import current_rdm_records_service +from ...resources.serializers.ui import UIJSONSerializer +from ..errors import CommunityRequiredError, RecordDeletedException +from .metadata import RDMReleaseMetadata +from .utils import retrieve_recid_by_uuid + + +def _get_user_identity(user): + """Get user identity.""" + identity = get_identity(user) + identity.provides.add(authenticated_user) + return identity + + +def _format_error_message(ex): + """Format an exception into a user-readable message.""" + if hasattr(ex, "message"): + # Some errors have a 'message' attribute or a 'description' attribute, which is not the value that gets used + # when the error is stringified. + # Need to stringify the LazyString, otherwise serialisation will fail + return str(ex.message) + elif hasattr(ex, "description"): + return str(ex.description) + elif str(ex) != "": + return str(ex) + else: + # Some errors might not have any accessible message, so we use the class name as a last resort. + return type(ex).__name__ + + +class RDMVCSRelease(VCSRelease): + """Implement release API instance for RDM.""" + + metadata_cls = RDMReleaseMetadata + + @property + def metadata(self): + """Extracts metadata to create an RDM draft.""" + metadata = self.metadata_cls(self) + output = metadata.default_metadata + output.update(metadata.extra_metadata) + output.update(metadata.citation_metadata) + + if not output.get("creators"): + # Get owner from Github API + owner = self.get_owner() + if owner: + output.update({"creators": [owner]}) + + # Default to "Unkwnown" + if not output.get("creators"): + output.update( + { + "creators": [ + { + "person_or_org": { + "type": "personal", + "family_name": _("Unknown"), + }, + } + ] + } + ) + + # Add license if not yet added and available from the repo. + if not output.get("rights") and metadata.repo_license: + output.update({"rights": [{"id": metadata.repo_license.lower()}]}) + return output + + def get_custom_fields(self): + """Get custom fields.""" + ret = {} + repo_url = self.provider.factory.url_for_repository(self.generic_repo.full_name) + ret["code:codeRepository"] = repo_url + return ret + + def get_owner(self): + """Retrieves repository owner and its affiliation, if any.""" + # `owner.name` is not required, `owner.login` is. + output = None + if self.owner: + name = getattr(self.owner, "name", self.owner.login) + company = getattr(self.owner, "company", None) + output = {"person_or_org": {"type": "personal", "family_name": name}} + if company: + output.update({"affiliations": [{"name": company}]}) + return output + + def resolve_record(self): + """Resolves an RDM record from a release.""" + if not self.db_release.record_id: + return None + recid = retrieve_recid_by_uuid(self.db_release.record_id) + try: + if self.db_release.record_is_draft == True: + return current_rdm_records_service.read_draft( + system_identity, recid.pid_value + ) + else: + return current_rdm_records_service.read( + system_identity, recid.pid_value + ) + except RecordDeletedException: + return None + except DraftNotCreatedError: + # This error mostly occurs when we tried to read a draft but the record was published. + # It can happen when the VCSComponent for handling the record publish fails to run for + # whatever reason. To ensure we can recover and keep the DB consistent, we will update + # the release here. + published_record = current_rdm_records_service.read( + system_identity, recid.pid_value + ) + self.db_release.record_is_draft = False + self.release_published() + db.session.commit() + return published_record + + def _upload_files_to_draft(self, identity, draft, uow): + """Upload files to draft.""" + # Validate the release files are fetchable before initialising the draft files. + self.resolve_zipball_url() + + draft_file_service = current_rdm_records_service.draft_files + + draft_file_service.init_files( + identity, + draft.id, + data=[{"key": self.release_file_name}], + uow=uow, + ) + + with self.fetch_zipball_file() as file_stream: + draft_file_service.set_file_content( + identity, + draft.id, + self.release_file_name, + file_stream, + uow=uow, + ) + + def publish(self): + """Publish VCS release as record. + + Drafts and records are created using the current records service. + The following steps are run inside a single transaction: + + - Check if a published record corresponding to a successful release exists. + - If so, create a new version draft with the same parent. Otherwise, create a new parent/draft. + - The draft's ownership is set to the user's id via its parent. + - Upload files to the draft. + - Publish the draft. + + In case of failure, the transaction is rolled back and the release status set to 'FAILED' + + + :raises ex: any exception generated by the records service (e.g. invalid metadata) + """ + draft_file_service = current_rdm_records_service.draft_files + draft = None + + try: + with UnitOfWork(db.session) as uow: + data = { + "metadata": self.metadata, + "access": {"record": "public", "files": "public"}, + "files": {"enabled": True}, + "custom_fields": self.get_custom_fields(), + } + if self.is_first_release(): + # For the first release, use the repo's owner identity. + identity = self.user_identity + draft = current_rdm_records_service.create(identity, data, uow=uow) + self._upload_files_to_draft(identity, draft, uow) + + if self.db_repo.record_community_id is not None: + # Create a review request for the repo's configured community ID if any + # If RDM_COMMUNITY_REQUIRED_TO_PUBLISH is true and no ID is provided, the publish will fail + # and the user will be sent a notification to manually assign a community. + current_rdm_records_service.review.create( + identity, + data={ + "receiver": { + "community": self.db_repo.record_community_id + }, + "type": CommunitySubmission.type_id, + }, + record=draft._record, + uow=uow, + ) + else: + # Retrieve latest record id and its recid + latest_release = self.db_repo.latest_release() + assert latest_release is not None + latest_record_uuid = latest_release.record_id + + recid = retrieve_recid_by_uuid(latest_record_uuid) + + # Use the previous record's owner as the new version owner + last_record = current_rdm_records_service.read( + system_identity, recid.pid_value, include_deleted=True + ) + owner = last_record._record.parent.access.owner.resolve() + + identity = _get_user_identity(owner) + + # Create a new version and update its contents + new_version_draft = current_rdm_records_service.new_version( + identity, recid.pid_value, uow=uow + ) + + self._upload_files_to_draft(identity, new_version_draft, uow) + + draft = current_rdm_records_service.update_draft( + identity, new_version_draft.id, data, uow=uow + ) + + draft_file_service.commit_file( + identity, draft.id, self.release_file_name, uow=uow + ) + + # UOW must be committed manually since we're not using the decorator + uow.commit() + except Exception as ex: + # Flag release as FAILED and raise the exception + self.release_failed() + + with UnitOfWork(db.session) as uow: + # Send a notification of the failed draft save. + # This almost always will be because of a problem with the repository's contents or + # metadata, and so the user needs to change something and then publish a new release. + notification = RepositoryReleaseFailureNotificationBuilder.build( + provider=self.provider.factory.id, + generic_repository=self.generic_repo, + generic_release=self.generic_release, + error_message=_format_error_message(ex), + ) + uow.register(NotificationOp(notification)) + uow.commit() + + # Commit the FAILED state, other changes were already rollbacked by the UOW + db.session.commit() + raise ex + + # We try to publish the draft in a separate try/except. We want to save the draft even + # if the publish fails, but we want to notify the user. + + try: + with UnitOfWork(db.session) as uow: + if draft._record.parent.review is None: + record = current_rdm_records_service.publish( + identity, draft.id, uow=uow + ) + # Update release weak reference and set status to PUBLISHED + self.db_release.record_id = record._record.model.id + self.db_release.record_is_draft = False + self.release_published() + + uow.register( + NotificationOp( + RepositoryReleaseSuccessNotificationBuilder.build( + provider=self.provider.factory.id, + generic_repository=self.generic_repo, + generic_release=self.generic_release, + record=record, + ) + ) + ) + else: + review_request = current_rdm_records_service.review.submit( + identity, draft.id, uow=uow + ) + + self.db_release.record_id = draft._record.model.id + self.db_release.record_is_draft = True + self.release_pending() + + uow.register( + NotificationOp( + RepositoryReleaseCommunitySubmittedNotificationBuilder.build( + provider=self.provider.factory.id, + generic_repository=self.generic_repo, + generic_release=self.generic_release, + request=review_request._record, + community=review_request._record.receiver.resolve(), + ) + ) + ) + + # UOW must be committed manually since we're not using the decorator + uow.commit() + return None + except Exception as ex: + # Flag release as FAILED and raise the exception + self.release_failed() + + # Store the ID of the draft so we make sure to add a version instead of a whole new record for future releases. + self.db_release.record_id = draft._record.model.id + self.db_release.record_is_draft = True + + # The release publish can fail for a wide range of reasons, each of which have various inconsistent error types. + error_message = _format_error_message(ex) + + if isinstance(ex, CommunityRequiredError): + # Use a special case notification for record's without a community (on mandatory-community instances). + # This message phrases the error as a "step" the user has to take rather than something they did wrong. + notification = ( + RepositoryReleaseCommunityRequiredNotificationBuilder.build( + provider=self.provider.factory.id, + generic_repository=self.generic_repo, + generic_release=self.generic_release, + draft=draft, + ) + ) + else: + notification = RepositoryReleaseFailureNotificationBuilder.build( + provider=self.provider.factory.id, + generic_repository=self.generic_repo, + generic_release=self.generic_release, + draft=draft, + error_message=error_message, + ) + + with UnitOfWork(db.session) as uow: + uow.register(NotificationOp(notification)) + uow.commit() + + # Commit the FAILED state, other changes were already rollbacked by the UOW + db.session.commit() + + # Wrap the error to ensure Celery does not attempt to retry it (since user action is needed to resolve the problem) + raise CustomVCSReleaseNoRetryError(message=error_message) + + def process_release(self): + """Processes a VCS release. + + The release might be first validated, in terms of sender, and then published. + + :raises ex: any exception generated by the records service when creating a draft or publishing the release record. + """ + try: + record = self.publish() + return record + except Exception as ex: + message = ( + f"Error while processing VCS release {self.db_release.id}: {str(ex)}" + ) + + # A CustomVCSReleaseNoRetryError implies that the release failed due to a user error. Therefore, we should not + # log this as an exception. This error will be caught upstream by InvenioVCS. + if isinstance(ex, CustomVCSReleaseNoRetryError): + current_app.logger.info(message) + else: + current_app.logger.exception(message) + + raise ex + + def serialize_record(self): + """Serializes an RDM record.""" + return UIJSONSerializer().serialize_object(self.record.data) + + @property + def record_url(self): + """Release self url points to RDM record. + + It points to DataCite URL if the integration is enabled, otherwise it points to the HTML URL. + """ + if self.record is None: + return None + html_url = self.record.data["links"]["self_html"] + doi_url = self.record.data["links"].get("doi") + return doi_url or html_url + + @property + def badge_title(self): + """Returns the badge title.""" + if current_app.config.get("DATACITE_ENABLED"): + return "DOI" + + @property + def badge_value(self): + """Returns the badge value.""" + if current_app.config.get("DATACITE_ENABLED"): + return self.record.data.get("pids", {}).get("doi", {}).get("identifier") + + def release_published(self): + """Mark a release as published.""" + self.db_release.status = ReleaseStatus.PUBLISHED + + def release_failed(self): + """Mark a release as failed.""" + self.db_release.status = ReleaseStatus.FAILED + + def release_pending(self): + """Mark a release as pending (waiting for user action before publishing).""" + self.db_release.status = ReleaseStatus.PUBLISH_PENDING diff --git a/invenio_rdm_records/services/vcs/utils.py b/invenio_rdm_records/services/vcs/utils.py new file mode 100644 index 0000000000..3d84e45073 --- /dev/null +++ b/invenio_rdm_records/services/vcs/utils.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2023 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. +"""Utility functions.""" + +from invenio_pidstore.models import PersistentIdentifier + + +def retrieve_recid_by_uuid(rec_uuid): + """Retrieves a persistent identifier given its objects uuid. + + Helper function. + """ + recid = PersistentIdentifier.get_by_object( + pid_type="recid", + object_uuid=rec_uuid, + object_type="rec", + ) + return recid diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja new file mode 100644 index 0000000000..b4280efd82 --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja @@ -0,0 +1,52 @@ +{% set repository_full_name = notification.context.repository_full_name %} +{% set release_tag = notification.context.release_tag %} +{% set draft = notification.context.draft %} + +{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %} +{% set draft_link = draft["links"]["self_html"] %} + +{%- block subject -%} + {{ _("Finish publishing your release from %(repository_name)s", repository_name=repository_full_name) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + + + + +
{{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name )}} +
{{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }}
{{ _("To do so,") }} {{ _("view the unpublished draft record.") }}
_
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body -%} + +{%- block plain_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name) }} + +{{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }} + +{{ _("To do so,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }}) + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }} +{%- endblock plain_body -%} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name )}} + +{{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }} + +{{ _("To do so,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }}) + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}} +{%- endblock md_body -%} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja new file mode 100644 index 0000000000..edf29cf329 --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja @@ -0,0 +1,52 @@ +{% set repository_full_name = notification.context.repository_full_name %} +{% set release_tag = notification.context.release_tag %} +{% set community_title = notification.context.community["metadata"]["title"] %} +{% set request_link = notification.context.request["links"]["self_html"] %} + +{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %} + +{%- block subject -%} + {{ _("Release from %(repository_name)s submitted to %(community_title)s", repository_name=repository_full_name, community_title=community_title) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + + + + +
{{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title )}} +
{{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }}
{{ _("View the review request") }} {{ _("for more details.") }}
_
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body -%} + +{%- block plain_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title) }} + +{{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }} + +[{{ _("View the review request") }}]({{ request_link }}) {{ _("for more details.") }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }} +{%- endblock plain_body -%} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title) }} + +{{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }} + +[{{ _("View the review request") }}]({{ request_link }}) {{ _("for more details.") }} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }} +{%- endblock md_body -%} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja new file mode 100644 index 0000000000..b0fb05b74d --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja @@ -0,0 +1,65 @@ +{% set repository_full_name = notification.context.repository_full_name %} +{% set release_tag = notification.context.release_tag %} +{% set draft = notification.context.draft %} +{% set error_message = notification.context.error_message %} + +{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %} +{% set draft_link = none if draft is none else draft["links"]["self_html"] %} + +{%- block subject -%} + {{ _("There was a problem publishing a release from %(repository_name)s", repository_name=repository_full_name) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + {% if draft is none %} + + {% else %} + + {% endif %} + + + + + + + +
{{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name )}} +
{{ _("Reason:") }} {{ error_message }}
{{ _("Please fix any errors and then create a new release to retry publishing.") }}{{ _("For more details and to fix any errors,") }} {{ _("view the unpublished draft record.") }}
_
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body -%} + +{%- block plain_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name) }} + +{{ _("Reason:") }} {{error_message}} + +{% if draft is none %} + {{ _("Please fix any errors and then create a new release to retry publishing.") }} +{% else %} + {{ _("For more details and to fix any errors,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }}) +{% endif %} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }} +{%- endblock plain_body -%} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name )}} + +{{ _("Reason:") }} {{error_message}} + +{% if draft is none %} + {{ _("Please fix any errors and then create a new release to retry publishing.") }} +{% else %} + {{ _("For more details and to fix any errors,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }}) +{% endif %} + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}} +{%- endblock md_body -%} diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja new file mode 100644 index 0000000000..04ef4383ac --- /dev/null +++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja @@ -0,0 +1,45 @@ +{% set repository_full_name = notification.context.repository_full_name %} +{% set release_tag = notification.context.release_tag %} +{% set record = notification.context.record %} + +{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %} +{% set record_link = record["links"]["self_html"] %} + +{%- block subject -%} + {{ _("A release from %(repository_name)s was published successfully", repository_name=repository_full_name) }} +{%- endblock subject -%} + +{%- block html_body -%} + + + + + + + + + + + + + +
{{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }} +
{{ _("For more details,")}} {{ _("view the published record.") }}
_
{{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}.
+{%- endblock html_body -%} + +{%- block plain_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }} + +{{ _("For more details,") }} [{{ _("view the published record.") }}]({{ record_link }}) + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }} +{%- endblock plain_body -%} + +{# Markdown for Slack/Mattermost/chat #} +{%- block md_body -%} +{{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }} + +{{ _("For more details,") }} [{{ _("view the published record.") }}]({{ record_link }}) + +{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}} +{%- endblock md_body -%} diff --git a/setup.cfg b/setup.cfg index 805df8d6cb..60d7b1ca43 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,6 @@ install_requires = invenio-communities>=25.0.0,<26.0.0 invenio-drafts-resources>=8.0.0,<9.0.0 invenio-records-resources>=9.0.0,<10.0.0 - invenio-github>=5.0.0,<6.0.0 invenio-i18n>=3.0.0,<4.0.0 invenio-jobs>=8.0.0,<9.0.0 invenio-oaiserver>=4.0.0,<5.0.0 @@ -66,6 +65,11 @@ tests = pytest-mock>=1.6.0 sphinx>=4.5.0 tripoli>=2.0.0 + # invenio-vcs and invenio-github are included for unit tests and local development but are not + # otherwise required. They can optionally be depended on at the instance level. + # TODO: replace when we publish the module + invenio-vcs @ git+https://github.com/inveniosoftware/invenio-vcs@master + invenio-github>=5.0.0,<6.0.0 elasticsearch7 = invenio-search[elasticsearch7]>=3.0.0,<4.0.0 opensearch1 =