diff --git a/invenio_vcs/assets/semantic-ui/js/invenio_vcs/index.js b/invenio_vcs/assets/semantic-ui/js/invenio_vcs/index.js new file mode 100644 index 00000000..f9438bdd --- /dev/null +++ b/invenio_vcs/assets/semantic-ui/js/invenio_vcs/index.js @@ -0,0 +1,232 @@ +// This file is part of InvenioVCS +// Copyright (C) 2023 CERN. +// +// Invenio VCS is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. +import $ from "jquery"; + +function addResultMessage(element, color, icon, message) { + element.classList.remove("hidden"); + element.classList.add(color); + element.querySelector(`.icon`).className = `${icon} small icon`; + element.querySelector(".content").textContent = message; +} + +// function from https://www.w3schools.com/js/js_cookies.asp +function getCookie(cname) { + let name = cname + "="; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) == " ") { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} + +const REQUEST_HEADERS = { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken"), +}; + +const sync_button = document.getElementById("sync_repos"); +if (sync_button) { + sync_button.addEventListener("click", function () { + const resultMessage = document.getElementById("sync-result-message"); + const loaderIcon = document.getElementById("loader_icon"); + const buttonTextElem = document.getElementById("sync_repos_btn_text"); + const buttonText = buttonTextElem.innerHTML; + const loadingText = sync_button.dataset.loadingText; + const provider = sync_button.dataset.provider; + + const url = `/api/user/vcs/${provider}/repositories/sync`; + const request = new Request(url, { + method: "POST", + headers: REQUEST_HEADERS, + }); + + buttonTextElem.innerHTML = loadingText; + loaderIcon.classList.add("loading"); + + function fetchWithTimeout(url, options, timeout = 100000) { + /** Timeout set to 100000 ms = 1m40s .*/ + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), timeout) + ) + ]); + } + + syncRepos(request); + + async function syncRepos(request) { + try { + const response = await fetchWithTimeout(request); + loaderIcon.classList.remove("loading"); + sync_button.classList.add("disabled"); + buttonTextElem.innerHTML = buttonText; + if (response.ok) { + addResultMessage( + resultMessage, + "positive", + "checkmark", + "Repositories synced successfully. Please reload the page." + ); + sync_button.classList.remove("disabled"); + setTimeout(function () { + resultMessage.classList.add("hidden"); + }, 10000); + } else { + addResultMessage( + resultMessage, + "negative", + "cancel", + `Request failed with status code: ${response.status}` + ); + setTimeout(function () { + resultMessage.classList.add("hidden"); + }, 10000); + sync_button.classList.remove("disabled"); + } + } catch (error) { + loaderIcon.classList.remove("loading"); + if(error.message === "timeout"){ + addResultMessage( + resultMessage, + "warning", + "hourglass", + "This action seems to take some time, refresh the page after several minutes to inspect the synchronisation." + ); + } + else { + addResultMessage( + resultMessage, + "negative", + "cancel", + `There has been a problem: ${error}` + ); + setTimeout(function () { + resultMessage.classList.add("hidden"); + }, 7000); + } + } + } + }); +} + +const repositories = document.getElementsByClassName("repository-item"); +if (repositories) { + for (const repo of repositories) { + repo.addEventListener("change", function (event) { + sendEnableDisableRequest(event.target.checked, repo); + }); + } +} + +function sendEnableDisableRequest(checked, repo) { + const input = repo.querySelector("input[data-repo-id]"); + const repo_id= input.getAttribute("data-repo-id"); + const provider = input.getAttribute("data-provider"); + const switchMessage = repo.querySelector(".repo-switch-message"); + + let url; + if (checked === true) { + url = `/api/user/vcs/${provider}/repositories/${repo_id}/enable`; + } else if (checked === false) { + url = `/api/user/vcs/${provider}/repositories/${repo_id}/disable`; + } + + const request = new Request(url, { + method: "POST", + headers: REQUEST_HEADERS, + }); + + sendRequest(request); + + async function sendRequest(request) { + try { + const response = await fetch(request); + if (response.ok) { + addResultMessage( + switchMessage, + "positive", + "checkmark", + "Repository synced successfully. Please reload the page." + ); + setTimeout(function () { + switchMessage.classList.add("hidden"); + }, 10000); + } else { + addResultMessage( + switchMessage, + "negative", + "cancel", + `Request failed with status code: ${response.status}` + ); + setTimeout(function () { + switchMessage.classList.add("hidden"); + }, 5000); + } + } catch (error) { + addResultMessage( + switchMessage, + "negative", + "cancel", + `There has been a problem: ${error}` + ); + setTimeout(function () { + switchMessage.classList.add("hidden"); + }, 7000); + } + } +} + +// DOI badge modal +$(".doi-badge-modal").modal({ + selector: { + close: ".close.button", + }, + onShow: function () { + const modalId = $(this).attr("id"); + const $modalTrigger = $(`#${modalId}-trigger`); + $modalTrigger.attr("aria-expanded", true); + }, + onHide: function () { + const modalId = $(this).attr("id"); + const $modalTrigger = $(`#${modalId}-trigger`); + $modalTrigger.attr("aria-expanded", false); + }, +}); + +$(".doi-modal-trigger").on("click", function (event) { + const modalId = $(event.target).attr("aria-controls"); + $(`#${modalId}.doi-badge-modal`).modal("show"); +}); + +$(".doi-modal-trigger").on("keydown", function (event) { + if (event.key === "Enter") { + const modalId = $(event.target).attr("aria-controls"); + $(`#${modalId}.doi-badge-modal`).modal("show"); + } +}); + +// ON OFF toggle a11y +const $onOffToggle = $(".toggle.on-off"); + +$onOffToggle && + $onOffToggle.on("change", (event) => { + const target = $(event.target); + const $onOffToggleCheckedAriaLabel = target.data("checked-aria-label"); + const $onOffToggleUnCheckedAriaLabel = target.data("unchecked-aria-label"); + if (event.target.checked) { + target.attr("aria-label", $onOffToggleCheckedAriaLabel); + } else { + target.attr("aria-label", $onOffToggleUnCheckedAriaLabel); + } + }); diff --git a/invenio_vcs/ext.py b/invenio_vcs/ext.py new file mode 100644 index 00000000..c2cdf3ca --- /dev/null +++ b/invenio_vcs/ext.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# This file is part of Invenio. +# Copyright (C) 2025 CERN. +# +# Invenio is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""Invenio module that adds VCS integration to the platform.""" + +from flask import current_app, request +from flask_menu import current_menu +from invenio_i18n import LazyString +from invenio_i18n import gettext as _ +from invenio_theme.proxies import current_theme_icons +from six import string_types +from werkzeug.utils import cached_property, import_string + +from invenio_vcs.config import get_provider_list +from invenio_vcs.receivers import VCSReceiver +from invenio_vcs.service import VCSRelease +from invenio_vcs.utils import obj_or_import_string + +from . import config + + +class InvenioVCS(object): + """Invenio-VCS extension.""" + + def __init__(self, app=None): + """Extension initialization.""" + if app: + self.init_app(app) + + @cached_property + def release_api_class(self): + """Release API class.""" + cls = current_app.config["VCS_RELEASE_CLASS"] + if isinstance(cls, string_types): + cls = import_string(cls) + assert issubclass(cls, VCSRelease) + return cls + + @cached_property + def release_error_handlers(self): + """Release error handlers.""" + error_handlers = current_app.config.get("VCS_ERROR_HANDLERS") or [] + return [ + (obj_or_import_string(error_cls), obj_or_import_string(handler)) + for error_cls, handler in error_handlers + ] + + def init_app(self, app): + """Flask application initialization.""" + self.init_config(app) + app.extensions["invenio-vcs"] = self + + def init_config(self, app): + """Initialize configuration.""" + app.config.setdefault( + "VCS_SETTINGS_TEMPLATE", + app.config.get("SETTINGS_TEMPLATE", "invenio_vcs/settings/base.html"), + ) + + for k in dir(config): + if k.startswith("VCS_"): + app.config.setdefault(k, getattr(config, k)) + + +def finalize_app_ui(app): + """Finalize app.""" + init_menu(app) + init_webhooks(app) + + +def finalize_app_api(app): + """Finalize app.""" + init_webhooks(app) + + +def init_menu(app): + """Init menu.""" + for provider in get_provider_list(app): + + def is_active(current_node): + return ( + request.endpoint.startswith("invenio_vcs.") + and request.view_args.get("provider", "") == current_node.name + ) + + current_menu.submenu(f"settings.{provider.id}").register( + endpoint="invenio_vcs.get_repositories", + endpoint_arguments_constructor=lambda id=provider.id: {"provider": id}, + text=_( + "%(icon)s %(provider)s", + icon=LazyString( + lambda: f'' + ), + provider=provider.name, + ), + order=10, + active_when=is_active, + ) + + +def init_webhooks(app): + """Register the webhook receivers based on the configured VCS providers.""" + state = app.extensions.get("invenio-webhooks") + if state is not None: + for provider in get_provider_list(app): + # Procedurally register the webhook receivers instead of including them as an entry point, since + # they are defined in the VCS provider config list rather than in the instance's setup.cfg file. + if provider.id not in state.receivers: + state.register(provider.id, VCSReceiver) diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/helpers.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/helpers.html new file mode 100644 index 00000000..da997bd2 --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/helpers.html @@ -0,0 +1,55 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} +{% from "semantic-ui/invenio_formatter/macros/badges.html" import badges_formats_list %} + +{%- macro doi_badge(doi, doi_url, provider_id, provider) %} + {%- block doi_badge scoped %} + {% set image_url = url_for('invenio_vcs_badge.index', provider=provider, repo_provider_id=provider_id, _external=True) %} + + + + {%- endblock %} +{%- endmacro %} diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/helpers.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/helpers.html new file mode 100644 index 00000000..dfa2e2a7 --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/helpers.html @@ -0,0 +1,138 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} + +{%- from "invenio_vcs/helpers.html" import doi_badge with context -%} + +{%- macro panel_start( + title, + icon="", + btn_help_text='', + btn_text='', + btn_loading_text='', + btn_icon='', + btn_href='', + btn_class='', + btn_id='', + btn_name='', + provider='', + loaded_message_id='', + id="", + panel_extra_class="secondary" + ) +%} + {%- block panel_start scoped %} +
+
+ + {%- block panel_heading scoped %} +
+
+ + {% if latest_release %} +
+
+ {% endif %} + +

