Skip to content

Commit e5f9245

Browse files
committed
WIP: feat(vcs): view handlers
* Updated the view handlers and the Jinja templates to accommodate the new generic structure. The view handlers needed only relatively small changes, but the Jinja templates had to have all references to 'GitHub', the GitHub logo, and GitHub URLs replaced with provider-specific references. The 'vocabulary' defined in the `RepositoryServiceProviderFactory` specifies the terminology that should be used for a given VCS, including the name/icon but also the word for 'repository' which is 'project' for GitLab. * The `ext.py` file is also changed here to reflect the dynamic submenu entries depending on the VCS providers registered in the config. * This commit on its own is UNRELEASABLE. We will merge multiple commits related to the VCS upgrade into the `vcs-staging` branch and then merge them all into `master` once we have a fully release-ready prototype. At that point, we will create a squash commit.
1 parent a841b14 commit e5f9245

File tree

9 files changed

+1505
-0
lines changed

9 files changed

+1505
-0
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// This file is part of InvenioGithub
2+
// Copyright (C) 2023 CERN.
3+
//
4+
// Invenio Github is free software; you can redistribute it and/or modify it
5+
// under the terms of the MIT License; see LICENSE file for more details.
6+
import $ from "jquery";
7+
8+
function addResultMessage(element, color, icon, message) {
9+
element.classList.remove("hidden");
10+
element.classList.add(color);
11+
element.querySelector(`.icon`).className = `${icon} small icon`;
12+
element.querySelector(".content").textContent = message;
13+
}
14+
15+
// function from https://www.w3schools.com/js/js_cookies.asp
16+
function getCookie(cname) {
17+
let name = cname + "=";
18+
let decodedCookie = decodeURIComponent(document.cookie);
19+
let ca = decodedCookie.split(";");
20+
for (let i = 0; i < ca.length; i++) {
21+
let c = ca[i];
22+
while (c.charAt(0) == " ") {
23+
c = c.substring(1);
24+
}
25+
if (c.indexOf(name) == 0) {
26+
return c.substring(name.length, c.length);
27+
}
28+
}
29+
return "";
30+
}
31+
32+
const REQUEST_HEADERS = {
33+
"Content-Type": "application/json",
34+
"X-CSRFToken": getCookie("csrftoken"),
35+
};
36+
37+
const sync_button = document.getElementById("sync_repos");
38+
if (sync_button) {
39+
sync_button.addEventListener("click", function () {
40+
const resultMessage = document.getElementById("sync-result-message");
41+
const loaderIcon = document.getElementById("loader_icon");
42+
const buttonTextElem = document.getElementById("sync_repos_btn_text");
43+
const buttonText = buttonTextElem.innerHTML;
44+
const loadingText = sync_button.dataset.loadingText;
45+
const provider = sync_button.dataset.provider;
46+
47+
const url = `/api/user/vcs/${provider}/repositories/sync`;
48+
const request = new Request(url, {
49+
method: "POST",
50+
headers: REQUEST_HEADERS,
51+
});
52+
53+
buttonTextElem.innerHTML = loadingText;
54+
loaderIcon.classList.add("loading");
55+
56+
function fetchWithTimeout(url, options, timeout = 100000) {
57+
/** Timeout set to 100000 ms = 1m40s .*/
58+
return Promise.race([
59+
fetch(url, options),
60+
new Promise((_, reject) =>
61+
setTimeout(() => reject(new Error('timeout')), timeout)
62+
)
63+
]);
64+
}
65+
66+
syncRepos(request);
67+
68+
async function syncRepos(request) {
69+
try {
70+
const response = await fetchWithTimeout(request);
71+
loaderIcon.classList.remove("loading");
72+
sync_button.classList.add("disabled");
73+
buttonTextElem.innerHTML = buttonText;
74+
if (response.ok) {
75+
addResultMessage(
76+
resultMessage,
77+
"positive",
78+
"checkmark",
79+
"Repositories synced successfully. Please reload the page."
80+
);
81+
sync_button.classList.remove("disabled");
82+
setTimeout(function () {
83+
resultMessage.classList.add("hidden");
84+
}, 10000);
85+
} else {
86+
addResultMessage(
87+
resultMessage,
88+
"negative",
89+
"cancel",
90+
`Request failed with status code: ${response.status}`
91+
);
92+
setTimeout(function () {
93+
resultMessage.classList.add("hidden");
94+
}, 10000);
95+
sync_button.classList.remove("disabled");
96+
}
97+
} catch (error) {
98+
loaderIcon.classList.remove("loading");
99+
if(error.message === "timeout"){
100+
addResultMessage(
101+
resultMessage,
102+
"warning",
103+
"hourglass",
104+
"This action seems to take some time, refresh the page after several minutes to inspect the synchronisation."
105+
);
106+
}
107+
else {
108+
addResultMessage(
109+
resultMessage,
110+
"negative",
111+
"cancel",
112+
`There has been a problem: ${error}`
113+
);
114+
setTimeout(function () {
115+
resultMessage.classList.add("hidden");
116+
}, 7000);
117+
}
118+
}
119+
}
120+
});
121+
}
122+
123+
const repositories = document.getElementsByClassName("repository-item");
124+
if (repositories) {
125+
for (const repo of repositories) {
126+
repo.addEventListener("change", function (event) {
127+
sendEnableDisableRequest(event.target.checked, repo);
128+
});
129+
}
130+
}
131+
132+
function sendEnableDisableRequest(checked, repo) {
133+
const input = repo.querySelector("input[data-repo-id]");
134+
const repo_id= input.getAttribute("data-repo-id");
135+
const provider = input.getAttribute("data-provider");
136+
const switchMessage = repo.querySelector(".repo-switch-message");
137+
138+
let url;
139+
if (checked === true) {
140+
url = `/api/user/vcs/${provider}/repositories/${repo_id}/enable`;
141+
} else if (checked === false) {
142+
url = `/api/user/vcs/${provider}/repositories/${repo_id}/disable`;
143+
}
144+
145+
const request = new Request(url, {
146+
method: "POST",
147+
headers: REQUEST_HEADERS,
148+
});
149+
150+
sendRequest(request);
151+
152+
async function sendRequest(request) {
153+
try {
154+
const response = await fetch(request);
155+
if (response.ok) {
156+
addResultMessage(
157+
switchMessage,
158+
"positive",
159+
"checkmark",
160+
"Repository synced successfully. Please reload the page."
161+
);
162+
setTimeout(function () {
163+
switchMessage.classList.add("hidden");
164+
}, 10000);
165+
} else {
166+
addResultMessage(
167+
switchMessage,
168+
"negative",
169+
"cancel",
170+
`Request failed with status code: ${response.status}`
171+
);
172+
setTimeout(function () {
173+
switchMessage.classList.add("hidden");
174+
}, 5000);
175+
}
176+
} catch (error) {
177+
addResultMessage(
178+
switchMessage,
179+
"negative",
180+
"cancel",
181+
`There has been a problem: ${error}`
182+
);
183+
setTimeout(function () {
184+
switchMessage.classList.add("hidden");
185+
}, 7000);
186+
}
187+
}
188+
}
189+
190+
// DOI badge modal
191+
$(".doi-badge-modal").modal({
192+
selector: {
193+
close: ".close.button",
194+
},
195+
onShow: function () {
196+
const modalId = $(this).attr("id");
197+
const $modalTrigger = $(`#${modalId}-trigger`);
198+
$modalTrigger.attr("aria-expanded", true);
199+
},
200+
onHide: function () {
201+
const modalId = $(this).attr("id");
202+
const $modalTrigger = $(`#${modalId}-trigger`);
203+
$modalTrigger.attr("aria-expanded", false);
204+
},
205+
});
206+
207+
$(".doi-modal-trigger").on("click", function (event) {
208+
const modalId = $(event.target).attr("aria-controls");
209+
$(`#${modalId}.doi-badge-modal`).modal("show");
210+
});
211+
212+
$(".doi-modal-trigger").on("keydown", function (event) {
213+
if (event.key === "Enter") {
214+
const modalId = $(event.target).attr("aria-controls");
215+
$(`#${modalId}.doi-badge-modal`).modal("show");
216+
}
217+
});
218+
219+
// ON OFF toggle a11y
220+
const $onOffToggle = $(".toggle.on-off");
221+
222+
$onOffToggle &&
223+
$onOffToggle.on("change", (event) => {
224+
const target = $(event.target);
225+
const $onOffToggleCheckedAriaLabel = target.data("checked-aria-label");
226+
const $onOffToggleUnCheckedAriaLabel = target.data("unchecked-aria-label");
227+
if (event.target.checked) {
228+
target.attr("aria-label", $onOffToggleCheckedAriaLabel);
229+
} else {
230+
target.attr("aria-label", $onOffToggleUnCheckedAriaLabel);
231+
}
232+
});

invenio_vcs/ext.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Invenio.
4+
# Copyright (C) 2023 CERN.
5+
# Copyright (C) 2024 Graz University of Technology.
6+
#
7+
# Invenio is free software; you can redistribute it
8+
# and/or modify it under the terms of the GNU General Public License as
9+
# published by the Free Software Foundation; either version 2 of the
10+
# License, or (at your option) any later version.
11+
#
12+
# Invenio is distributed in the hope that it will be
13+
# useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15+
# General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with Invenio; if not, write to the
19+
# Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
20+
# MA 02111-1307, USA.
21+
#
22+
# In applying this license, CERN does not
23+
# waive the privileges and immunities granted to it by virtue of its status
24+
# as an Intergovernmental Organization or submit itself to any jurisdiction.
25+
26+
"""Invenio module that adds VCS integration to the platform."""
27+
28+
from flask import current_app, request
29+
from flask_menu import current_menu
30+
from invenio_i18n import LazyString
31+
from invenio_i18n import gettext as _
32+
from invenio_theme.proxies import current_theme_icons
33+
from six import string_types
34+
from werkzeug.utils import cached_property, import_string
35+
36+
from invenio_vcs.config import get_provider_list
37+
from invenio_vcs.receivers import VCSReceiver
38+
from invenio_vcs.service import VCSRelease
39+
from invenio_vcs.utils import obj_or_import_string
40+
41+
from . import config
42+
43+
44+
class InvenioVCS(object):
45+
"""Invenio-VCS extension."""
46+
47+
def __init__(self, app=None):
48+
"""Extension initialization."""
49+
if app:
50+
self.init_app(app)
51+
52+
@cached_property
53+
def release_api_class(self):
54+
"""Release API class."""
55+
cls = current_app.config["VCS_RELEASE_CLASS"]
56+
if isinstance(cls, string_types):
57+
cls = import_string(cls)
58+
assert issubclass(cls, VCSRelease)
59+
return cls
60+
61+
@cached_property
62+
def release_error_handlers(self):
63+
"""Release error handlers."""
64+
error_handlers = current_app.config.get("VCS_ERROR_HANDLERS") or []
65+
return [
66+
(obj_or_import_string(error_cls), obj_or_import_string(handler))
67+
for error_cls, handler in error_handlers
68+
]
69+
70+
def init_app(self, app):
71+
"""Flask application initialization."""
72+
self.init_config(app)
73+
app.extensions["invenio-vcs"] = self
74+
75+
def init_config(self, app):
76+
"""Initialize configuration."""
77+
app.config.setdefault(
78+
"VCS_SETTINGS_TEMPLATE",
79+
app.config.get("SETTINGS_TEMPLATE", "invenio_vcs/settings/base.html"),
80+
)
81+
82+
for k in dir(config):
83+
if k.startswith("VCS_"):
84+
app.config.setdefault(k, getattr(config, k))
85+
86+
87+
def finalize_app_ui(app):
88+
"""Finalize app."""
89+
if app.config.get("VCS_INTEGRATION_ENABLED", False):
90+
init_menu(app)
91+
init_webhooks(app)
92+
93+
94+
def finalize_app_api(app):
95+
"""Finalize app."""
96+
if app.config.get("VCS_INTEGRATION_ENABLED", False):
97+
init_webhooks(app)
98+
99+
100+
def init_menu(app):
101+
"""Init menu."""
102+
for provider in get_provider_list(app):
103+
104+
def is_active(current_node):
105+
return (
106+
request.endpoint.startswith("invenio_vcs.")
107+
and request.view_args.get("provider", "") == current_node.name
108+
)
109+
110+
current_menu.submenu(f"settings.{provider.id}").register(
111+
endpoint="invenio_vcs.get_repositories",
112+
endpoint_arguments_constructor=lambda id=provider.id: {"provider": id},
113+
text=_(
114+
"%(icon)s %(provider)s",
115+
icon=LazyString(
116+
lambda: f'<i class="{current_theme_icons[provider.icon]}"></i>'
117+
),
118+
provider=provider.name,
119+
),
120+
order=10,
121+
active_when=is_active,
122+
)
123+
124+
125+
def init_webhooks(app):
126+
state = app.extensions.get("invenio-webhooks")
127+
if state is not None:
128+
for provider in get_provider_list(app):
129+
# Procedurally register the webhook receivers instead of including them as an entry point, since
130+
# they are defined in the VCS provider config list rather than in the instance's setup.cfg file.
131+
if provider.id not in state.receivers:
132+
state.register(provider.id, VCSReceiver)

0 commit comments

Comments
 (0)