+ {% if icon %}{% endif %}{{ title }} +

+ + {% if latest_release %} +
+ +
+ {%- if latest_release.record %} + {%- set latest_release_record_doi = latest_release.record.pids.get('doi', {}).get('identifier') %} + {%- set conceptid_doi_url = latest_release.record.links.parent_doi %} + {%- endif %} + + {%- if latest_release_record_doi %} + {{ doi_badge(latest_release_record_doi, doi_url=conceptid_doi_url, provider=provider, provider_id=repo.provider_id) }} + {%- endif %} +
+
+ {% endif %} + +
+ +
+
+ {%- if btn_text and (btn_href or btn_help_text) -%} + {%- if btn_help_text %} +

+ {{ btn_help_text }} +

+ {%- endif %} + + {%- if btn_href %} + + {% if btn_icon %} + + {% endif %} + {{ btn_text }} + + {%- elif btn_name and btn_id %} + + {%- endif %} + {%- endif -%} +
+
+
+ {%- endblock %} + +
+ + {%- endblock %} +{%- endmacro %} + +{%- macro panel_end() %} + {%- block panel_end scoped %} +
+ {%- endblock %} +{%- endmacro %} + +{%- macro repo_switch(repo, repo_id, provider) %} + {%- block repo_switch scoped %} + {%- set inaccessible = (repo and repo.user_id and (repo.user_id != current_user.id)) %} +
+ + +
+ {%- endblock %} +{%- endmacro %} diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index.html new file mode 100644 index 00000000..05e7a12f --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index.html @@ -0,0 +1,184 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} +{%- import "invenio_vcs/settings/helpers.html" as helpers with context %} +{%- if not request.is_xhr %} + {%- extends config.VCS_SETTINGS_TEMPLATE %} +{%- endif %} + +{%- block settings_content %} + {%- if connected %} + {%- block repositories_get_started %} + {{ + helpers.panel_start( + _('Repositories'), + icon=vocabulary["icon"] + " icon", + btn_text=_('Sync now'), + btn_loading_text=_('Syncing'), + btn_icon='sync alternate icon', + btn_id='sync_repos', + btn_name='sync-repos', + btn_help_text=_('(updated {})').format(last_sync|naturaltime), + provider=provider, + loaded_message_id='sync-result-message', + id="vcs-view", + ) + }} + +
+
+
+

+ {{ _("Get started") }} +

+
+ +
+
+

1 {{ _("Flip the switch") }}

+
+

+ {{ _('Select the repository you want to preserve, and toggle + the switch below to turn on automatic preservation of your software.') }} +

+ +
+
+ + +
+
+
+ +
+

2 {{ _("Create a release") }}

+
+

+ {{ _('Go to %(name)s and create a release . %(site_name)s will automatically download a .zip-ball of each new release and register a DOI.', + name=vocabulary["name"], site_name=config.THEME_SITENAME | default('System')) }} +

+
+ +
+

3 {{ _("Get the badge") }}

+
+

+ {{ _('After your first release, a DOI badge that you can include in your %(name)s + README will appear next to your repository below.', name=vocabulary["name"]) }} +

+ +
+ {#- TODO remove hardcoding Zenodo stuff #} + + {{ _('Example DOI:') }} 10.5281/zenodo.8475 + + + {{ _("(example)") }} + +
+
+
+
+
+ {{ helpers.panel_end() }} + {%- endblock %} + + {%- if repos %} + {%- block enabled_repositories %} + {{ helpers.panel_start(_('Enabled Repositories')) }} + + + + {{ helpers.panel_end() }} + {%- endblock %} + {% endif %} + + {%- block disabled_repositories %} + {{ helpers.panel_start(_('Repositories')) }} + + {%- block repositories_tooltip %} +

+ + + {{ _('If your organization\'s repositories do not show up in the list, please + ensure you have enabled third-party + access to the {} application. Private repositories are not supported.') + .format(config.THEME_SITENAME | default('Invenio')) }} + +

+ {%- endblock %} + + {%- if not repos %} +

+ {{ _('You have no repositories on %(name)s.', name=vocabulary["name"]) }} +
+
+ {{_('Go to %(name)s and create your first or + click the Sync button to synchronize the latest changes from %(name)s.', name=vocabulary["name"], url=new_repo_url)}} +

+ {%- else %} + + {% endif %} + + {{ helpers.panel_end() }} + {%- endblock %} + + {#- If the user has not connected their VCS account... #} + {%- else %} + {%- block connect_to_vcs_account %} + {{ helpers.panel_start(_('%(name)s', name=vocabulary["name"]), icon=vocabulary["icon"] + " icon") }} +
+
+

{{ _('Software preservation made simple!') }}

+ + + {{ _('Connect') }} + +

+ {{ _('To get started, click "Connect" and we will get a list of your %(repositories)s from %(name)s.', repositories=vocabulary["repository_name_plural"], name=vocabulary["name"]) }} +

+
+
+ {{ helpers.panel_end() }} + {%- endblock %} + {%- endif %} +{%- endblock %} + +{%- block javascript %} + {{ super() }} + {{ webpack['invenio-vcs-init.js'] }} +{%- endblock javascript %} diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index_item.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index_item.html new file mode 100644 index 00000000..72c2546b --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/index_item.html @@ -0,0 +1,59 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} +{%- from "invenio_vcs/helpers.html" import doi_badge with context -%} +{%- set release = repo.get('latest') %} + +{%- block repository_item %} +
+
+
+
+ + + {%- if release and release.record %} + {%- set release_record_doi = release.record.pids.get('doi', {}).get('identifier') %} + {%- set conceptid_doi_url = release.record.links.parent_doi %} + {%- endif %} + + {%- if release_record_doi %} + {{ doi_badge(release_record_doi, doi_url=conceptid_doi_url, provider=provider, provider_id=repo_id) }} + {%- endif %} +
+ + +
+ +
+
+
+ +
+ {{ helpers.repo_switch(repo.instance, repo_id, provider) }} +
+
+{%- endblock %} diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/release_item.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/release_item.html new file mode 100644 index 00000000..f9a5f924 --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/release_item.html @@ -0,0 +1,212 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2025 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. +#} + +{%- block release_item %} + {% set release_tag = release.generic_release.tag_name %} + {% set this_release_url = release_url(repo.full_name, release.generic_release.id, release.generic_release.tag_name) %} + {% set release_name = release_tag %} + + {% set status_name = release.db_release.status.name %} + {% if status_name == "RECEIVED" %} + {% set status_title = _("Received") %} + {% set status_icon = "spinner loading" %} + {% set status_color = "warning" %} + {% elif status_name == "PROCESSING" %} + {% set status_title = _("Processing") %} + {% set status_icon = "spinner loading" %} + {% set status_color = "warning" %} + {% elif status_name == "PUBLISHED" %} + {% set status_title = _("Published") %} + {% set status_icon = "check" %} + {% set status_color = "positive" %} + {% elif status_name == "FAILED" %} + {% set status_title = _("Failed") %} + {% set status_icon = "times" %} + {% set status_color = "negative" %} + {% elif status_name == "DELETED" %} + {% set status_title = _("Deleted") %} + {% set status_icon = "times" %} + {% set status_color = "negative" %} + {% endif %} + + {% set status_icon_color = status_color %} + {% if status_color == "warning" %} + {% set status_icon_color = "warning-color" %} + {% endif %} + +
  • + {%- block release_header scoped %} +
    +
    + + {%- block release_title scoped %} +
    + + + {%- if release.record %} + {%- set release_doi = release.record.pids.get('doi', {}).get('identifier') %} + {%- endif %} + + {%- if release_doi %} + + {%- endif %} + +

    + + {{ release_name or release_tag }} + +

    +
    + {%- endblock release_title %} + + {%- block release_status %} +
    +
    + + + {{ status_title }} + +
    + +

    + {{ release.db_release.created|naturaltime }} +

    +
    + {%- endblock %} +
    + {%- block release_details_content scoped %} +
    +
    + {%- block release_details_tabs scoped %} + + {%- endblock release_details_tabs %} + + {% set active = true %} + {%- block release_details_tabs_content %} +
    + {%- block releasetab_cff %} + {% set repo_name = value %} +
    +

    {{ _("Citation File") }}

    + + {{ _("Create CITATION.cff") }} + +
    +

    + CITATION.cff {{ _('files are plain text files with human- + and machine-readable citation information for software. Code developers can include them in their repositories to let others know how to correctly cite their software.') }} +

    +

    {{ _("An example of the CITATION.cff for this release can be found below:") }}

    +
    +
    +cff-version: 1.1.0
    +message: "If you use this software, please cite it as below."
    +authors:
    +- family-names: Joe
    +given-names: Johnson
    +orcid: https://orcid.org/0000-0000-0000-0000
    +title:  {%- if release.record %}{{ release.record.data["metadata"]["title"] }}{%- endif %}
    +version: {{ release_tag }}
    +date-released: {{ release.event.payload.get("release", {}).get("published_at", "")[:10] if release.event else '2021-07-28' }}
    +                      
    +
    + {%- endblock releasetab_cff %} +
    + + {% set active = false %} + +
    + {%- block releasetab_payload %} + {%- if release.event %} +
    +

    {{ _("%(name)s Payload", name=vocabulary["name"]) }}

    + {{ _("Received") }} {{ release.event.created|datetimeformat }}. +
    + +
    +
    {{ release.event.payload|tojson(indent=4) }}
    +
    + {%- endif %} + {%- endblock releasetab_payload %} +
    + + {% set active = false %} + + {%- block metadata_tab_content %} + {%- endblock metadata_tab_content %} + +
    + {%- block releasetab_errors %} + {%- if release.db_release.errors %} +
    +
    +
    +

    {{ _("Errors") }}

    +
    +
    +
    +
    +
    +
    {{ release.db_release.errors|tojson(indent=4) }}
    +
    +
    +
    +
    + {%- endif %} + {%- endblock releasetab_errors %} +
    + {% set active = false %} + {%- endblock release_details_tabs_content %} + +
    +
    + {%- endblock release_details_content %} + +
    + {%- endblock release_header %} +
  • + + {%- block release_footer scoped %} + {%- if not is_last %}{%- endif %} + {%- endblock release_footer %} + +{%- endblock %} diff --git a/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/view.html b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/view.html new file mode 100644 index 00000000..ce2df3f9 --- /dev/null +++ b/invenio_vcs/templates/semantic-ui/invenio_vcs/settings/view.html @@ -0,0 +1,144 @@ +{# -*- coding: utf-8 -*- + + This file is part of Invenio. + Copyright (C) 2023 CERN. + + Invenio is free software; you can redistribute it and/or modify it + under the terms of the MIT License; see LICENSE file for more details. +#} + +{%- import "invenio_vcs/settings/helpers.html" as helpers with context %} +{%- from "invenio_vcs/helpers.html" import doi_badge with context -%} + +{%- extends config.VCS_SETTINGS_TEMPLATE %} + +{%- block settings_content %} + {% set active = true %} + + {%- block repo_details_header scoped %} +
    +
    +
    +
    +
    +
    + + {{ _("Repositories") }} + + + + +

    + {{ repo.full_name }} +

    +
    +
    +
    +
    + {{ helpers.repo_switch(repo, repo.provider_id, provider) }} +
    +
    + +
    + +
    +
    +
    + + {%- endblock %} + + {{ + helpers.panel_start( + _('Releases'), + btn_text=_('Create release'), + btn_icon=vocabulary["icon"] + ' icon', + btn_href=new_release_url, + ) + }} +
    + {%- if not releases %} + {%- if repo.enabled %} + + {%- block enabled_repo_get_started scoped %} +
    +

    {{ _("Get started!") }}

    +

    {{ _("Go to %(name)s and make your first release.", name=vocabulary["name"]) }}

    + + + {{ repo.full_name }} + +
    + {%- endblock enabled_repo_get_started %} + + {%- else -%} + + {%- block disabled_repo_getstarted scoped %} +
    +
    +

    {{ _("Get started!") }}

    +
    +
    +
    +

    1 {{ _("Flip the switch") }}

    +
    +

    + {{ _("Toggle the switch below to turn on/off automatic preservation of your repository.") }} +

    +
    + {{ helpers.repo_switch(repo, repo.provider_id) }} +
    + + +
    + +
    +

    2 {{ _("Create a release") }}

    +
    +

    + {{ _('Go to {} and create a release. {} + will automatically download a .zip-ball of each new release and register a DOI.') + .format(vocabulary["name"], config.THEME_SITENAME | default('Invenio')) }} +

    + + +
    +
    +
    + {%- endblock disabled_repo_getstarted %} + + {%- endif -%} + {%- else %} + {%- block repo_releases scoped %} + + {%- endblock repo_releases %} + {%- endif %} +
    + {{ helpers.panel_end() }} +{%- endblock %} + +{%- block javascript %} + {{ super() }} + {{ webpack['invenio-vcs-init.js'] }} +{%- endblock javascript %} diff --git a/invenio_vcs/views/badge.py b/invenio_vcs/views/badge.py new file mode 100644 index 00000000..e19b05a8 --- /dev/null +++ b/invenio_vcs/views/badge.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# This file is part of Invenio. +# Copyright (C) 2014-2025 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. + +"""DOI Badge Blueprint.""" + +from __future__ import absolute_import + +from flask import Blueprint, abort, redirect, url_for +from flask_login import current_user + +from invenio_vcs.config import get_provider_by_id +from invenio_vcs.models import ReleaseStatus, Repository +from invenio_vcs.proxies import current_vcs +from invenio_vcs.service import VCSService + +blueprint = Blueprint( + "invenio_vcs_badge", + __name__, + url_prefix="/badge/", + static_folder="../static", + template_folder="../templates", +) + + +@blueprint.route("/.svg") +def index(provider, repo_provider_id): + """Generate a badge for a specific vcs repository (by vcs ID).""" + repo = Repository.query.filter( + Repository.provider_id == repo_provider_id, Repository.provider == provider + ).one_or_none() + if not repo: + abort(404) + + latest_release = repo.latest_release(ReleaseStatus.PUBLISHED) + if not latest_release: + abort(404) + + provider = get_provider_by_id(provider).for_user(current_user.id) + release = current_vcs.release_api_class(latest_release, provider) + + # release.badge_title points to "DOI" + # release.badge_value points to the record "pids.doi.identifier" + badge_url = url_for( + "invenio_formatter_badges.badge", + title=release.badge_title, + value=release.badge_value, + ext="svg", + ) + return redirect(badge_url) + + +# Kept for backward compatibility +@blueprint.route("//.svg") +def index_old(provider, user_id, repo_name): + """Generate a badge for a specific vcs repository (by name).""" + repo = Repository.query.filter( + Repository.full_name == repo_name, Repository.provider == provider + ).one_or_none() + if not repo: + abort(404) + + latest_release = repo.latest_release(ReleaseStatus.PUBLISHED) + if not latest_release: + abort(404) + + provider = get_provider_by_id(provider).for_user(current_user.id) + release = current_vcs.release_api_class(latest_release, provider) + + # release.badge_title points to "DOI" + # release.badge_value points to the record "pids.doi.identifier" + badge_url = url_for( + "invenio_formatter_badges.badge", + title=release.badge_title, + value=release.badge_value, + ext="svg", + ) + return redirect(badge_url) + + +# Kept for backward compatibility +@blueprint.route("/latestdoi/") +def latest_doi(provider, provider_id): + """Redirect to the newest record version.""" + # Without user_id, we can't use VCSService. Therefore, we fetch the latest release using the Repository model directly. + repo = Repository.query.filter( + Repository.provider_id == provider_id, Repository.provider == provider + ).one_or_none() + if not repo: + abort(404) + + latest_release = repo.latest_release(ReleaseStatus.PUBLISHED) + if not latest_release: + abort(404) + + provider = get_provider_by_id(provider).for_user(current_user.id) + release = current_vcs.release_api_class(latest_release, provider) + + # record.url points to DOI url or HTML url if Datacite is not enabled. + return redirect(release.record_url) + + +# TODO: Do we really need this? If we get rid of it, we can remove the index on `name` on vcs_repositories +""" +# Kept for backward compatibility +@blueprint.route("/latestdoi//") +def latest_doi_old(provider, user_id, repo_name): + svc = VCSService.for_provider_and_user(provider, user_id) + repo = svc.get_repository(repo_name=repo_name) + release = svc.get_repo_latest_release(repo) + if not release: + abort(404) + + # record.url points to DOI url or HTML url if Datacite is not enabled. + return redirect(release.record_url) +""" diff --git a/invenio_vcs/views/vcs.py b/invenio_vcs/views/vcs.py new file mode 100644 index 00000000..01885d71 --- /dev/null +++ b/invenio_vcs/views/vcs.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# This file is part of Invenio. +# Copyright (C) 2014-2025 CERN. +# Copyright (C) 2024 Graz University of Technology. +# Copyright (C) 2024 KTH Royal Institute of Technology. +# +# 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. + +"""VCS views blueprint for Invenio platform.""" + +from functools import wraps + +from flask import Blueprint, abort, current_app, render_template +from flask_login import current_user, login_required +from invenio_db import db +from invenio_i18n import gettext as _ +from sqlalchemy.orm.exc import NoResultFound + +from invenio_vcs.service import VCSService + +from ..errors import RepositoryAccessError, RepositoryNotFoundError, VCSTokenNotFound + + +def vcs_error_handler(): + """Common error handling behaviour for VCS routes.""" + + def decorator(f): + @wraps(f) + def inner(*args, **kwargs): + try: + return f(*args, **kwargs) + except RepositoryAccessError: + abort(403) + except (NoResultFound, RepositoryNotFoundError): + abort(404) + except Exception as exc: + current_app.logger.exception(str(exc)) + abort(400) + + return inner + + return decorator + + +def require_vcs_connected(): + """Requests an oauth session token to be configured for the user.""" + + def decorator(f): + @wraps(f) + def inner(*args, **kwargs): + provider = kwargs["provider"] + svc = VCSService.for_provider_and_user(provider, current_user.id) + if svc.is_authenticated: + return f(*args, **kwargs) + raise VCSTokenNotFound( + current_user, _("Account must be connected to the VCS provider.") + ) + + return inner + + return decorator + + +def create_ui_blueprint(app): + """Creates blueprint and registers UI endpoints if the integration is enabled.""" + blueprint = Blueprint( + "invenio_vcs", + __name__, + static_folder="../static", + template_folder="../templates", + url_prefix="/account/settings/vcs/", + ) + with app.app_context(): # Todo: Temporary fix, it should be removed when inveniosoftware/invenio-theme#355 is merged + register_ui_routes(blueprint) + return blueprint + + +def create_api_blueprint(app): + """Creates blueprint and registers API endpoints if the integration is enabled.""" + blueprint_api = Blueprint( + "invenio_vcs_api", __name__, url_prefix="/user/vcs/" + ) + register_api_routes(blueprint_api) + return blueprint_api + + +def register_ui_routes(blueprint): + """Register ui routes.""" + + @blueprint.route("/") + @login_required + @vcs_error_handler() + def get_repositories(provider): + """Display list of the user's repositories.""" + svc = VCSService.for_provider_and_user(provider, current_user.id) + ctx: dict = dict( + connected=False, + provider=provider, + vocabulary=svc.provider.factory.vocabulary, + repo_url=svc.provider.factory.url_for_repository, + new_repo_url=svc.provider.factory.url_for_new_repo(), + ) + + if svc.is_authenticated: + # Generate the repositories view object + repos = svc.list_repositories() + last_sync = svc.get_last_sync_time() + + ctx.update( + { + "connected": True, + "repos": repos, + "last_sync": last_sync, + } + ) + + return render_template(current_app.config["VCS_TEMPLATE_INDEX"], **ctx) + + @blueprint.route("/repository/") + @login_required + @require_vcs_connected() + @vcs_error_handler() + def get_repository(provider, repo_id): + """Displays one repository. + + Retrieves and builds context to display all repository releases, if any. + """ + svc = VCSService.for_provider_and_user(provider, current_user.id) + + repo = svc.get_repository(repo_id) + latest_release = svc.get_repo_latest_release(repo) + default_branch = svc.get_repo_default_branch(repo_id) + releases = svc.list_repo_releases(repo) + release_url = svc.provider.factory.url_for_release + new_release_url = svc.provider.factory.url_for_new_release(repo.full_name) + new_citation_file_url = svc.provider.factory.url_for_new_file( + repo.full_name, default_branch or "main", "CITATION.cff" + ) + + return render_template( + current_app.config["VCS_TEMPLATE_VIEW"], + latest_release=latest_release, + provider=provider, + repo=repo, + releases=releases, + default_branch=default_branch, + release_url=release_url, + new_release_url=new_release_url, + new_citation_file_url=new_citation_file_url, + vocabulary=svc.provider.factory.vocabulary, + ) + + +def register_api_routes(blueprint): + """Register API routes.""" + + @login_required + @require_vcs_connected() + @vcs_error_handler() + @blueprint.route("/repositories/sync", methods=["POST"]) + def sync_user_repositories(provider): + """Synchronizes user repos. + + Currently: + POST /api/user/vcs//repositories/sync + Previously: + POST /account/settings/github/hook + """ + svc = VCSService.for_provider_and_user(provider, current_user.id) + svc.sync(async_hooks=False) + db.session.commit() + + return "", 200 + + @login_required + @require_vcs_connected() + @vcs_error_handler() + @blueprint.route("/repositories//enable", methods=["POST"]) + def enable_repository(provider, repository_id): + """Enables one repository. + + Currently: + POST /api/user/vcs//repositories//enable + Previously: + POST /account/settings/github/hook + """ + svc = VCSService.for_provider_and_user(provider, current_user.id) + create_success = svc.enable_repository(repository_id) + + db.session.commit() + if create_success: + return "", 201 + else: + raise Exception( + _("Failed to enable repository, hook creation not successful.") + ) + + @login_required + @require_vcs_connected() + @vcs_error_handler() + @blueprint.route("/repositories//disable", methods=["POST"]) + def disable_repository(provider, repository_id): + """Disables one repository. + + Currently: + POST /api/user/vcs//repositories//disable + Previously: + DELETE /account/settings/github/hook + """ + svc = VCSService.for_provider_and_user(provider, current_user.id) + remove_success = svc.disable_repository(repository_id) + + db.session.commit() + if remove_success: + return "", 204 + else: + raise Exception( + _("Failed to disable repository, hook removal not successful.") + )