diff --git a/MANIFEST.in b/MANIFEST.in index ea19ad89..51d20eaf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,14 +1,15 @@ include pytest.ini include README.md include requirements*.txt include tox.ini include version.txt recursive-include swh py.typed recursive-include assets * -recursive-include swh/web/templates * +recursive-include swh/web/*/templates * +recursive-include swh/web/*/assets * recursive-include swh/web/tests/resources * recursive-include swh/web/tests/inbound_email/resources *.eml include package.json include yarn.lock diff --git a/assets/config/webpack.config.development.js b/assets/config/webpack.config.development.js index 761de649..057d7f43 100644 --- a/assets/config/webpack.config.development.js +++ b/assets/config/webpack.config.development.js @@ -1,471 +1,488 @@ /** - * Copyright (C) 2018-2021 The Software Heritage developers + * Copyright (C) 2018-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ // webpack configuration for compiling static assets in development mode // import required node modules and webpack plugins const chalk = require('chalk'); const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); const BundleTracker = require('webpack-bundle-tracker'); const RobotstxtPlugin = require('robotstxt-webpack-plugin'); const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const FixSwhSourceMapsPlugin = require('./webpack-plugins/fix-swh-source-maps-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const GenerateWebLabelsPlugin = require('./webpack-plugins/generate-weblabels-webpack-plugin'); const ProgressBarPlugin = require('progress-bar-webpack-plugin'); const DumpHighlightjsLanguagesDataPlugin = require('./webpack-plugins/dump-highlightjs-languages-data-plugin'); const ESLintPlugin = require('eslint-webpack-plugin'); // are we running webpack-dev-server ? const isDevServer = process.argv.find(v => v.includes('serve')) !== undefined; // webpack-dev-server configuration const devServerPort = 3000; const devServerPublicPath = 'http://localhost:' + devServerPort + '/static/'; // set publicPath according if we are using webpack-dev-server to serve // our assets or not const publicPath = isDevServer ? devServerPublicPath : '/static/'; const nodeModules = path.resolve(__dirname, '../../node_modules/'); -// collect all bundles we want to produce with webpack +// collect all bundles we want to produce with webpack, +// bundles will be generated by scanning swh-web django applications: +// * if swh/web//assets/index.js exists, bundle is generated +// * if swh/web//assets//index.js exists, bundle is generated var bundles = {}; -const bundlesDir = path.join(__dirname, '../src/bundles'); -fs.readdirSync(bundlesDir).forEach(file => { - bundles[file] = ['bundles/' + file + '/index.js']; +const appsDir = path.join(__dirname, '../../swh/web'); +fs.readdirSync(appsDir).forEach(app => { + const appAssetsDir = path.join(appsDir, app, 'assets'); + if (fs.existsSync(appAssetsDir)) { + const appAssetsIndex = path.join(appAssetsDir, 'index.js'); + if (fs.existsSync(appAssetsIndex)) { + bundles[app] = [path.join(app, 'assets', 'index.js')]; + } else { + fs.readdirSync(appAssetsDir).forEach(appBundle => { + const appBundleIndex = path.join(appAssetsDir, appBundle, 'index.js'); + if (fs.existsSync(appBundleIndex)) { + bundles[appBundle] = [path.join(app, 'assets', appBundle, 'index.js')]; + } + }); + } + } }); // common loaders for css related assets (css, sass) const cssLoaders = [ MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { sourceMap: !isDevServer } }, { loader: 'postcss-loader', options: { sourceMap: !isDevServer, postcssOptions: { plugins: [ // lint swh-web stylesheets ['stylelint', { 'config': { 'extends': 'stylelint-config-standard', 'rules': { 'indentation': 4, 'font-family-no-missing-generic-family-keyword': null, 'no-descending-specificity': null, 'selector-class-pattern': null }, 'ignoreFiles': ['node_modules/**/*.css', 'assets/src/thirdparty/**/*.css'] } }], // automatically add vendor prefixes to css rules 'autoprefixer', 'postcss-normalize', ['postcss-reporter', { clearReportedMessages: true }] ] } } } ]; // webpack development configuration module.exports = { // use caching to speedup incremental builds cache: { type: 'filesystem' }, // set mode to development mode: 'development', // workaround for https://github.com/webpack/webpack-dev-server/issues/2758 target: process.env.NODE_ENV === 'development' ? 'web' : 'browserslist', // use eval source maps when using webpack-dev-server for quick debugging, // otherwise generate source map files (more expensive) devtool: isDevServer ? 'eval' : 'source-map', // webpack-dev-server configuration devServer: { client: { logging: 'warn', overlay: { warnings: true, errors: true }, progress: true }, devMiddleware: { publicPath: devServerPublicPath, stats: 'errors-only' }, host: '0.0.0.0', port: devServerPort, // enable to serve static assets not managed by webpack static: { directory: path.resolve('./'), watch: false }, // we do not use hot reloading here (as a framework like React needs to be used in order to fully benefit from that feature) // and prefer to fully reload the frontend application in the browser instead hot: false, historyApiFallback: true, headers: { 'Access-Control-Allow-Origin': '*' }, watchFiles: { paths: ['assets/**/*', 'static/**/*', 'swh/web/**/*'], options: { - ignored: /.*.sqlite3/ + ignored: /.*.sqlite3.*/ } } }, // set entries to the bundles we want to produce entry: bundles, // assets output configuration output: { path: path.resolve('./static/'), filename: 'js/[name].[contenthash].js', chunkFilename: 'js/[name].[contenthash].js', publicPath: publicPath, // each bundle will be compiled as a umd module with its own namespace // in order to easily use them in django templates library: ['swh', '[name]'], libraryTarget: 'umd' }, // module resolving configuration resolve: { // alias pdfjs to its minified version alias: { 'pdfjs-dist': 'pdfjs-dist/build/pdf.min.js' }, // configure base paths for resolving modules with webpack modules: [ 'node_modules', - path.resolve(__dirname, '../src') + path.resolve(__dirname, '../src'), + path.resolve(__dirname, '../../swh/web') ] }, stats: 'errors-warnings', snapshot: { // fix webpack warning related to missing package.json file managedPaths: [/^highlightjs-/] }, // module import configuration module: { rules: [ { // Use babel-loader in order to use es6 syntax in js files // but also advanced js features like async/await syntax. // All code get transpiled to es5 in order to be executed // in a large majority of browsers. test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'babel-loader', options: { presets: [ // use env babel presets to benefit from es6 syntax ['@babel/preset-env', { // Do not transform es6 module syntax to another module type // in order to benefit from dead code elimination (aka tree shaking) // when running webpack in production mode 'loose': true, 'modules': false }] ], plugins: [ // use babel transform-runtime plugin in order to use aync/await syntax ['@babel/plugin-transform-runtime', { 'regenerator': true }], // use other babel plugins to benefit from advanced js features (es2017) '@babel/plugin-syntax-dynamic-import' ], env: { test: { plugins: ['istanbul'] } } } }] }, { test: /\.ejs$/, use: [{ loader: 'ejs-compiled-loader', options: { htmlmin: true, htmlminOptions: { removeComments: true } } }] }, // expose jquery to the global context as $ and jQuery when importing it { test: require.resolve('jquery'), use: [{ loader: 'expose-loader', options: { exposes: [ { globalName: '$', override: true }, { globalName: 'jQuery', override: true } ] } }] }, // expose highlightjs to the global context as hljs when importing it { test: require.resolve('highlight.js'), use: [{ loader: 'expose-loader', options: { exposes: { globalName: 'hljs', override: true } } }] }, // css import configuration: // - first process it with postcss // - then extract it to a dedicated file associated to each bundle { test: /\.css$/, use: cssLoaders }, // sass import configuration: // - generate css with sass-loader // - process it with postcss // - then extract it to a dedicated file associated to each bundle { test: /\.scss$/, use: cssLoaders.concat([ { loader: 'sass-loader', options: { sourceMap: !isDevServer } } ]) }, // web fonts import configuration { test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext][query]' } }, { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext][query]' } }, { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext][query]' } }, { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext][query]' } }, { test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, type: 'asset/resource', generator: { filename: 'fonts/[name][ext][query]' } }, { test: /\.png$/, type: 'asset/resource', generator: { filename: 'img/thirdParty/[name][ext][query]' } }, { test: /\.ya?ml$/, type: 'json', use: { loader: 'yaml-loader', options: { asJSON: true } } } ], // tell webpack to not parse already minified files to speedup build process noParse: [path.resolve(nodeModules, 'pdfjs-dist/build/pdf.min.js'), path.resolve(nodeModules, 'mathjax/es5/tex-mml-chtml.js')] }, // webpack plugins plugins: [ // cleanup previously generated assets new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: ['**/*', '!xml', '!xml/*', '!img', '!img/*', '!img/logos', '!img/logos/*', '!img/icons', '!img/icons/*', '!json', '!json/*'] }), // needed in order to use django_webpack_loader new BundleTracker({ filename: './static/webpack-stats.json' }), // for generating the robots.txt file new RobotstxtPlugin({ policy: [{ userAgent: '*', disallow: '/api/' }] }), // for extracting all stylesheets in separate css files new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash].css', chunkFilename: 'css/[name].[contenthash].css' }), // fix generated asset sourcemaps to workaround a Firefox issue new FixSwhSourceMapsPlugin(), // define some global variables accessible from js code new webpack.DefinePlugin({ __STATIC__: JSON.stringify(publicPath) }), // needed in order to use bootstrap 4.x new webpack.ProvidePlugin({ Popper: ['popper.js', 'default'], Alert: 'exports-loader?Alert!bootstrap/js/dist/alert', Button: 'exports-loader?Button!bootstrap/js/dist/button', Carousel: 'exports-loader?Carousel!bootstrap/js/dist/carousel', Collapse: 'exports-loader?Collapse!bootstrap/js/dist/collapse', Dropdown: 'exports-loader?Dropdown!bootstrap/js/dist/dropdown', Modal: 'exports-loader?Modal!bootstrap/js/dist/modal', Popover: 'exports-loader?Popover!bootstrap/js/dist/popover', Scrollspy: 'exports-loader?Scrollspy!bootstrap/js/dist/scrollspy', Tab: 'exports-loader?Tab!bootstrap/js/dist/tab', Tooltip: 'exports-loader?Tooltip!bootstrap/js/dist/tooltip', Util: 'exports-loader?Util!bootstrap/js/dist/util' }), // needed in order to use pdf.js new webpack.IgnorePlugin({resourceRegExp: /^\.\/pdf.worker.js$/}), new CopyWebpackPlugin({ patterns: [ { from: path.resolve(nodeModules, 'pdfjs-dist/build/pdf.worker.min.js'), to: path.resolve(__dirname, '../../static/js/') }, { from: path.resolve(nodeModules, 'mathjax/es5/output/chtml/fonts/woff-v2/**'), to: path.resolve(__dirname, '../../static/fonts/[name][ext]') } ] }), new GenerateWebLabelsPlugin({ outputType: 'json', exclude: ['mini-css-extract-plugin', 'bootstrap-loader'], srcReplace: { './node_modules/pdfjs-dist/build/pdf.min.js': './node_modules/pdfjs-dist/build/pdf.js', './node_modules/admin-lte/dist/js/adminlte.min.js': './node_modules/admin-lte/dist/js/adminlte.js' }, licenseOverride: { './assets/src/thirdparty/jquery.tabSlideOut/jquery.tabSlideOut.js': { 'spdxLicenseExpression': 'GPL-3.0', 'licenseFilePath': './assets/src/thirdparty/jquery.tabSlideOut/LICENSE' }, './node_modules/highlightjs-chapel/dist/chapel.min.js': { 'spdxLicenseExpression': 'BSD-3-Clause', 'licenseFilePath': './node_modules/highlightjs-chapel/LICENSE' }, './node_modules/highlightjs-mirc/mirc.js': { 'spdxLicenseExpression': 'MIT', 'licenseFilePath': './node_modules/highlightjs-mirc/LICENSE' }, './node_modules/highlightjs-never/dist/never.min.js': { 'spdxLicenseExpression': 'MIT', 'licenseFilePath': './node_modules/highlightjs-never/LICENSE' } }, additionalScripts: Object.assign( { 'js/pdf.worker.min.js': [ { 'id': 'pdfjs-dist/build/pdf.worker.js', 'path': './node_modules/pdfjs-dist/build/pdf.worker.js', 'spdxLicenseExpression': 'Apache-2.0', 'licenseFilePath': './node_modules/pdfjs-dist/LICENSE' } ], '/jsreverse/': [ { 'id': 'jsreverse', 'path': '/jsreverse/', 'spdxLicenseExpression': 'AGPL-3.0-or-later', 'licenseFilePath': './LICENSE' } ], 'https://piwik.inria.fr/matomo.js': [ { 'id': 'matomo.js', 'path': 'https://github.com/matomo-org/matomo/blob/master/js/piwik.js', 'spdxLicenseExpression': 'BSD-3-Clause', 'licenseFilePath': 'https://github.com/matomo-org/matomo/blob/master/js/LICENSE.txt' } ] } ) }), new ProgressBarPlugin({ format: chalk.cyan.bold('webpack build of swh-web assets') + ' [:bar] ' + chalk.green.bold(':percent') + ' (:elapsed seconds)', width: 50 }), new DumpHighlightjsLanguagesDataPlugin(), // Process all js files with eslint for consistent code style // and avoid bad js development practices. new ESLintPlugin({ overrideConfigFile: path.join(__dirname, '.eslintrc'), ignorePath: path.join(__dirname, '.eslintignore'), cache: true, emitWarning: true }) ], // webpack optimizations optimization: { // ensure the vendors bundle gets emitted in a single chunk splitChunks: { cacheGroups: { defaultVendors: { test: 'vendors', chunks: 'all', name: 'vendors', enforce: true } } } }, // disable webpack warnings about bundle sizes performance: { hints: false } }; diff --git a/assets/src/bundles/add_forge/add-request-history-item.ejs b/assets/src/bundles/add_forge/add-request-history-item.ejs deleted file mode 100644 index 90eeee6d..00000000 --- a/assets/src/bundles/add_forge/add-request-history-item.ejs +++ /dev/null @@ -1,36 +0,0 @@ -<%# - Copyright (C) 2022 The Software Heritage developers - See the AUTHORS file at the top-level directory of this distribution - License: GNU Affero General Public License version 3, or any later version - See top-level LICENSE file for more information -%> -
-
-
-

- -

-
-
-
- <%if (event.text) { %> -
<%= event.text %>
- <% } %> - <%if (event.message_source_url !== null) { %> -

Open original message in email client

- <% } %> - <%if (event.new_status !== null) { %> -

- Status changed to: <%= swh.add_forge.formatRequestStatusName(event.new_status) %> -

- <% } %> -
-
-
-
diff --git a/assets/src/bundles/save/artifact-form-row.ejs b/assets/src/bundles/save/artifact-form-row.ejs deleted file mode 100644 index f9d426eb..00000000 --- a/assets/src/bundles/save/artifact-form-row.ejs +++ /dev/null @@ -1,34 +0,0 @@ -<%# - Copyright (C) 2021 The Software Heritage developers - See the AUTHORS file at the top-level directory of this distribution - License: GNU Affero General Public License version 3, or any later version - See top-level LICENSE file for more information -%> - -
-
- - -
The artifact url is mandatory
-
-
- - -
The artifact version is mandatory
-
-
- <% if (deletableRow) { %> - - - <% } else { %> - - - <% } %> -
-
\ No newline at end of file diff --git a/cypress/e2e/origin-save.cy.js b/cypress/e2e/origin-save.cy.js index 396352ec..5728ca5e 100644 --- a/cypress/e2e/origin-save.cy.js +++ b/cypress/e2e/origin-save.cy.js @@ -1,882 +1,882 @@ /** * Copyright (C) 2019-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ let url; const origin = { type: 'git', url: 'https://git.example.org/user/repo' }; const $ = Cypress.$; const saveCodeMsg = { 'success': 'The "save code now" request has been accepted and will be processed as soon as possible.', 'warning': 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', 'rejected': 'The "save code now" request has been rejected because the provided origin url is blacklisted.', 'rateLimit': 'The rate limit for "save code now" requests has been reached. Please try again later.', 'not-found': 'The provided url does not exist', 'unknownError': 'An unexpected error happened when submitting the "save code now request', 'csrfError': 'CSRF Failed: Referrer checking failed - no Referrer.' }; const anonymousVisitTypes = ['bzr', 'cvs', 'git', 'hg', 'svn']; const allVisitTypes = ['archives', 'bzr', 'cvs', 'git', 'hg', 'svn']; function makeOriginSaveRequest(originType, originUrl) { cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select(originType) .get('#swh-save-origin-form') .submit(); } function checkAlertVisible(alertType, msg) { cy.get('#swh-origin-save-request-status') .should('be.visible') .find(`.alert-${alertType}`) .should('be.visible') .and('contain', msg); } // Stub requests to save an origin function stubSaveRequest({ requestUrl, visitType = 'git', saveRequestStatus, originUrl, saveTaskStatus, responseStatus = 200, // For error code with the error message in the 'reason' key response errorMessage = '', saveRequestDate = new Date(), visitDate = new Date(), visitStatus = null } = {}) { let response; if (responseStatus !== 200 && errorMessage) { response = { 'reason': errorMessage }; } else { response = genOriginSaveResponse({visitType: visitType, saveRequestStatus: saveRequestStatus, originUrl: originUrl, saveRequestDate: saveRequestDate, saveTaskStatus: saveTaskStatus, visitDate: visitDate, visitStatus: visitStatus }); } cy.intercept('POST', requestUrl, {body: response, statusCode: responseStatus}) .as('saveRequest'); } // Mocks API response : /save/(:visit_type)/(:origin_url) // visit_type : {'git', 'hg', 'svn', ...} function genOriginSaveResponse({ visitType = 'git', saveRequestStatus, originUrl, saveRequestDate = new Date(), saveTaskStatus, visitDate = new Date(), visitStatus } = {}) { return { 'visit_type': visitType, 'save_request_status': saveRequestStatus, 'origin_url': originUrl, 'id': 1, 'save_request_date': saveRequestDate ? saveRequestDate.toISOString() : null, 'save_task_status': saveTaskStatus, 'visit_date': visitDate ? visitDate.toISOString() : null, 'visit_status': visitStatus }; }; function loadSaveRequestsListPage() { // click on tab to visit requests list page cy.get('#swh-origin-save-requests-list-tab').click(); // two XHR requests are sent by datatables when initializing requests table cy.wait(['@saveRequestsList', '@saveRequestsList']); // ensure datatable got rendered cy.wait(100); } describe('Origin Save Tests', function() { before(function() { url = this.Urls.origin_save(); this.originSaveUrl = this.Urls.api_1_save_origin(origin.type, origin.url); }); beforeEach(function() { cy.fixture('origin-save').as('originSaveJSON'); cy.fixture('save-task-info').as('saveTaskInfoJSON'); cy.visit(url); }); it('should format appropriately values depending on their type', function() { const inputValues = [ // null values stay null {type: 'json', value: null, expectedValue: null}, {type: 'date', value: null, expectedValue: null}, {type: 'raw', value: null, expectedValue: null}, {type: 'duration', value: null, expectedValue: null}, // non null values formatted depending on their type {type: 'json', value: '{}', expectedValue: '"{}"'}, {type: 'date', value: '04/04/2021 01:00:00', expectedValue: '4/4/2021, 1:00:00 AM'}, {type: 'raw', value: 'value-for-identity', expectedValue: 'value-for-identity'}, {type: 'duration', value: '10', expectedValue: '10 seconds'}, {type: 'duration', value: 100, expectedValue: '100 seconds'} ]; cy.window().then(win => { inputValues.forEach(function(input, index, array) { - const actualValue = win.swh.save.formatValuePerType(input.type, input.value); + const actualValue = win.swh.save_code_now.formatValuePerType(input.type, input.value); assert.equal(actualValue, input.expectedValue); }); }); }); it('should display accepted message when accepted', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate gitlab subproject url', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user/project/sub/'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate project url with _ in username', function() { const gitlabSubProjectUrl = 'https://gitlab.com/user_name/project.git'; const originSaveUrl = this.Urls.api_1_save_origin('git', gitlabSubProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: gitlabSubProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', gitlabSubProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should validate git repo url starting with https://git.code.sf.net/u/', function() { const sfUserGirProjectUrl = 'https://git.code.sf.net/u/username/project.git'; const originSaveUrl = this.Urls.api_1_save_origin('git', sfUserGirProjectUrl); stubSaveRequest({requestUrl: originSaveUrl, saveRequestStatus: 'accepted', originurl: sfUserGirProjectUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', sfUserGirProjectUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should display warning message when pending', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'pending', originUrl: origin.url, saveTaskStatus: 'not created'}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('warning', saveCodeMsg['warning']); }); }); it('should show error when the origin does not exist (status: 400)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, originUrl: origin.url, responseStatus: 400, errorMessage: saveCodeMsg['not-found']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['not-found']); }); }); it('should show error when csrf validation failed (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['csrfError']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['csrfError']); }); }); it('should show error when origin is rejected (status: 403)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'rejected', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 403, errorMessage: saveCodeMsg['rejected']}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rejected']); }); }); it('should show error when rate limited (status: 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Request was throttled. Expected available in 60 seconds.', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 429}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['rateLimit']); }); }); it('should show error when unknown error occurs (status other than 200, 403, 429)', function() { stubSaveRequest({requestUrl: this.originSaveUrl, saveRequestStatus: 'Error', originUrl: origin.url, saveTaskStatus: 'not created', responseStatus: 406}); makeOriginSaveRequest(origin.type, origin.url); cy.wait('@saveRequest').then(() => { checkAlertVisible('danger', saveCodeMsg['unknownError']); }); }); it('should display origin save info in the requests table', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}) .as('saveRequestsList'); loadSaveRequestsListPage(); cy.get('tbody tr').then(rows => { let i = 0; for (const row of rows) { const cells = row.cells; const requestDateStr = new Date(this.originSaveJSON.data[i].save_request_date).toLocaleString(); const saveStatus = this.originSaveJSON.data[i].save_task_status; assert.equal($(cells[0]).text(), requestDateStr); assert.equal($(cells[1]).text(), this.originSaveJSON.data[i].visit_type); let html = ''; if (saveStatus === 'succeeded') { let browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(this.originSaveJSON.data[i].origin_url)}`; browseOriginUrl += `&timestamp=${encodeURIComponent(this.originSaveJSON.data[i].visit_date)}`; html += `${this.originSaveJSON.data[i].origin_url}`; } else { html += this.originSaveJSON.data[i].origin_url; } html += ` `; html += ''; assert.equal($(cells[2]).html(), html); assert.equal($(cells[3]).text(), this.originSaveJSON.data[i].save_request_status); assert.equal($(cells[4]).text(), saveStatus); ++i; } }); }); it('should not add timestamp to the browse origin URL is no visit date has been found', function() { const originUrl = 'https://git.example.org/example.git'; const saveRequestData = genOriginSaveResponse({ saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'succeeded', visitDate: null, visitStatus: 'full' }); const saveRequestsListData = { 'recordsTotal': 1, 'draw': 2, 'recordsFiltered': 1, 'data': [saveRequestData] }; cy.intercept('/save/requests/list/**', {body: saveRequestsListData}) .as('saveRequestsList'); loadSaveRequestsListPage(); cy.get('tbody tr').then(rows => { const firstRowCells = rows[0].cells; const browseOriginUrl = `${this.Urls.browse_origin()}?origin_url=${encodeURIComponent(originUrl)}`; const browseOriginLink = `${originUrl}`; expect($(firstRowCells[2]).html()).to.have.string(browseOriginLink); }); }); it('should not add link to browse an origin when there is no visit status', function() { const originUrl = 'https://git.example.org/example.git'; const saveRequestData = genOriginSaveResponse({ saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'succeeded', visitDate: null, visitStatus: null }); const saveRequestsListData = { 'recordsTotal': 1, 'draw': 2, 'recordsFiltered': 1, 'data': [saveRequestData] }; cy.intercept('/save/requests/list/**', {body: saveRequestsListData}) .as('saveRequestsList'); loadSaveRequestsListPage(); cy.get('tbody tr').then(rows => { const firstRowCells = rows[0].cells; const tooltip = 'origin was successfully loaded, waiting for data to be available in database'; const expectedContent = `${originUrl}`; expect($(firstRowCells[2]).html()).to.have.string(expectedContent); }); }); it('should display/close task info popover when clicking on the info button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}) .as('saveRequestsList'); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}) .as('saveTaskInfo'); loadSaveRequestsListPage(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.wait('@saveTaskInfo'); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info') .eq(0) .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should hide task info popover when clicking on the close button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}) .as('saveRequestsList'); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}) .as('saveTaskInfo'); loadSaveRequestsListPage(); cy.get('.swh-save-request-info') .eq(0) .click(); cy.wait('@saveTaskInfo'); cy.get('.swh-save-request-info-popover') .should('be.visible'); cy.get('.swh-save-request-info-close') .click(); cy.get('.swh-save-request-info-popover') .should('not.exist'); }); it('should fill save request form when clicking on "Save again" button', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}) .as('saveRequestsList'); loadSaveRequestsListPage(); cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', $(cells[1]).text()); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); it('should select correct visit type if possible when clicking on "Save again" button', function() { const originUrl = 'https://gitlab.inria.fr/solverstack/maphys/maphys/'; const badVisitType = 'hg'; const goodVisitType = 'git'; cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}) .as('saveRequestsList'); stubSaveRequest({requestUrl: this.Urls.api_1_save_origin(badVisitType, originUrl), visitType: badVisitType, saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'failed', visitStatus: 'failed', responseStatus: 200, errorMessage: saveCodeMsg['accepted']}); makeOriginSaveRequest(badVisitType, originUrl); loadSaveRequestsListPage(); cy.wait('@saveRequest').then(() => { cy.get('.swh-save-origin-again') .eq(0) .click(); cy.get('tbody tr').eq(0).then(row => { const cells = row[0].cells; cy.get('#swh-input-visit-type') .should('have.value', goodVisitType); cy.get('#swh-input-origin-url') .should('have.value', $(cells[2]).text().slice(0, -1)); }); }); }); it('should create save request for authenticated user', function() { cy.userLogin(); cy.visit(url); const originUrl = 'https://git.example.org/account/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: origin.url, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should not show user requests filter checkbox for anonymous users', function() { cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('not.exist'); }); it('should show user requests filter checkbox for authenticated users', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); }); it('should show only user requests when filter is activated', function() { cy.intercept('POST', '/api/1/origin/save/**') .as('saveRequest'); cy.intercept(this.Urls.origin_save_requests_list('all') + '**') .as('saveRequestsList'); const originAnonymousUser = 'https://some.git.server/project/'; const originAuthUser = 'https://other.git.server/project/'; // anonymous user creates a save request makeOriginSaveRequest('git', originAnonymousUser); cy.wait('@saveRequest'); // authenticated user creates another save request cy.userLogin(); cy.visit(url); makeOriginSaveRequest('git', originAuthUser); cy.wait('@saveRequest'); // user requests filter checkbox should be in the DOM cy.get('#swh-origin-save-requests-list-tab').click(); cy.get('#swh-save-requests-user-filter').should('exist'); // check unfiltered user requests cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); expect($(rows[1].cells[2]).text()).to.contain(originAnonymousUser); }); // activate filter and check filtered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.wait('@saveRequestsList'); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(1); expect($(rows[0].cells[2]).text()).to.contain(originAuthUser); }); // deactivate filter and check unfiltered user requests cy.get('#swh-save-requests-user-filter') .click({force: true}); cy.wait('@saveRequestsList'); cy.get('tbody tr').then(rows => { expect(rows.length).to.eq(2); }); }); it('should list unprivileged visit types when not connected', function() { cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list unprivileged visit types when connected as unprivileged user', function() { cy.userLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(anonymousVisitTypes); }); }); it('should list privileged visit types when connected as ambassador', function() { cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-visit-type').children('option').then(options => { const actual = [...options].map(o => o.value); expect(actual).to.deep.eq(allVisitTypes); }); }); it('should display extra inputs when dealing with \'archives\' visit type', function() { cy.ambassadorLogin(); cy.visit(url); for (const visitType of anonymousVisitTypes) { cy.get('#swh-input-visit-type').select(visitType); cy.get('.swh-save-origin-archives-form').should('not.be.visible'); } // this should display more inputs with the 'archives' type cy.get('#swh-input-visit-type').select('archives'); cy.get('.swh-save-origin-archives-form').should('be.visible'); }); it('should be allowed to submit \'archives\' save request when connected as ambassador', function() { const originUrl = 'https://github.com/chromium/chromium/tags'; const artifactUrl = 'https://github.com/chromium/chromium/archive/refs/tags/104.0.5106.1.tar.gz'; const artifactVersion = '104.0.5106.1'; stubSaveRequest({ requestUrl: this.Urls.api_1_save_origin('archives', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled' }); cy.ambassadorLogin(); cy.visit(url); // input new 'archives' information and submit cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives') .get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion) .get('#swh-save-origin-form') .submit(); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should submit multiple artifacts for the archives visit type', function() { const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; const artifactVersion = '1.1.4'; const artifact2Url = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.5.tar.gz'; const artifact2Version = '1.1.5'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl) .get('#swh-input-artifact-version-0') .clear() .type(artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // check new row is displayed cy.get('#swh-input-artifact-url-1') .should('exist'); // request removal of newly added row cy.get('#swh-remove-archive-artifact-1') .click(); // check row has been removed cy.get('#swh-input-artifact-url-1') .should('not.exist'); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url) .get('#swh-input-artifact-version-1') .clear() .type(artifact2Version); // setup request interceptor to check POST data and stub response cy.intercept('POST', this.Urls.api_1_save_origin('archives', originUrl), (req) => { expect(req.body).to.deep.equal({ archives_data: [ {artifact_url: artifactUrl, artifact_version: artifactVersion}, {artifact_url: artifact2Url, artifact_version: artifact2Version} ] }); req.reply(genOriginSaveResponse({ visitType: 'archives', saveRequestStatus: 'accepted', originUrl: originUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should autofill artifact version when pasting artifact url', function() { const originUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf'; const artifactUrl = 'https://ftp.gnu.org/pub/pub/gnu/3dldf/3DLDF-1.1.4.tar.gz'; const artifactVersion = '3DLDF-1.1.4'; const artifact2Url = 'https://example.org/artifact/test/1.3.0.zip'; const artifact2Version = '1.3.0'; cy.ambassadorLogin(); cy.visit(url); cy.get('#swh-input-origin-url') .type(originUrl) .get('#swh-input-visit-type') .select('archives'); // fill first artifact info cy.get('#swh-input-artifact-url-0') .type(artifactUrl); // check autofilled version cy.get('#swh-input-artifact-version-0') .should('have.value', artifactVersion); // add new artifact form row cy.get('#swh-add-archive-artifact') .click(); // fill second artifact info cy.get('#swh-input-artifact-url-1') .type(artifact2Url); // check autofilled version cy.get('#swh-input-artifact-version-1') .should('have.value', artifact2Version); }); it('should use canonical URL for github repository to save', function() { const ownerRepo = 'BIC-MNI/mni_autoreg'; const canonicalOriginUrl = 'https://github.com/BIC-MNI/mni_autoreg'; // stub call to github Web API fetching canonical repo URL cy.intercept(`https://api.github.com/repos/${ownerRepo.toLowerCase()}`, (req) => { req.reply({html_url: canonicalOriginUrl}); }).as('ghWebApiRequest'); // stub save request creation with canonical URL of github repo cy.intercept('POST', this.Urls.api_1_save_origin('git', canonicalOriginUrl), (req) => { req.reply(genOriginSaveResponse({ visitType: 'git', saveRequestStatus: 'accepted', originUrl: canonicalOriginUrl, saveRequestDate: new Date(), saveTaskStatus: 'not yet scheduled', visitDate: null, visitStatus: null })); }).as('saveRequest'); for (const originUrl of ['https://github.com/BiC-MnI/MnI_AuToReG', 'https://github.com/BiC-MnI/MnI_AuToReG.git', 'https://github.com/BiC-MnI/MnI_AuToReG/', 'https://BiC-MnI.github.io/MnI_AuToReG/' ]) { // enter non canonical URL of github repo cy.get('#swh-input-origin-url') .clear() .type(originUrl); // submit form cy.get('#swh-save-origin-form') .submit(); // submission should be successful cy.wait('@ghWebApiRequest') .wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); } }); it('should switch tabs when playing with browser history', function() { cy.intercept('/save/requests/list/**', {fixture: 'origin-save'}); cy.intercept('/save/task/info/**', {fixture: 'save-task-info'}); cy.get('#swh-origin-save-request-help-tab') .should('have.class', 'active'); cy.get('#swh-origin-save-requests-list-tab') .click(); cy.get('#swh-origin-save-requests-list-tab') .should('have.class', 'active'); cy.go('back') .get('#swh-origin-save-request-help-tab') .should('have.class', 'active'); cy.go('forward') .get('#swh-origin-save-requests-list-tab') .should('have.class', 'active'); }); it('should not accept origin URL with password', function() { makeOriginSaveRequest('git', 'https://user:password@git.example.org/user/repo'); cy.get('.invalid-feedback') .should('contain', 'The origin url contains a password and cannot be accepted for security reasons'); }); it('should accept origin URL with username but without password', function() { cy.adminLogin(); cy.visit(url); const originUrl = 'https://user@git.example.org/user/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should accept origin URL with anonymous credentials', function() { cy.adminLogin(); cy.visit(url); const originUrl = 'https://anonymous:anonymous@git.example.org/user/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); it('should accept origin URL with empty password', function() { cy.adminLogin(); cy.visit(url); const originUrl = 'https://anonymous:@git.example.org/user/repo'; stubSaveRequest({requestUrl: this.Urls.api_1_save_origin('git', originUrl), saveRequestStatus: 'accepted', originUrl: originUrl, saveTaskStatus: 'not yet scheduled'}); makeOriginSaveRequest('git', originUrl); cy.wait('@saveRequest').then(() => { checkAlertVisible('success', saveCodeMsg['success']); }); }); }); diff --git a/assets/src/bundles/add_forge/add-forge.css b/swh/web/add_forge_now/assets/add-forge.css similarity index 100% rename from assets/src/bundles/add_forge/add-forge.css rename to swh/web/add_forge_now/assets/add-forge.css diff --git a/swh/web/add_forge_now/assets/add-request-history-item.ejs b/swh/web/add_forge_now/assets/add-request-history-item.ejs new file mode 100644 index 00000000..8987a9c3 --- /dev/null +++ b/swh/web/add_forge_now/assets/add-request-history-item.ejs @@ -0,0 +1,36 @@ +<%# + Copyright (C) 2022 The Software Heritage developers + See the AUTHORS file at the top-level directory of this distribution + License: GNU Affero General Public License version 3, or any later version + See top-level LICENSE file for more information +%> +
+
+
+

+ +

+
+
+
+ <%if (event.text) { %> +
<%= event.text %>
+ <% } %> + <%if (event.message_source_url !== null) { %> +

Open original message in email client

+ <% } %> + <%if (event.new_status !== null) { %> +

+ Status changed to: <%= swh.add_forge_now.formatRequestStatusName(event.new_status) %> +

+ <% } %> +
+
+
+
diff --git a/assets/src/bundles/add_forge/create-request.js b/swh/web/add_forge_now/assets/create-request.js similarity index 98% rename from assets/src/bundles/add_forge/create-request.js rename to swh/web/add_forge_now/assets/create-request.js index 796ad7a9..13734b30 100644 --- a/assets/src/bundles/add_forge/create-request.js +++ b/swh/web/add_forge_now/assets/create-request.js @@ -1,134 +1,134 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {swhSpinnerSrc} from 'utils/constants'; import { csrfPost, errorMessageFromResponse, genLink, getHumanReadableDate, handleFetchError, validateUrl } from 'utils/functions'; import userRequestsFilterCheckboxFn from 'utils/requests-filter-checkbox.ejs'; let requestBrowseTable; const addForgeCheckboxId = 'swh-add-forge-user-filter'; const userRequestsFilterCheckbox = userRequestsFilterCheckboxFn({ 'inputId': addForgeCheckboxId, 'checked': true // by default, display only user requests }); export function onCreateRequestPageLoad() { $(document).ready(() => { $('#requestCreateForm').submit(async function(event) { event.preventDefault(); try { const response = await csrfPost($(this).attr('action'), {'Content-Type': 'application/x-www-form-urlencoded'}, $(this).serialize()); handleFetchError(response); $('#userMessageDetail').empty(); $('#userMessage').text('Your request has been submitted'); $('#userMessage').removeClass('badge-danger'); $('#userMessage').addClass('badge-success'); requestBrowseTable.draw(); // redraw the table to update the list } catch (errorResponse) { $('#userMessageDetail').empty(); let errorMessage; const errorData = await errorResponse.json(); // if (errorResponse.content_type === 'text/plain') { // does not work? if (errorResponse.status === 409) { errorMessage = errorData; } else { // assuming json response // const exception = errorData['exception']; errorMessage = errorMessageFromResponse( errorData, 'An unknown error occurred during the request creation'); } $('#userMessage').text(errorMessage); $('#userMessage').removeClass('badge-success'); $('#userMessage').addClass('badge-danger'); } }); populateRequestBrowseList(); // Load existing requests }); } export function populateRequestBrowseList() { requestBrowseTable = $('#add-forge-request-browse') .on('error.dt', (e, settings, techNote, message) => { $('#add-forge-browse-request-error').text(message); }) .DataTable({ serverSide: true, processing: true, language: { processing: `` }, retrieve: true, searching: true, // Layout configuration, see [1] for more details // [1] https://datatables.net/reference/option/dom dom: '<"row"<"col-sm-3"l><"col-sm-6 text-left user-requests-filter"><"col-sm-3"f>>' + '<"row"<"col-sm-12"tr>>' + '<"row"<"col-sm-5"i><"col-sm-7"p>>', ajax: { 'url': Urls.add_forge_request_list_datatables(), data: (d) => { const checked = $(`#${addForgeCheckboxId}`).prop('checked'); // If this function is called while the page is loading, 'checked' is // undefined. As the checkbox defaults to being checked, coerce this to true. if (swh.webapp.isUserLoggedIn() && (checked === undefined || checked)) { d.user_requests_only = '1'; } } }, fnInitComplete: function() { if (swh.webapp.isUserLoggedIn()) { $('div.user-requests-filter').html(userRequestsFilterCheckbox); $(`#${addForgeCheckboxId}`).on('change', () => { requestBrowseTable.draw(); }); } }, columns: [ { data: 'submission_date', name: 'submission_date', render: getHumanReadableDate }, { data: 'forge_type', name: 'forge_type', render: $.fn.dataTable.render.text() }, { data: 'forge_url', name: 'forge_url', render: (data, type, row) => { const sanitizedURL = $.fn.dataTable.render.text().display(data); return genLink(sanitizedURL, type, true); } }, { data: 'status', name: 'status', render: function(data, type, row, meta) { - return swh.add_forge.formatRequestStatusName(data); + return swh.add_forge_now.formatRequestStatusName(data); } } ], order: [[0, 'desc']] }); } export function validateForgeUrl(input) { let customValidity = ''; if (!validateUrl(input.value.trim(), ['http', 'https'])) { customValidity = 'The provided forge URL is not valid.'; } input.setCustomValidity(customValidity); } diff --git a/assets/src/bundles/add_forge/forge-admin-email.ejs b/swh/web/add_forge_now/assets/forge-admin-email.ejs similarity index 100% rename from assets/src/bundles/add_forge/forge-admin-email.ejs rename to swh/web/add_forge_now/assets/forge-admin-email.ejs diff --git a/assets/src/bundles/add_forge/index.js b/swh/web/add_forge_now/assets/index.js similarity index 100% rename from assets/src/bundles/add_forge/index.js rename to swh/web/add_forge_now/assets/index.js diff --git a/assets/src/bundles/add_forge/moderation-dashboard.js b/swh/web/add_forge_now/assets/moderation-dashboard.js similarity index 96% rename from assets/src/bundles/add_forge/moderation-dashboard.js rename to swh/web/add_forge_now/assets/moderation-dashboard.js index 40aa3182..ac22ec43 100644 --- a/assets/src/bundles/add_forge/moderation-dashboard.js +++ b/swh/web/add_forge_now/assets/moderation-dashboard.js @@ -1,75 +1,75 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {genLink, getHumanReadableDate} from 'utils/functions'; export function onModerationPageLoad() { populateModerationList(); } export async function populateModerationList() { $('#swh-add-forge-now-moderation-list') .on('error.dt', (e, settings, techNote, message) => { $('#swh-add-forge-now-moderation-list-error').text(message); }) .DataTable({ serverSide: true, processing: true, searching: true, dom: '<<"d-flex justify-content-between align-items-center"f' + '<"#list-exclude">l>rt<"bottom"ip>>', ajax: { 'url': Urls.add_forge_request_list_datatables() }, columns: [ { data: 'id', name: 'id', render: function(data, type, row, meta) { const dashboardUrl = Urls.add_forge_now_request_dashboard(data); return `${data}`; } }, { data: 'submission_date', name: 'submission_date', render: getHumanReadableDate }, { data: 'forge_type', name: 'forge_type', render: $.fn.dataTable.render.text() }, { data: 'forge_url', name: 'forge_url', render: (data, type, row) => { const sanitizedURL = $.fn.dataTable.render.text().display(data); return genLink(sanitizedURL, type, true); } }, { data: 'last_moderator', name: 'last_moderator', render: $.fn.dataTable.render.text() }, { data: 'last_modified_date', name: 'last_modified_date', render: getHumanReadableDate }, { data: 'status', name: 'status', render: function(data, type, row, meta) { - return swh.add_forge.formatRequestStatusName(data); + return swh.add_forge_now.formatRequestStatusName(data); } } ], order: [[0, 'desc']] }); } diff --git a/assets/src/bundles/add_forge/request-dashboard.js b/swh/web/add_forge_now/assets/request-dashboard.js similarity index 96% rename from assets/src/bundles/add_forge/request-dashboard.js rename to swh/web/add_forge_now/assets/request-dashboard.js index 4839c649..5be0dfeb 100644 --- a/assets/src/bundles/add_forge/request-dashboard.js +++ b/swh/web/add_forge_now/assets/request-dashboard.js @@ -1,118 +1,118 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {csrfPost, getHumanReadableDate, handleFetchError} from 'utils/functions'; import requestHistoryItem from './add-request-history-item.ejs'; import emailTempate from './forge-admin-email.ejs'; let forgeRequest; export function onRequestDashboardLoad(requestId, nextStatusesFor) { $(document).ready(() => { populateRequestDetails(requestId, nextStatusesFor); $('#contactForgeAdmin').click((event) => { contactForgeAdmin(event); }); $('#updateRequestForm').submit(async function(event) { event.preventDefault(); try { const response = await csrfPost($(this).attr('action'), {'Content-Type': 'application/x-www-form-urlencoded'}, $(this).serialize()); handleFetchError(response); $('#userMessage').text('The request status has been updated '); $('#userMessage').removeClass('badge-danger'); $('#userMessage').addClass('badge-success'); populateRequestDetails(requestId, nextStatusesFor); } catch (response) { $('#userMessage').text('Sorry; Updating the request failed'); $('#userMessage').removeClass('badge-success'); $('#userMessage').addClass('badge-danger'); } }); }); } async function populateRequestDetails(requestId, nextStatusesFor) { try { const response = await fetch(Urls.api_1_add_forge_request_get(requestId)); handleFetchError(response); const data = await response.json(); forgeRequest = data.request; - $('#requestStatus').text(swh.add_forge.formatRequestStatusName(forgeRequest.status)); + $('#requestStatus').text(swh.add_forge_now.formatRequestStatusName(forgeRequest.status)); $('#requestType').text(forgeRequest.forge_type); $('#requestURL').text(forgeRequest.forge_url); $('#requestContactName').text(forgeRequest.forge_contact_name); $('#requestContactConsent').text(forgeRequest.submitter_forward_username); $('#requestContactEmail').text(forgeRequest.forge_contact_email); $('#submitterMessage').text(forgeRequest.forge_contact_comment); $('#updateComment').val(''); // Setting data for the email, now adding static data $('#contactForgeAdmin').attr('emailTo', forgeRequest.forge_contact_email); $('#contactForgeAdmin').attr('emailCc', forgeRequest.inbound_email_address); $('#contactForgeAdmin').attr('emailSubject', `Software Heritage archival notification for ${forgeRequest.forge_domain}`); populateRequestHistory(data.history); populateDecisionSelectOption(forgeRequest.status, nextStatusesFor); } catch (e) { if (e instanceof Response) { // The fetch request failed (in handleFetchError), show the error message $('#fetchError').removeClass('d-none'); $('#requestDetails').addClass('d-none'); } else { // Unknown exception, pass it through throw e; } } } function populateRequestHistory(history) { $('#requestHistory').children().remove(); history.forEach((event, index) => { const historyEvent = requestHistoryItem({ 'event': event, 'index': index, 'getHumanReadableDate': getHumanReadableDate }); $('#requestHistory').append(historyEvent); }); } export function populateDecisionSelectOption(currentStatus, nextStatusesFor) { // Determine the possible next status out of the current one const nextStatuses = nextStatusesFor[currentStatus]; function addStatusOption(status, index) { // Push the next possible status options - const label = swh.add_forge.formatRequestStatusName(status); + const label = swh.add_forge_now.formatRequestStatusName(status); $('#decisionOptions').append( `` ); } // Remove all the options and add new ones $('#decisionOptions').children().remove(); nextStatuses.forEach(addStatusOption); $('#decisionOptions').append( '' ); } function contactForgeAdmin(event) { // Open the mailclient with pre-filled text const mailTo = encodeURIComponent($('#contactForgeAdmin').attr('emailTo')); const mailCc = encodeURIComponent($('#contactForgeAdmin').attr('emailCc')); const subject = encodeURIComponent($('#contactForgeAdmin').attr('emailSubject')); const emailText = encodeURIComponent(emailTempate({'forgeUrl': forgeRequest.forge_url}).trim().replace(/\n/g, '\r\n')); const w = window.open('', '_blank', '', true); w.location.href = `mailto:${mailTo}?Cc=${mailCc}&Reply-To=${mailCc}&Subject=${subject}&body=${emailText}`; w.focus(); } diff --git a/swh/web/add_forge_now/templates/add-forge-common.html b/swh/web/add_forge_now/templates/add-forge-common.html index fed2359d..8fc2707f 100644 --- a/swh/web/add_forge_now/templates/add-forge-common.html +++ b/swh/web/add_forge_now/templates/add-forge-common.html @@ -1,72 +1,72 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load render_bundle from webpack_loader %} {% load static %} {% block header %} -{% render_bundle 'add_forge' %} +{% render_bundle 'add_forge_now' %} {% endblock %} {% block title %} Add forge now – Software Heritage archive {% endblock %} {% block navbar-content %}

Request the addition of a forge into the archive

{% endblock %} {% block content %}

“Add forge now” provides a service for Software Heritage users to save a complete forge in the Software Heritage archive by requesting the addition of the forge URL into the list of regularly visited forges.

{% if not user.is_authenticated %}

You can submit an “Add forge now” request only when you are authenticated, please login to submit the request.

{% endif %}
{% block tab_content %} {% endblock %}
{% endblock %} diff --git a/swh/web/add_forge_now/templates/add-forge-creation-form.html b/swh/web/add_forge_now/templates/add-forge-creation-form.html index 86e65b2c..42433b32 100644 --- a/swh/web/add_forge_now/templates/add-forge-creation-form.html +++ b/swh/web/add_forge_now/templates/add-forge-creation-form.html @@ -1,129 +1,129 @@ {% extends "./add-forge-common.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% block tab_content %}
{% if not user.is_authenticated %}

You must be logged in to submit an add forge request. Please log in

{% else %}
{% csrf_token %}
Supported forge types in software archive.
+ name="forge_url" oninput="swh.add_forge_now.validateForgeUrl(this)" required> Remote URL of the forge.
Name of the forge administrator.
Email of the forge administrator. The given email address will not be used for any purpose outside the “add forge now” process.
Optionally, leave a comment to the moderator regarding your request.

Once an add-forge-request is submitted, its status can be viewed in the submitted requests list. This process involves a moderator approval and might take a few days to handle (it primarily depends on the response time from the forge).

{% endif %}
{% endblock %} diff --git a/swh/web/add_forge_now/templates/add-forge-request-dashboard.html b/swh/web/add_forge_now/templates/add-forge-request-dashboard.html index 61987e38..97ced829 100644 --- a/swh/web/add_forge_now/templates/add-forge-request-dashboard.html +++ b/swh/web/add_forge_now/templates/add-forge-request-dashboard.html @@ -1,122 +1,122 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load render_bundle from webpack_loader %} {% load static %} {% load swh_templatetags %} {% block header %} -{% render_bundle 'add_forge' %} +{% render_bundle 'add_forge_now' %} {% endblock %} {% block title %}{{heading}} – Software Heritage archive{% endblock %} {% block navbar-content %}

Add forge now request dashboard

{% endblock %} {% block content %}

Error fetching information about the request

{% csrf_token %}
Enter a comment related to your decision.

Request status

Forge type

Forge URL

Contact name

Consent to use name

Contact email

Message


{% endblock %} diff --git a/swh/web/add_forge_now/templates/add-forge-requests-moderation.html b/swh/web/add_forge_now/templates/add-forge-requests-moderation.html index dcdbe7b1..588f153a 100644 --- a/swh/web/add_forge_now/templates/add-forge-requests-moderation.html +++ b/swh/web/add_forge_now/templates/add-forge-requests-moderation.html @@ -1,48 +1,48 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load render_bundle from webpack_loader %} {% load static %} {% block header %} -{% render_bundle 'add_forge' %} +{% render_bundle 'add_forge_now' %} {% endblock %} {% block title %}{{ heading }} – Software Heritage archive{% endblock %} {% block navbar-content %}

Add forge now moderation

{% endblock %} {% block content %}
ID Submission date Forge type Forge URL Moderator Name Last Modified Date Status

{% endblock %} diff --git a/assets/src/bundles/auth/auth.css b/swh/web/auth/assets/auth.css similarity index 100% rename from assets/src/bundles/auth/auth.css rename to swh/web/auth/assets/auth.css diff --git a/assets/src/bundles/auth/index.js b/swh/web/auth/assets/index.js similarity index 100% rename from assets/src/bundles/auth/index.js rename to swh/web/auth/assets/index.js diff --git a/assets/src/bundles/browse/breadcrumbs.css b/swh/web/browse/assets/browse/breadcrumbs.css similarity index 100% rename from assets/src/bundles/browse/breadcrumbs.css rename to swh/web/browse/assets/browse/breadcrumbs.css diff --git a/assets/src/bundles/browse/browse-utils.js b/swh/web/browse/assets/browse/browse-utils.js similarity index 100% rename from assets/src/bundles/browse/browse-utils.js rename to swh/web/browse/assets/browse/browse-utils.js diff --git a/assets/src/bundles/browse/browse.css b/swh/web/browse/assets/browse/browse.css similarity index 100% rename from assets/src/bundles/browse/browse.css rename to swh/web/browse/assets/browse/browse.css diff --git a/assets/src/bundles/browse/content.css b/swh/web/browse/assets/browse/content.css similarity index 100% rename from assets/src/bundles/browse/content.css rename to swh/web/browse/assets/browse/content.css diff --git a/assets/src/bundles/browse/iframe.js b/swh/web/browse/assets/browse/iframe.js similarity index 100% rename from assets/src/bundles/browse/iframe.js rename to swh/web/browse/assets/browse/iframe.js diff --git a/assets/src/bundles/browse/index.js b/swh/web/browse/assets/browse/index.js similarity index 100% rename from assets/src/bundles/browse/index.js rename to swh/web/browse/assets/browse/index.js diff --git a/assets/src/bundles/browse/origin-search.js b/swh/web/browse/assets/browse/origin-search.js similarity index 100% rename from assets/src/bundles/browse/origin-search.js rename to swh/web/browse/assets/browse/origin-search.js diff --git a/assets/src/bundles/browse/snapshot-navigation.css b/swh/web/browse/assets/browse/snapshot-navigation.css similarity index 100% rename from assets/src/bundles/browse/snapshot-navigation.css rename to swh/web/browse/assets/browse/snapshot-navigation.css diff --git a/assets/src/bundles/browse/snapshot-navigation.js b/swh/web/browse/assets/browse/snapshot-navigation.js similarity index 100% rename from assets/src/bundles/browse/snapshot-navigation.js rename to swh/web/browse/assets/browse/snapshot-navigation.js diff --git a/assets/src/bundles/browse/swhid-utils.js b/swh/web/browse/assets/browse/swhid-utils.js similarity index 100% rename from assets/src/bundles/browse/swhid-utils.js rename to swh/web/browse/assets/browse/swhid-utils.js diff --git a/assets/src/bundles/guided_tour/guided-tour-steps.yaml b/swh/web/browse/assets/guided_tour/guided-tour-steps.yaml similarity index 100% rename from assets/src/bundles/guided_tour/guided-tour-steps.yaml rename to swh/web/browse/assets/guided_tour/guided-tour-steps.yaml diff --git a/assets/src/bundles/guided_tour/index.js b/swh/web/browse/assets/guided_tour/index.js similarity index 100% rename from assets/src/bundles/guided_tour/index.js rename to swh/web/browse/assets/guided_tour/index.js diff --git a/assets/src/bundles/guided_tour/swh-introjs.css b/swh/web/browse/assets/guided_tour/swh-introjs.css similarity index 100% rename from assets/src/bundles/guided_tour/swh-introjs.css rename to swh/web/browse/assets/guided_tour/swh-introjs.css diff --git a/assets/src/bundles/origin/index.js b/swh/web/browse/assets/origin_visits/index.js similarity index 100% rename from assets/src/bundles/origin/index.js rename to swh/web/browse/assets/origin_visits/index.js diff --git a/assets/src/bundles/origin/utils.js b/swh/web/browse/assets/origin_visits/utils.js similarity index 100% rename from assets/src/bundles/origin/utils.js rename to swh/web/browse/assets/origin_visits/utils.js diff --git a/assets/src/bundles/origin/visits-calendar.js b/swh/web/browse/assets/origin_visits/visits-calendar.js similarity index 100% rename from assets/src/bundles/origin/visits-calendar.js rename to swh/web/browse/assets/origin_visits/visits-calendar.js diff --git a/assets/src/bundles/origin/visits-histogram.js b/swh/web/browse/assets/origin_visits/visits-histogram.js similarity index 100% rename from assets/src/bundles/origin/visits-histogram.js rename to swh/web/browse/assets/origin_visits/visits-histogram.js diff --git a/assets/src/bundles/origin/visits-reporting.css b/swh/web/browse/assets/origin_visits/visits-reporting.css similarity index 100% rename from assets/src/bundles/origin/visits-reporting.css rename to swh/web/browse/assets/origin_visits/visits-reporting.css diff --git a/assets/src/bundles/origin/visits-reporting.js b/swh/web/browse/assets/origin_visits/visits-reporting.js similarity index 100% rename from assets/src/bundles/origin/visits-reporting.js rename to swh/web/browse/assets/origin_visits/visits-reporting.js diff --git a/assets/src/bundles/revision/diff-panel.ejs b/swh/web/browse/assets/revision/diff-panel.ejs similarity index 100% rename from assets/src/bundles/revision/diff-panel.ejs rename to swh/web/browse/assets/revision/diff-panel.ejs diff --git a/assets/src/bundles/revision/diff-utils.js b/swh/web/browse/assets/revision/diff-utils.js similarity index 100% rename from assets/src/bundles/revision/diff-utils.js rename to swh/web/browse/assets/revision/diff-utils.js diff --git a/assets/src/bundles/revision/index.js b/swh/web/browse/assets/revision/index.js similarity index 100% rename from assets/src/bundles/revision/index.js rename to swh/web/browse/assets/revision/index.js diff --git a/assets/src/bundles/revision/log-utils.js b/swh/web/browse/assets/revision/log-utils.js similarity index 100% rename from assets/src/bundles/revision/log-utils.js rename to swh/web/browse/assets/revision/log-utils.js diff --git a/assets/src/bundles/revision/revision.css b/swh/web/browse/assets/revision/revision.css similarity index 100% rename from assets/src/bundles/revision/revision.css rename to swh/web/browse/assets/revision/revision.css diff --git a/swh/web/browse/templates/browse-origin-visits.html b/swh/web/browse/templates/browse-origin-visits.html index db38c84b..3870f70d 100644 --- a/swh/web/browse/templates/browse-origin-visits.html +++ b/swh/web/browse/templates/browse-origin-visits.html @@ -1,92 +1,92 @@ {% extends "./browse.html" %} {% comment %} Copyright (C) 2017-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load static %} {% load swh_templatetags %} {% load render_bundle from webpack_loader %} {% block header %} {{ block.super }} -{% render_bundle 'origin' %} +{% render_bundle 'origin_visits' %} {% endblock %} {% block swh-browse-content %}

Overview

  • Total number of visits: {{ origin_visits|length }}
  • Last full visit:
  • First full visit:
  • Last visit:

History

Timeline
Calendar
List
{% endblock %} diff --git a/swh/web/browse/templates/browse.html b/swh/web/browse/templates/browse.html index 442aff74..8bde2475 100644 --- a/swh/web/browse/templates/browse.html +++ b/swh/web/browse/templates/browse.html @@ -1,47 +1,47 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2017-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% load render_bundle from webpack_loader %} {% block title %}{{ heading }} – Software Heritage archive{% endblock %} {% block header %} {% render_bundle 'browse' %} {% render_bundle 'vault' %} -{% render_bundle 'save' %} +{% render_bundle 'save_code_now' %} {% endblock %} {% block navbar-content %}

Browse the archive

{% endblock %} {% block content %} {% block browse-content %} {% block swh-browse-before-content %} {% if snapshot_context %} {% include "./includes/snapshot-context.html" %} {% endif %} {% endblock %} {% block swh-browse-content %}{% endblock %} {% block swh-browse-after-content %}{% endblock %} {% endblock %} {% endblock %} diff --git a/assets/src/bundles/admin/deposit.js b/swh/web/deposit/assets/index.js similarity index 100% rename from assets/src/bundles/admin/deposit.js rename to swh/web/deposit/assets/index.js diff --git a/swh/web/deposit/templates/deposit-admin.html b/swh/web/deposit/templates/deposit-admin.html index ba776f41..b28d490f 100644 --- a/swh/web/deposit/templates/deposit-admin.html +++ b/swh/web/deposit/templates/deposit-admin.html @@ -1,63 +1,63 @@ {% extends "layout.html" %} {% comment %} -Copyright (C) 2018-2021 The Software Heritage developers +Copyright (C) 2018-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% load render_bundle from webpack_loader %} {% block header %} {{ block.super }} -{% render_bundle 'admin' %} +{% render_bundle 'deposit' %} {% endblock %} {% block title %} Deposit administration {% endblock %} {% block navbar-content %}

Deposit administration

{% endblock %} {% block content %}

The table below displays the list of software artifacts deposited to Software Heritage.


id type uri reception date status metadata status detail directory directory with context

{% endblock content %} diff --git a/assets/src/bundles/admin/mailmap.js b/swh/web/mailmap/assets/index.js similarity index 98% rename from assets/src/bundles/admin/mailmap.js rename to swh/web/mailmap/assets/index.js index 25365dcb..a9f0c773 100644 --- a/assets/src/bundles/admin/mailmap.js +++ b/swh/web/mailmap/assets/index.js @@ -1,159 +1,159 @@ /** * Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {csrfPost, handleFetchError} from 'utils/functions'; import mailmapFormTemplate from './mailmap-form.ejs'; let mailmapsTable; export function mailmapForm(buttonText, email = '', displayName = '', displayNameActivated = false, update = false) { return mailmapFormTemplate({ buttonText: buttonText, email: email, displayName: displayName, displayNameActivated: displayNameActivated, updateForm: update }); } function getMailmapDataFromForm() { return { 'from_email': $('#swh-mailmap-from-email').val(), 'display_name': $('#swh-mailmap-display-name').val(), 'display_name_activated': $('#swh-mailmap-display-name-activated').prop('checked') }; } function processMailmapForm(formTitle, formHtml, formApiUrl) { swh.webapp.showModalHtml(formTitle, formHtml); $(`#swh-mailmap-form`).on('submit', async event => { event.preventDefault(); event.stopPropagation(); const postData = getMailmapDataFromForm(); try { const response = await csrfPost( formApiUrl, {'Content-Type': 'application/json'}, JSON.stringify(postData) ); $('#swh-web-modal-html').modal('hide'); handleFetchError(response); mailmapsTable.draw(); } catch (response) { const error = await response.text(); swh.webapp.showModalMessage('Error', error); } }); } export function addNewMailmap() { const mailmapFormHtml = mailmapForm('Add mailmap'); processMailmapForm('Add new mailmap', mailmapFormHtml, Urls.profile_mailmap_add()); } export function updateMailmap(mailmapId) { let mailmapData; const rows = mailmapsTable.rows().data(); for (let i = 0; i < rows.length; ++i) { const row = rows[i]; if (row.id === mailmapId) { mailmapData = row; break; } } const mailmapFormHtml = mailmapForm('Update mailmap', mailmapData.from_email, mailmapData.display_name, mailmapData.display_name_activated, true); processMailmapForm('Update existing mailmap', mailmapFormHtml, Urls.profile_mailmap_update()); } const mdiCheckBold = ''; const mdiCloseThick = ''; export function initMailmapUI() { $(document).ready(() => { mailmapsTable = $('#swh-mailmaps-table') .on('error.dt', (e, settings, techNote, message) => { $('#swh-mailmaps-list-error').text( 'An error occurred while retrieving the mailmaps list'); console.log(message); }) .DataTable({ serverSide: true, ajax: Urls.profile_mailmap_list_datatables(), columns: [ { data: 'from_email', name: 'from_email', render: $.fn.dataTable.render.text() }, { data: 'from_email_verified', name: 'from_email_verified', render: (data, type, row) => { return data ? mdiCheckBold : mdiCloseThick; }, className: 'dt-center' }, { data: 'display_name', name: 'display_name', render: $.fn.dataTable.render.text() }, { data: 'display_name_activated', name: 'display_name_activated', render: (data, type, row) => { return data ? mdiCheckBold : mdiCloseThick; }, className: 'dt-center' }, { data: 'last_update_date', name: 'last_update_date', render: (data, type, row) => { if (type === 'display') { const date = new Date(data); return date.toLocaleString(); } return data; } }, { render: (data, type, row) => { const lastUpdateDate = new Date(row.last_update_date); const lastProcessingDate = new Date(row.mailmap_last_processing_date); if (!lastProcessingDate || lastProcessingDate < lastUpdateDate) { return mdiCloseThick; } else { return mdiCheckBold; } }, className: 'dt-center', orderable: false }, { render: (data, type, row) => { const html = ``; return html; }, orderable: false } ], ordering: true, searching: true, searchDelay: 1000, scrollY: '50vh', scrollCollapse: true }); }); } diff --git a/assets/src/bundles/admin/mailmap-form.ejs b/swh/web/mailmap/assets/mailmap-form.ejs similarity index 100% rename from assets/src/bundles/admin/mailmap-form.ejs rename to swh/web/mailmap/assets/mailmap-form.ejs diff --git a/swh/web/mailmap/templates/admin/mailmap.html b/swh/web/mailmap/templates/admin/mailmap.html index 3ac87e2e..9b59f170 100644 --- a/swh/web/mailmap/templates/admin/mailmap.html +++ b/swh/web/mailmap/templates/admin/mailmap.html @@ -1,54 +1,54 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% load render_bundle from webpack_loader %} {% block header %} {{ block.super }} -{% render_bundle 'admin' %} +{% render_bundle 'mailmap' %} {% endblock %} {% block title %} Mailmap administration – Software Heritage archive {% endblock %} {% block navbar-content %}

Mailmap administration

{% endblock %} {% block content %}

This interface enables to manage author display names in the archive based on their emails.

-
Email Verified Display name Activated Last update Effective

{% endblock content %} diff --git a/swh/web/save_code_now/assets/artifact-form-row.ejs b/swh/web/save_code_now/assets/artifact-form-row.ejs new file mode 100644 index 00000000..0e537675 --- /dev/null +++ b/swh/web/save_code_now/assets/artifact-form-row.ejs @@ -0,0 +1,31 @@ +<%# Copyright (C) 2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this + distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for + more information %> + +
+
+ + +
The artifact url is mandatory
+
+
+ + +
The artifact version is mandatory
+
+
+ <% if (deletableRow) { %> + + + <% } else { %> + + + <% } %> +
+
\ No newline at end of file diff --git a/assets/src/bundles/admin/index.js b/swh/web/save_code_now/assets/index.js similarity index 66% rename from assets/src/bundles/admin/index.js rename to swh/web/save_code_now/assets/index.js index 63f26cdc..ca40fadf 100644 --- a/assets/src/bundles/admin/index.js +++ b/swh/web/save_code_now/assets/index.js @@ -1,10 +1,8 @@ -/** - * Copyright (C) 2018-2022 The Software Heritage developers +/* Copyright (C) 2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ -export * from './deposit'; -export * from './mailmap'; export * from './origin-save'; +export * from './origin-save-admin'; diff --git a/assets/src/bundles/admin/origin-save.js b/swh/web/save_code_now/assets/origin-save-admin.js similarity index 98% rename from assets/src/bundles/admin/origin-save.js rename to swh/web/save_code_now/assets/origin-save-admin.js index 69d942df..567b18d8 100644 --- a/assets/src/bundles/admin/origin-save.js +++ b/swh/web/save_code_now/assets/origin-save-admin.js @@ -1,401 +1,400 @@ /** * Copyright (C) 2018-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ -import {handleFetchError, csrfPost, htmlAlert, - getHumanReadableDate} from 'utils/functions'; import {swhSpinnerSrc} from 'utils/constants'; +import {csrfPost, getHumanReadableDate, handleFetchError, htmlAlert} from 'utils/functions'; let authorizedOriginTable; let unauthorizedOriginTable; let pendingSaveRequestsTable; let acceptedSaveRequestsTable; let rejectedSaveRequestsTable; function enableRowSelection(tableSel) { $(`${tableSel} tbody`).on('click', 'tr', function() { if ($(this).hasClass('selected')) { $(this).removeClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', true); } else { $(`${tableSel} tr.selected`).removeClass('selected'); $(this).addClass('selected'); $(tableSel).closest('.tab-pane').find('.swh-action-need-selection').prop('disabled', false); } }); } export function initOriginSaveAdmin() { $(document).ready(() => { $.fn.dataTable.ext.errMode = 'throw'; authorizedOriginTable = $('#swh-authorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_authorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-authorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(authorizedOriginTable); unauthorizedOriginTable = $('#swh-unauthorized-origin-urls').DataTable({ serverSide: true, ajax: Urls.admin_origin_save_unauthorized_urls_list(), columns: [{data: 'url', name: 'url'}], scrollY: '50vh', scrollCollapse: true, info: false }); enableRowSelection('#swh-unauthorized-origin-urls'); swh.webapp.addJumpToPagePopoverToDataTable(unauthorizedOriginTable); const columnsData = [ { data: 'id', name: 'id', visible: false, searchable: false }, { data: 'save_request_date', name: 'request_date', render: getHumanReadableDate }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `${sanitizedURL}`; } else { html += sanitizedURL; } html += ` ` + ''; return html; } return data; } } ]; pendingSaveRequestsTable = $('#swh-origin-save-pending-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('pending'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-pending-requests'); swh.webapp.addJumpToPagePopoverToDataTable(pendingSaveRequestsTable); columnsData.push({ name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed' || row.note != null) { return ``; + onclick="swh.save_code_now.displaySaveRequestInfo(event, ${row.id})">`; } else { return ''; } } }); rejectedSaveRequestsTable = $('#swh-origin-save-rejected-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('rejected'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-rejected-requests'); swh.webapp.addJumpToPagePopoverToDataTable(rejectedSaveRequestsTable); columnsData.splice(columnsData.length - 1, 0, { data: 'save_task_status', name: 'save_task_status' }); acceptedSaveRequestsTable = $('#swh-origin-save-accepted-requests').DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: Urls.origin_save_requests_list('accepted'), searchDelay: 1000, columns: columnsData, scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); enableRowSelection('#swh-origin-save-accepted-requests'); swh.webapp.addJumpToPagePopoverToDataTable(acceptedSaveRequestsTable); $('#swh-authorized-origins-tab').on('shown.bs.tab', () => { authorizedOriginTable.draw(); }); $('#swh-unauthorized-origins-tab').on('shown.bs.tab', () => { unauthorizedOriginTable.draw(); }); $('#swh-save-requests-pending-tab').on('shown.bs.tab', () => { pendingSaveRequestsTable.draw(); }); $('#swh-save-requests-accepted-tab').on('shown.bs.tab', () => { acceptedSaveRequestsTable.draw(); }); $('#swh-save-requests-rejected-tab').on('shown.bs.tab', () => { rejectedSaveRequestsTable.draw(); }); $('#swh-save-requests-pending-tab').click(() => { pendingSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-accepted-tab').click(() => { acceptedSaveRequestsTable.ajax.reload(null, false); }); $('#swh-save-requests-rejected-tab').click(() => { rejectedSaveRequestsTable.ajax.reload(null, false); }); $('body').on('click', e => { if ($(e.target).parents('.popover').length > 0) { e.stopPropagation(); } else if ($(e.target).parents('.swh-save-request-info').length === 0) { $('.swh-save-request-info').popover('dispose'); } }); }); } export async function addAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_authorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); authorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-authorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the authorized list.', true) ); } catch (_) { $('.swh-add-authorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the authorized list.', true) ); } } export async function removeAuthorizedOriginUrl() { const originUrl = $('#swh-authorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_authorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); authorizedOriginTable.row('.selected').remove().draw(); } catch (_) {} } } export async function addUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-url-prefix').val(); const addOriginUrl = Urls.admin_origin_save_add_unauthorized_url(originUrl); try { const response = await csrfPost(addOriginUrl); handleFetchError(response); unauthorizedOriginTable.row.add({'url': originUrl}).draw(); $('.swh-add-unauthorized-origin-status').html( htmlAlert('success', 'The origin url prefix has been successfully added in the unauthorized list.', true) ); } catch (_) { $('.swh-add-unauthorized-origin-status').html( htmlAlert('warning', 'The provided origin url prefix is already registered in the unauthorized list.', true) ); } } export async function removeUnauthorizedOriginUrl() { const originUrl = $('#swh-unauthorized-origin-urls tr.selected').text(); if (originUrl) { const removeOriginUrl = Urls.admin_origin_save_remove_unauthorized_url(originUrl); try { const response = await csrfPost(removeOriginUrl); handleFetchError(response); unauthorizedOriginTable.row('.selected').remove().draw(); } catch (_) {}; } } export function acceptOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); if (selectedRow.length) { const acceptOriginSaveRequestCallback = async() => { const rowData = selectedRow.data(); const acceptSaveRequestUrl = Urls.admin_origin_save_request_accept(rowData['visit_type'], rowData['origin_url']); await csrfPost(acceptSaveRequestUrl); pendingSaveRequestsTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Accept origin save request ?', 'Are you sure to accept this origin save request ?', acceptOriginSaveRequestCallback); } } const rejectModalHtml = `
`; export function rejectOriginSaveRequest() { const selectedRow = pendingSaveRequestsTable.row('.selected'); const rowData = selectedRow.data(); if (selectedRow.length) { const rejectOriginSaveRequestCallback = async() => { $('#swh-web-modal-html').modal('hide'); const rejectSaveRequestUrl = Urls.admin_origin_save_request_reject( rowData['visit_type'], rowData['origin_url']); await csrfPost(rejectSaveRequestUrl, {}, JSON.stringify({note: $('#swh-rejection-text').val()})); pendingSaveRequestsTable.ajax.reload(null, false); }; let currentRejectionReason = 'custom'; const rejectionTexts = {}; swh.webapp.showModalHtml('Reject origin save request ?', rejectModalHtml); $('#swh-rejection-reason').on('change', (event) => { // backup current textarea value rejectionTexts[currentRejectionReason] = $('#swh-rejection-text').val(); currentRejectionReason = event.target.value; let newRejectionText = ''; if (rejectionTexts.hasOwnProperty(currentRejectionReason)) { // restore previous textarea value newRejectionText = rejectionTexts[currentRejectionReason]; } else { // fill textarea with default text according to rejection type if (currentRejectionReason === 'invalid-origin') { newRejectionText = `The origin with URL ${rowData['origin_url']} is not ` + `a link to a ${rowData['visit_type']} repository.`; } else if (currentRejectionReason === 'invalid-origin-type') { newRejectionText = `The origin with URL ${rowData['origin_url']} is not ` + `of type ${rowData['visit_type']}.`; } else if (currentRejectionReason === 'origin-not-found') { newRejectionText = `The origin with URL ${rowData['origin_url']} cannot be found.`; } } $('#swh-rejection-text').val(newRejectionText); }); $('#swh-rejection-form').on('submit', (event) => { event.preventDefault(); event.stopPropagation(); // ensure confirmation modal will be displayed above the html modal $('#swh-web-modal-html').css('z-index', 4000); swh.webapp.showModalConfirm( 'Reject origin save request ?', 'Are you sure to reject this origin save request ?', rejectOriginSaveRequestCallback); }); } } function removeOriginSaveRequest(requestTable) { const selectedRow = requestTable.row('.selected'); if (selectedRow.length) { const requestId = selectedRow.data()['id']; const removeOriginSaveRequestCallback = async() => { const removeSaveRequestUrl = Urls.admin_origin_save_request_remove(requestId); await csrfPost(removeSaveRequestUrl); requestTable.ajax.reload(null, false); }; swh.webapp.showModalConfirm( 'Remove origin save request ?', 'Are you sure to remove this origin save request ?', removeOriginSaveRequestCallback); } } export function removePendingOriginSaveRequest() { removeOriginSaveRequest(pendingSaveRequestsTable); } export function removeAcceptedOriginSaveRequest() { removeOriginSaveRequest(acceptedSaveRequestsTable); } export function removeRejectedOriginSaveRequest() { removeOriginSaveRequest(rejectedSaveRequestsTable); } diff --git a/assets/src/bundles/save/index.js b/swh/web/save_code_now/assets/origin-save.js similarity index 99% rename from assets/src/bundles/save/index.js rename to swh/web/save_code_now/assets/origin-save.js index a352eecb..ba64bc27 100644 --- a/assets/src/bundles/save/index.js +++ b/swh/web/save_code_now/assets/origin-save.js @@ -1,546 +1,546 @@ /** * Copyright (C) 2018-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {swhSpinnerSrc} from 'utils/constants'; import { csrfPost, getCanonicalOriginURL, getHumanReadableDate, handleFetchError, htmlAlert, isGitRepoUrl, validateUrl } from 'utils/functions'; import userRequestsFilterCheckboxFn from 'utils/requests-filter-checkbox.ejs'; import artifactFormRowTemplate from './artifact-form-row.ejs'; let saveRequestsTable; async function originSaveRequest( originType, originUrl, extraData, acceptedCallback, pendingCallback, errorCallback ) { // Actually trigger the origin save request const addSaveOriginRequestUrl = Urls.api_1_save_origin(originType, originUrl); $('.swh-processing-save-request').css('display', 'block'); let headers = {}; let body = null; if (extraData !== {}) { body = JSON.stringify(extraData); headers = { 'Content-Type': 'application/json' }; }; try { const response = await csrfPost(addSaveOriginRequestUrl, headers, body); handleFetchError(response); const data = await response.json(); $('.swh-processing-save-request').css('display', 'none'); if (data.save_request_status === 'accepted') { acceptedCallback(); } else { pendingCallback(); } } catch (response) { $('.swh-processing-save-request').css('display', 'none'); const errorData = await response.json(); errorCallback(response.status, errorData); }; } function addArtifactVersionAutofillHandler(formId) { // autofill artifact version input with the filename from // the artifact url without extensions $(`#swh-input-artifact-url-${formId}`).on('input', function(event) { const artifactUrl = $(this).val().trim(); let filename = artifactUrl.split('/').slice(-1)[0]; if (filename !== artifactUrl) { filename = filename.replace(/tar.*$/, 'tar'); const filenameNoExt = filename.split('.').slice(0, -1).join('.'); const artifactVersion = $(`#swh-input-artifact-version-${formId}`); if (filenameNoExt !== filename) { artifactVersion.val(filenameNoExt); } } }); } export function maybeRequireExtraInputs() { // Read the actual selected value and depending on the origin type, display some extra // inputs or hide them. This makes the extra inputs disabled when not displayed. const originType = $('#swh-input-visit-type').val(); let display = 'none'; let disabled = true; if (originType === 'archives') { display = 'flex'; disabled = false; } $('.swh-save-origin-archives-form').css('display', display); if (!disabled) { // help paragraph must have block display for proper rendering $('#swh-save-origin-archives-help').css('display', 'block'); } $('.swh-save-origin-archives-form .form-control').prop('disabled', disabled); if (originType === 'archives' && $('.swh-save-origin-archives-form').length === 1) { // insert first artifact row when the archives visit type is selected for the first time $('.swh-save-origin-archives-form').last().after( artifactFormRowTemplate({deletableRow: false, formId: 0})); addArtifactVersionAutofillHandler(0); } } export function addArtifactFormRow() { const formId = $('.swh-save-origin-artifact-form').length; $('.swh-save-origin-artifact-form').last().after( artifactFormRowTemplate({ deletableRow: true, formId: formId }) ); addArtifactVersionAutofillHandler(formId); } export function deleteArtifactFormRow(event) { $(event.target).closest('.swh-save-origin-artifact-form').remove(); } const saveRequestCheckboxId = 'swh-save-requests-user-filter'; const userRequestsFilterCheckbox = userRequestsFilterCheckboxFn({ 'inputId': saveRequestCheckboxId, 'checked': false // no filtering by default on that view }); export function initOriginSave() { $(document).ready(() => { $.fn.dataTable.ext.errMode = 'none'; // set git as the default value as before $('#swh-input-visit-type').val('git'); saveRequestsTable = $('#swh-origin-save-requests') .on('error.dt', (e, settings, techNote, message) => { $('#swh-origin-save-request-list-error').text('An error occurred while retrieving the save requests list'); console.log(message); }) .DataTable({ serverSide: true, processing: true, language: { processing: `` }, ajax: { url: Urls.origin_save_requests_list('all'), data: (d) => { if (swh.webapp.isUserLoggedIn() && $(`#${saveRequestCheckboxId}`).prop('checked')) { d.user_requests_only = '1'; } } }, searchDelay: 1000, // see https://datatables.net/examples/advanced_init/dom_toolbar.html and the comments section // this option customizes datatables UI components by adding an extra checkbox above the table // while keeping bootstrap layout dom: '<"row"<"col-sm-3"l><"col-sm-6 text-left user-requests-filter"><"col-sm-3"f>>' + '<"row"<"col-sm-12"tr>>' + '<"row"<"col-sm-5"i><"col-sm-7"p>>', fnInitComplete: function() { if (swh.webapp.isUserLoggedIn()) { $('div.user-requests-filter').html(userRequestsFilterCheckbox); $(`#${saveRequestCheckboxId}`).on('change', () => { saveRequestsTable.draw(); }); } }, columns: [ { data: 'save_request_date', name: 'request_date', render: getHumanReadableDate }, { data: 'visit_type', name: 'visit_type' }, { data: 'origin_url', name: 'origin_url', render: (data, type, row) => { if (type === 'display') { let html = ''; const sanitizedURL = $.fn.dataTable.render.text().display(data); if (row.save_task_status === 'succeeded') { if (row.visit_status === 'full' || row.visit_status === 'partial') { let browseOriginUrl = `${Urls.browse_origin()}?origin_url=${encodeURIComponent(sanitizedURL)}`; if (row.visit_date) { browseOriginUrl += `&timestamp=${encodeURIComponent(row.visit_date)}`; } html += `${sanitizedURL}`; } else { const tooltip = 'origin was successfully loaded, waiting for data to be available in database'; html += `${sanitizedURL}`; } } else { html += sanitizedURL; } html += ` ` + ''; return html; } return data; } }, { data: 'save_request_status', name: 'status' }, { data: 'save_task_status', name: 'loading_task_status' }, { name: 'info', render: (data, type, row) => { if (row.save_task_status === 'succeeded' || row.save_task_status === 'failed' || row.note != null) { return ``; + onclick="swh.save_code_now.displaySaveRequestInfo(event, ${row.id})">`; } else { return ''; } } }, { render: (data, type, row) => { if (row.save_request_status === 'accepted') { const saveAgainButton = ''; return saveAgainButton; } else { return ''; } } } ], scrollY: '50vh', scrollCollapse: true, order: [[0, 'desc']], responsive: { details: { type: 'none' } } }); swh.webapp.addJumpToPagePopoverToDataTable(saveRequestsTable); if (window.location.pathname === Urls.origin_save() && window.location.hash === '#requests') { // Keep old URLs to the save list working window.location = Urls.origin_save_list(); } else if ($('#swh-origin-save-requests')) { saveRequestsTable.draw(); } const saveRequestAcceptedAlert = htmlAlert( 'success', 'The "save code now" request has been accepted and will be processed as soon as possible.', true ); const saveRequestPendingAlert = htmlAlert( 'warning', 'The "save code now" request has been put in pending state and may be accepted for processing after manual review.', true ); const saveRequestRateLimitedAlert = htmlAlert( 'danger', 'The rate limit for "save code now" requests has been reached. Please try again later.', true ); const saveRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $('#swh-save-origin-form').submit(async event => { event.preventDefault(); event.stopPropagation(); $('.alert').alert('close'); if (event.target.checkValidity()) { $(event.target).removeClass('was-validated'); const originType = $('#swh-input-visit-type').val(); let originUrl = $('#swh-input-origin-url').val(); originUrl = await getCanonicalOriginURL(originUrl); // read the extra inputs for the 'archives' type const extraData = {}; if (originType === 'archives') { extraData['archives_data'] = []; for (let i = 0; i < $('.swh-save-origin-artifact-form').length; ++i) { extraData['archives_data'].push({ 'artifact_url': $(`#swh-input-artifact-url-${i}`).val(), 'artifact_version': $(`#swh-input-artifact-version-${i}`).val() }); } } originSaveRequest(originType, originUrl, extraData, () => $('#swh-origin-save-request-status').html(saveRequestAcceptedAlert), () => $('#swh-origin-save-request-status').html(saveRequestPendingAlert), (statusCode, errorData) => { $('#swh-origin-save-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['reason']}`); $('#swh-origin-save-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-origin-save-request-status').html(saveRequestRateLimitedAlert); } else if (statusCode === 400) { const errorAlert = htmlAlert('danger', errorData['reason']); $('#swh-origin-save-request-status').html(errorAlert); } else { $('#swh-origin-save-request-status').html(saveRequestUnknownErrorAlert); } }); } else { $(event.target).addClass('was-validated'); } }); $('#swh-show-origin-save-requests-list').on('click', (event) => { event.preventDefault(); $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); }); $('#swh-input-origin-url').on('input', function(event) { const originUrl = $(this).val().trim(); $(this).val(originUrl); $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); // origin URL input need to be validated once new visit type set validateSaveOriginUrl($('#swh-input-origin-url')[0]); } }); }); if (window.location.hash === '#requests') { $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); } $(window).on('hashchange', () => { if (window.location.hash === '#requests') { $('.nav-tabs a[href="#swh-origin-save-requests-list"]').tab('show'); } else { $('.nav-tabs a[href="#swh-origin-save-requests-create"]').tab('show'); } }); }); } export function validateSaveOriginUrl(input) { const originType = $('#swh-input-visit-type').val(); const allowedProtocols = ['http:', 'https:', 'svn:', 'git:', 'rsync:', 'pserver:', 'ssh:', 'bzr:']; const originUrl = validateUrl(input.value.trim(), allowedProtocols); let validUrl = originUrl !== null; if (validUrl && originType === 'git') { validUrl = isGitRepoUrl(originUrl); } let customValidity = ''; if (validUrl) { if ((originUrl.password !== '' && originUrl.password !== 'anonymous')) { customValidity = 'The origin url contains a password and cannot be accepted for security reasons'; } } else { customValidity = 'The origin url is not valid or does not reference a code repository'; } input.setCustomValidity(customValidity); $(input).siblings('.invalid-feedback').text(customValidity); } export function initTakeNewSnapshot() { const newSnapshotRequestAcceptedAlert = htmlAlert( 'success', 'The "take new snapshot" request has been accepted and will be processed as soon as possible.', true ); const newSnapshotRequestPendingAlert = htmlAlert( 'warning', 'The "take new snapshot" request has been put in pending state and may be accepted for processing after manual review.', true ); const newSnapshotRequestRateLimitAlert = htmlAlert( 'danger', 'The rate limit for "take new snapshot" requests has been reached. Please try again later.', true ); const newSnapshotRequestUnknownErrorAlert = htmlAlert( 'danger', 'An unexpected error happened when submitting the "save code now request".', true ); $(document).ready(() => { $('#swh-take-new-snapshot-form').submit(event => { event.preventDefault(); event.stopPropagation(); const originType = $('#swh-input-visit-type').val(); const originUrl = $('#swh-input-origin-url').val(); const extraData = {}; originSaveRequest(originType, originUrl, extraData, () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestAcceptedAlert), () => $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestPendingAlert), (statusCode, errorData) => { $('#swh-take-new-snapshot-request-status').css('color', 'red'); if (statusCode === 403) { const errorAlert = htmlAlert('danger', `Error: ${errorData['detail']}`, true); $('#swh-take-new-snapshot-request-status').html(errorAlert); } else if (statusCode === 429) { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestRateLimitAlert); } else { $('#swh-take-new-snapshot-request-status').html(newSnapshotRequestUnknownErrorAlert); } }); }); }); } export function formatValuePerType(type, value) { // Given some typed value, format and return accordingly formatted value const mapFormatPerTypeFn = { 'json': (v) => JSON.stringify(v, null, 2), 'date': (v) => new Date(v).toLocaleString(), 'raw': (v) => v, 'duration': (v) => v + ' seconds' }; return value === null ? null : mapFormatPerTypeFn[type](value); } export async function displaySaveRequestInfo(event, saveRequestId) { event.stopPropagation(); const saveRequestTaskInfoUrl = Urls.origin_save_task_info(saveRequestId); // close popover when clicking again on the info icon if ($(event.target).data('bs.popover')) { $(event.target).popover('dispose'); return; } $('.swh-save-request-info').popover('dispose'); $(event.target).popover({ animation: false, boundary: 'viewport', container: 'body', title: 'Save request task information ' + '`, content: `

Fetching task information ...

`, html: true, placement: 'left', sanitizeFn: swh.webapp.filterXSS }); $(event.target).on('shown.bs.popover', function() { const popoverId = $(this).attr('aria-describedby'); $(`#${popoverId} .mdi-close`).click(() => { $(this).popover('dispose'); }); }); $(event.target).popover('show'); const response = await fetch(saveRequestTaskInfoUrl); const saveRequestTaskInfo = await response.json(); let content; if ($.isEmptyObject(saveRequestTaskInfo)) { content = 'Not available'; } else if (saveRequestTaskInfo.note != null) { content = `
${saveRequestTaskInfo.note}
`; } else { const saveRequestInfo = []; const taskData = { 'Type': ['raw', 'type'], 'Visit status': ['raw', 'visit_status'], 'Arguments': ['json', 'arguments'], 'Id': ['raw', 'id'], 'Backend id': ['raw', 'backend_id'], 'Scheduling date': ['date', 'scheduled'], 'Start date': ['date', 'started'], 'Completion date': ['date', 'ended'], 'Duration': ['duration', 'duration'], 'Runner': ['raw', 'worker'], 'Log': ['raw', 'message'] }; for (const [title, [type, property]] of Object.entries(taskData)) { if (saveRequestTaskInfo.hasOwnProperty(property)) { saveRequestInfo.push({ key: title, value: formatValuePerType(type, saveRequestTaskInfo[property]) }); } } content = ''; for (const info of saveRequestInfo) { content += ``; } content += '
'; } $('.swh-popover').html(content); $(event.target).popover('update'); } export function fillSaveRequestFormAndScroll(visitType, originUrl) { $('#swh-input-origin-url').val(originUrl); let originTypeFound = false; $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val && originUrl.includes(val)) { $(this).prop('selected', true); originTypeFound = true; } }); if (!originTypeFound) { $('#swh-input-visit-type option').each(function() { const val = $(this).val(); if (val === visitType) { $(this).prop('selected', true); } }); } window.scrollTo(0, 0); } diff --git a/swh/web/save_code_now/templates/admin/origin-save-common.html b/swh/web/save_code_now/templates/admin/origin-save-common.html index b284771d..9daca34e 100644 --- a/swh/web/save_code_now/templates/admin/origin-save-common.html +++ b/swh/web/save_code_now/templates/admin/origin-save-common.html @@ -1,41 +1,40 @@ {% extends "layout.html" %} {% comment %} Copyright (C) 2018-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% load render_bundle from webpack_loader %} {% block header %} {{ block.super }} -{% render_bundle 'admin' %} -{% render_bundle 'save' %} +{% render_bundle 'save_code_now' %} {% endblock %} {% block title %} Save origin administration {% endblock %} {% block navbar-content %}

Save origin administration

{% endblock %} {% block content %}
{% block tab_content %} {% endblock %}
{% endblock %} diff --git a/swh/web/save_code_now/templates/admin/origin-save-filters.html b/swh/web/save_code_now/templates/admin/origin-save-filters.html index c50f0d76..4eb44998 100644 --- a/swh/web/save_code_now/templates/admin/origin-save-filters.html +++ b/swh/web/save_code_now/templates/admin/origin-save-filters.html @@ -1,76 +1,76 @@ {% extends "./origin-save-common.html" %} {% comment %} Copyright (C) 2018-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% block tab_content %}
Url
- +
- +
Url
- +
- +
{% endblock %} diff --git a/swh/web/save_code_now/templates/admin/origin-save-requests.html b/swh/web/save_code_now/templates/admin/origin-save-requests.html index 3a115311..de2cdd97 100644 --- a/swh/web/save_code_now/templates/admin/origin-save-requests.html +++ b/swh/web/save_code_now/templates/admin/origin-save-requests.html @@ -1,93 +1,93 @@ {% extends "./origin-save-common.html" %} {% comment %} Copyright (C) 2018-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% block tab_content %}
Date Type Url
- +
- +
- +
Date Type Url Status Info
- +
Date Type Url Info
- +
{% endblock %} diff --git a/swh/web/save_code_now/templates/includes/take-new-snapshot.html b/swh/web/save_code_now/templates/includes/take-new-snapshot.html index 32f4ca9a..b149d3e8 100644 --- a/swh/web/save_code_now/templates/includes/take-new-snapshot.html +++ b/swh/web/save_code_now/templates/includes/take-new-snapshot.html @@ -1,74 +1,74 @@ {% comment %} -Copyright (C) 2019-2021 The Software Heritage developers +Copyright (C) 2019-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load static %} {% load swh_templatetags %} {% if snapshot_context and snapshot_context.visit_info and snapshot_context.visit_info.type|visit_type_savable %} {% endif %} diff --git a/swh/web/save_code_now/templates/origin-save.html b/swh/web/save_code_now/templates/origin-save.html index 04ee73f9..687a17d1 100644 --- a/swh/web/save_code_now/templates/origin-save.html +++ b/swh/web/save_code_now/templates/origin-save.html @@ -1,88 +1,88 @@ {% extends "layout.html" %} {% comment %} -Copyright (C) 2018-2021 The Software Heritage developers +Copyright (C) 2018-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load render_bundle from webpack_loader %} {% load static %} {% block title %}{{ heading }} – Software Heritage archive{% endblock %} {% block header %} -{% render_bundle 'save' %} +{% render_bundle 'save_code_now' %} {% endblock %} {% block navbar-content %}

Save code now

{% endblock %} {% block content %}

You can contribute to extend the content of the Software Heritage archive by submitting an origin save request. To do so, fill the required info in the form below:

{% csrf_token %}
- {% for visit_type in visit_types %} {% endfor %}
The origin type must be specified
- +
The origin url is not valid or does not reference a code repository
{% block tab_content %} {% endblock %}
{% endblock %} diff --git a/swh/web/tests/conftest.py b/swh/web/tests/conftest.py index fcf253f9..d7c94baf 100644 --- a/swh/web/tests/conftest.py +++ b/swh/web/tests/conftest.py @@ -1,1239 +1,1254 @@ # Copyright (C) 2018-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from collections import defaultdict from datetime import timedelta import functools from importlib import import_module, reload import json import os import random import shutil import sys import time from typing import Any, Dict, List, Optional from _pytest.python import Function from hypothesis import HealthCheck from hypothesis import settings as hypothesis_settings import pytest from pytest_django.fixtures import SettingsWrapper from django.conf import settings from django.contrib.auth.models import User from django.core.cache import cache from django.test.utils import setup_databases from django.urls import clear_url_caches from rest_framework.test import APIClient, APIRequestFactory from swh.model.hashutil import ( ALGORITHMS, DEFAULT_ALGORITHMS, hash_to_bytes, hash_to_hex, ) from swh.model.model import Content, Directory from swh.model.swhids import CoreSWHID, ObjectType from swh.scheduler.tests.common import TASK_TYPES from swh.storage.algos.origin import origin_get_latest_visit_status from swh.storage.algos.revisions_walker import get_revisions_walker from swh.storage.algos.snapshot import snapshot_get_all_branches, snapshot_get_latest from swh.web.auth.utils import ( ADD_FORGE_MODERATOR_PERMISSION, MAILMAP_ADMIN_PERMISSION, MAILMAP_PERMISSION, ) from swh.web.config import get_config from swh.web.save_code_now.origin_save import get_scheduler_load_task_types from swh.web.tests.data import ( get_tests_data, override_storages, random_content, random_sha1, random_sha1_bytes, random_sha256, ) from swh.web.tests.helpers import create_django_permission from swh.web.utils import browsers_supported_image_mimes, converters from swh.web.utils.typing import OriginVisitInfo os.environ["LC_ALL"] = "C.UTF-8" fossology_missing = shutil.which("nomossa") is None # Register some hypothesis profiles hypothesis_settings.register_profile("default", hypothesis_settings()) # we use getattr here to keep mypy happy regardless hypothesis version function_scoped_fixture_check = ( [getattr(HealthCheck, "function_scoped_fixture")] if hasattr(HealthCheck, "function_scoped_fixture") else [] ) suppress_health_check = [ HealthCheck.too_slow, HealthCheck.filter_too_much, ] + function_scoped_fixture_check hypothesis_settings.register_profile( "swh-web", hypothesis_settings( deadline=None, suppress_health_check=suppress_health_check, ), ) hypothesis_settings.register_profile( "swh-web-fast", hypothesis_settings( deadline=None, max_examples=5, suppress_health_check=suppress_health_check, ), ) def pytest_addoption(parser): parser.addoption("--swh-web-random-seed", action="store", default=None) def pytest_configure(config): # Use fast hypothesis profile by default if none has been # explicitly specified in pytest option if config.getoption("--hypothesis-profile") is None: hypothesis_settings.load_profile("swh-web-fast") # Small hack in order to be able to run the unit tests # without static assets generated by webpack. # Those assets are not really needed for the Python tests # but the django templates will fail to load due to missing # generated file webpack-stats.json describing the js and css # files to include. # So generate a dummy webpack-stats.json file to overcome # that issue. test_dir = os.path.dirname(__file__) # location of the static folder when running tests through tox data_dir = os.path.join(sys.prefix, "share/swh/web") static_dir = os.path.join(data_dir, "static") if not os.path.exists(static_dir): # location of the static folder when running tests locally with pytest static_dir = os.path.join(test_dir, "../../../static") webpack_stats = os.path.join(static_dir, "webpack-stats.json") if os.path.exists(webpack_stats): return - bundles_dir = os.path.join(test_dir, "../../../assets/src/bundles") - if not os.path.exists(bundles_dir): - # location of the bundles folder when running tests with tox - bundles_dir = os.path.join(data_dir, "assets/src/bundles") - - _, bundles, _ = next(os.walk(bundles_dir)) + django_apps_dir = os.path.join(test_dir, "../../../swh/web") + if not os.path.exists(django_apps_dir): + # location of the applications folder when running tests with tox + django_apps_dir = os.path.join(data_dir, "swh/web") + + bundles = [] + _, apps, _ = next(os.walk(django_apps_dir)) + for app in apps: + app_assets_dir = os.path.join(django_apps_dir, app, "assets") + if os.path.exists(app_assets_dir): + if os.path.exists(os.path.join(app_assets_dir, "index.js")): + bundles.append(app) + else: + _, app_bundles, _ = next(os.walk(app_assets_dir)) + for app_bundle in app_bundles: + if os.path.exists( + os.path.join(app_assets_dir, app_bundle, "index.js") + ): + bundles.append(app_bundle) + + print(bundles) mock_webpack_stats = { "status": "done", "publicPath": "/static", "chunks": {}, "assets": {}, } for bundle in bundles: asset = f"js/{bundle}.js" mock_webpack_stats["chunks"][bundle] = [asset] mock_webpack_stats["assets"][asset] = { "name": asset, "publicPath": f"/static/{asset}", } with open(webpack_stats, "w") as outfile: json.dump(mock_webpack_stats, outfile) _swh_web_custom_section = "swh-web custom section" _random_seed_cache_key = "swh-web/random-seed" @pytest.fixture(scope="function", autouse=True) def random_seed(pytestconfig): state = random.getstate() seed = pytestconfig.getoption("--swh-web-random-seed") if seed is None: seed = time.time() seed = int(seed) cache.set(_random_seed_cache_key, seed) random.seed(seed) yield seed random.setstate(state) def pytest_report_teststatus(report, *args): if report.when == "call" and report.outcome == "failed": seed = cache.get(_random_seed_cache_key, None) line = ( f'FAILED {report.nodeid}: Use "pytest --swh-web-random-seed={seed} ' f'{report.nodeid}" to reproduce that test failure with same inputs' ) report.sections.append((_swh_web_custom_section, line)) def pytest_terminal_summary(terminalreporter, *args): reports = terminalreporter.getreports("failed") content = os.linesep.join( text for report in reports for secname, text in report.sections if secname == _swh_web_custom_section ) if content: terminalreporter.ensure_newline() terminalreporter.section(_swh_web_custom_section, sep="-", blue=True, bold=True) terminalreporter.line(content) # Clear Django cache before each test @pytest.fixture(autouse=True) def django_cache_cleared(): cache.clear() # Alias rf fixture from pytest-django @pytest.fixture def request_factory(rf): return rf # Fixture to get test client from Django REST Framework @pytest.fixture def api_client(): return APIClient() # Fixture to get API request factory from Django REST Framework @pytest.fixture def api_request_factory(): return APIRequestFactory() # Initialize tests data @pytest.fixture(scope="function", autouse=True) def tests_data(): data = get_tests_data(reset=True) # Update swh-web configuration to use the in-memory storages # instantiated in the tests.data module override_storages( data["storage"], data["idx_storage"], data["search"], data["counters"] ) return data @pytest.fixture(scope="function") def sha1(): """Fixture returning a valid hexadecimal sha1 value.""" return random_sha1() @pytest.fixture(scope="function") def invalid_sha1(): """Fixture returning an invalid sha1 representation.""" return hash_to_hex(bytes(random.randint(0, 255) for _ in range(50))) @pytest.fixture(scope="function") def sha256(): """Fixture returning a valid hexadecimal sha256 value.""" return random_sha256() def _known_swh_objects(tests_data, object_type): return tests_data[object_type] @pytest.fixture(scope="function") def content(tests_data): """Fixture returning a random content ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "contents")) @pytest.fixture(scope="function") def contents(tests_data): """Fixture returning random contents ingested into the test archive.""" return random.choices( _known_swh_objects(tests_data, "contents"), k=random.randint(2, 8) ) def _new_content(tests_data): while True: new_content = random_content() sha1_bytes = hash_to_bytes(new_content["sha1"]) if tests_data["storage"].content_get_data(sha1_bytes) is None: return new_content @pytest.fixture(scope="function") def unknown_content(tests_data): """Fixture returning a random content not ingested into the test archive.""" return _new_content(tests_data) @pytest.fixture(scope="function") def unknown_contents(tests_data): """Fixture returning random contents not ingested into the test archive.""" new_contents = [] new_content_ids = set() nb_contents = random.randint(2, 8) while len(new_contents) != nb_contents: new_content = _new_content(tests_data) if new_content["sha1"] not in new_content_ids: new_contents.append(new_content) new_content_ids.add(new_content["sha1"]) return list(new_contents) @pytest.fixture(scope="function") def empty_content(): """Fixture returning the empty content ingested into the test archive.""" empty_content = Content.from_data(data=b"").to_dict() for algo in DEFAULT_ALGORITHMS: empty_content[algo] = hash_to_hex(empty_content[algo]) return empty_content @functools.lru_cache(maxsize=None) def _content_text(): return list( filter( lambda c: c["mimetype"].startswith("text/"), _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_text(): """ Fixture returning a random textual content ingested into the test archive. """ return random.choice(_content_text()) @functools.lru_cache(maxsize=None) def _content_text_non_utf8(): return list( filter( lambda c: c["mimetype"].startswith("text/") and c["encoding"] not in ("utf-8", "us-ascii"), _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_text_non_utf8(): """Fixture returning a random textual content not encoded to UTF-8 ingested into the test archive. """ return random.choice(_content_text_non_utf8()) @functools.lru_cache(maxsize=None) def _content_application_no_highlight(): return list( filter( lambda c: c["mimetype"].startswith("application/") and c["hljs_language"] == "plaintext", _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_application_no_highlight(): """Fixture returning a random textual content with mimetype starting with application/ and no detected programming language to highlight ingested into the test archive. """ return random.choice(_content_application_no_highlight()) @functools.lru_cache(maxsize=None) def _content_text_no_highlight(): return list( filter( lambda c: c["mimetype"].startswith("text/") and c["hljs_language"] == "plaintext", _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_text_no_highlight(): """Fixture returning a random textual content with no detected programming language to highlight ingested into the test archive. """ return random.choice(_content_text_no_highlight()) @functools.lru_cache(maxsize=None) def _content_image_type(): return list( filter( lambda c: c["mimetype"] in browsers_supported_image_mimes, _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_image_type(): """Fixture returning a random image content ingested into the test archive.""" return random.choice(_content_image_type()) @functools.lru_cache(maxsize=None) def _content_unsupported_image_type_rendering(): return list( filter( lambda c: c["mimetype"].startswith("image/") and c["mimetype"] not in browsers_supported_image_mimes, _known_swh_objects(get_tests_data(), "contents"), ) ) @pytest.fixture(scope="function") def content_unsupported_image_type_rendering(): """Fixture returning a random image content ingested into the test archive that can not be rendered by browsers. """ return random.choice(_content_unsupported_image_type_rendering()) @functools.lru_cache(maxsize=None) def _content_utf8_detected_as_binary(): def utf8_binary_detected(content): if content["encoding"] != "binary": return False try: content["raw_data"].decode("utf-8") except Exception: return False else: return True return list( filter(utf8_binary_detected, _known_swh_objects(get_tests_data(), "contents")) ) @pytest.fixture(scope="function") def content_utf8_detected_as_binary(): """Fixture returning a random textual content detected as binary by libmagic while they are valid UTF-8 encoded files. """ return random.choice(_content_utf8_detected_as_binary()) @pytest.fixture(scope="function") def directory(tests_data): """Fixture returning a random directory ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "directories")) @functools.lru_cache(maxsize=None) def _directory_with_entry_type(type_): tests_data = get_tests_data() return list( filter( lambda d: any( [ e["type"] == type_ for e in list(tests_data["storage"].directory_ls(hash_to_bytes(d))) ] ), _known_swh_objects(tests_data, "directories"), ) ) @pytest.fixture(scope="function") def directory_with_subdirs(): """Fixture returning a random directory containing sub directories ingested into the test archive. """ return random.choice(_directory_with_entry_type("dir")) @pytest.fixture(scope="function") def directory_with_files(): """Fixture returning a random directory containing at least one regular file.""" return random.choice(_directory_with_entry_type("file")) @pytest.fixture(scope="function") def unknown_directory(tests_data): """Fixture returning a random directory not ingested into the test archive.""" while True: new_directory = random_sha1() sha1_bytes = hash_to_bytes(new_directory) if list(tests_data["storage"].directory_missing([sha1_bytes])): return new_directory @pytest.fixture(scope="function") def empty_directory(): """Fixture returning the empty directory ingested into the test archive.""" return Directory(entries=()).id.hex() @pytest.fixture(scope="function") def revision(tests_data): """Fixturereturning a random revision ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "revisions")) @pytest.fixture(scope="function") def revisions(tests_data): """Fixture returning random revisions ingested into the test archive.""" return random.choices( _known_swh_objects(tests_data, "revisions"), k=random.randint(2, 8), ) @pytest.fixture(scope="function") def revisions_list(tests_data): """Fixture returning random revisions ingested into the test archive.""" def gen_revisions_list(size): return random.choices( _known_swh_objects(tests_data, "revisions"), k=size, ) return gen_revisions_list @pytest.fixture(scope="function") def unknown_revision(tests_data): """Fixture returning a random revision not ingested into the test archive.""" while True: new_revision = random_sha1() sha1_bytes = hash_to_bytes(new_revision) if tests_data["storage"].revision_get([sha1_bytes])[0] is None: return new_revision def _get_origin_dfs_revisions_walker(tests_data): storage = tests_data["storage"] origin = random.choice(tests_data["origins"][:-1]) snapshot = snapshot_get_latest(storage, origin["url"]) if snapshot.branches[b"HEAD"].target_type.value == "alias": target = snapshot.branches[b"HEAD"].target head = snapshot.branches[target].target else: head = snapshot.branches[b"HEAD"].target return get_revisions_walker("dfs", storage, head) @functools.lru_cache(maxsize=None) def _ancestor_revisions_data(): # get a dfs revisions walker for one of the origins # loaded into the test archive revisions_walker = _get_origin_dfs_revisions_walker(get_tests_data()) master_revisions = [] children = defaultdict(list) init_rev_found = False # get revisions only authored in the master branch for rev in revisions_walker: for rev_p in rev["parents"]: children[rev_p].append(rev["id"]) if not init_rev_found: master_revisions.append(rev) if not rev["parents"]: init_rev_found = True return master_revisions, children @pytest.fixture(scope="function") def ancestor_revisions(): """Fixture returning a pair of revisions ingested into the test archive with an ancestor relation. """ master_revisions, children = _ancestor_revisions_data() # head revision root_rev = master_revisions[0] # pick a random revision, different from head, only authored # in the master branch ancestor_rev_idx = random.choice(list(range(1, len(master_revisions) - 1))) ancestor_rev = master_revisions[ancestor_rev_idx] ancestor_child_revs = children[ancestor_rev["id"]] return { "sha1_git_root": hash_to_hex(root_rev["id"]), "sha1_git": hash_to_hex(ancestor_rev["id"]), "children": [hash_to_hex(r) for r in ancestor_child_revs], } @functools.lru_cache(maxsize=None) def _non_ancestor_revisions_data(): # get a dfs revisions walker for one of the origins # loaded into the test archive revisions_walker = _get_origin_dfs_revisions_walker(get_tests_data()) merge_revs = [] children = defaultdict(list) # get all merge revisions for rev in revisions_walker: if len(rev["parents"]) > 1: merge_revs.append(rev) for rev_p in rev["parents"]: children[rev_p].append(rev["id"]) return merge_revs, children @pytest.fixture(scope="function") def non_ancestor_revisions(): """Fixture returning a pair of revisions ingested into the test archive with no ancestor relation. """ merge_revs, children = _non_ancestor_revisions_data() # find a merge revisions whose parents have a unique child revision random.shuffle(merge_revs) selected_revs = None for merge_rev in merge_revs: if all(len(children[rev_p]) == 1 for rev_p in merge_rev["parents"]): selected_revs = merge_rev["parents"] return { "sha1_git_root": hash_to_hex(selected_revs[0]), "sha1_git": hash_to_hex(selected_revs[1]), } @pytest.fixture(scope="function") def revision_with_submodules(): """Fixture returning a revision that is known to point to a directory with revision entries (aka git submodules) """ return { "rev_sha1_git": "ffcb69001f3f6745dfd5b48f72ab6addb560e234", "rev_dir_sha1_git": "d92a21446387fa28410e5a74379c934298f39ae2", "rev_dir_rev_path": "libtess2", } @pytest.fixture(scope="function") def release(tests_data): """Fixture returning a random release ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "releases")) @pytest.fixture(scope="function") def releases(tests_data): """Fixture returning random releases ingested into the test archive.""" return random.choices( _known_swh_objects(tests_data, "releases"), k=random.randint(2, 8) ) @pytest.fixture(scope="function") def unknown_release(tests_data): """Fixture returning a random release not ingested into the test archive.""" while True: new_release = random_sha1() sha1_bytes = hash_to_bytes(new_release) if tests_data["storage"].release_get([sha1_bytes])[0] is None: return new_release @pytest.fixture(scope="function") def snapshot(tests_data): """Fixture returning a random snapshot ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "snapshots")) @pytest.fixture(scope="function") def unknown_snapshot(tests_data): """Fixture returning a random snapshot not ingested into the test archive.""" while True: new_snapshot = random_sha1() sha1_bytes = hash_to_bytes(new_snapshot) if tests_data["storage"].snapshot_get_branches(sha1_bytes) is None: return new_snapshot @pytest.fixture(scope="function") def origin(tests_data): """Fixture returning a random origin ingested into the test archive.""" return random.choice(_known_swh_objects(tests_data, "origins")) @functools.lru_cache(maxsize=None) def _origin_with_multiple_visits(): tests_data = get_tests_data() origins = [] storage = tests_data["storage"] for origin in tests_data["origins"]: visit_page = storage.origin_visit_get(origin["url"]) if len(visit_page.results) > 1: origins.append(origin) return origins @pytest.fixture(scope="function") def origin_with_multiple_visits(): """Fixture returning a random origin with multiple visits ingested into the test archive. """ return random.choice(_origin_with_multiple_visits()) @functools.lru_cache(maxsize=None) def _origin_with_releases(): tests_data = get_tests_data() origins = [] for origin in tests_data["origins"]: snapshot = snapshot_get_latest(tests_data["storage"], origin["url"]) if any([b.target_type.value == "release" for b in snapshot.branches.values()]): origins.append(origin) return origins @pytest.fixture(scope="function") def origin_with_releases(): """Fixture returning a random origin with releases ingested into the test archive.""" return random.choice(_origin_with_releases()) @functools.lru_cache(maxsize=None) def _origin_with_pull_request_branches(): tests_data = get_tests_data() origins = [] storage = tests_data["storage"] for origin in storage.origin_list(limit=1000).results: snapshot = snapshot_get_latest(storage, origin.url) if any([b"refs/pull/" in b for b in snapshot.branches]): origins.append(origin) return origins @pytest.fixture(scope="function") def origin_with_pull_request_branches(): """Fixture returning a random origin with pull request branches ingested into the test archive. """ return random.choice(_origin_with_pull_request_branches()) @functools.lru_cache(maxsize=None) def _object_type_swhid(object_type): return list( filter( lambda swhid: swhid.object_type == object_type, _known_swh_objects(get_tests_data(), "swhids"), ) ) @pytest.fixture(scope="function") def content_swhid(): """Fixture returning a qualified SWHID for a random content object ingested into the test archive. """ return random.choice(_object_type_swhid(ObjectType.CONTENT)) @pytest.fixture(scope="function") def directory_swhid(): """Fixture returning a qualified SWHID for a random directory object ingested into the test archive. """ return random.choice(_object_type_swhid(ObjectType.DIRECTORY)) @pytest.fixture(scope="function") def release_swhid(): """Fixture returning a qualified SWHID for a random release object ingested into the test archive. """ return random.choice(_object_type_swhid(ObjectType.RELEASE)) @pytest.fixture(scope="function") def revision_swhid(): """Fixture returning a qualified SWHID for a random revision object ingested into the test archive. """ return random.choice(_object_type_swhid(ObjectType.REVISION)) @pytest.fixture(scope="function") def snapshot_swhid(): """Fixture returning a qualified SWHID for a snapshot object ingested into the test archive. """ return random.choice(_object_type_swhid(ObjectType.SNAPSHOT)) @pytest.fixture(scope="function", params=list(ObjectType)) def unknown_core_swhid(request) -> CoreSWHID: """Fixture returning an unknown core SWHID. Tests using this will be called once per object type. """ return CoreSWHID( object_type=request.param, object_id=random_sha1_bytes(), ) # Fixture to manipulate data from a sample archive used in the tests @pytest.fixture(scope="function") def archive_data(tests_data): return _ArchiveData(tests_data) # Fixture to manipulate indexer data from a sample archive used in the tests @pytest.fixture(scope="function") def indexer_data(tests_data): return _IndexerData(tests_data) # Custom data directory for requests_mock @pytest.fixture def datadir(): return os.path.join(os.path.abspath(os.path.dirname(__file__)), "resources") class _ArchiveData: """ Helper class to manage data from a sample test archive. It is initialized with a reference to an in-memory storage containing raw tests data. It is basically a proxy to Storage interface but it overrides some methods to retrieve those tests data in a json serializable format in order to ease tests implementation. """ def __init__(self, tests_data): self.storage = tests_data["storage"] def __getattr__(self, key): if key == "storage": raise AttributeError(key) # Forward calls to non overridden Storage methods to wrapped # storage instance return getattr(self.storage, key) def content_find(self, content: Dict[str, Any]) -> Dict[str, Any]: cnt_ids_bytes = { algo_hash: hash_to_bytes(content[algo_hash]) for algo_hash in ALGORITHMS if content.get(algo_hash) } cnt = self.storage.content_find(cnt_ids_bytes) return converters.from_content(cnt[0].to_dict()) if cnt else cnt def content_get(self, cnt_id: str) -> Dict[str, Any]: cnt_id_bytes = hash_to_bytes(cnt_id) content = self.storage.content_get([cnt_id_bytes])[0] if content: content_d = content.to_dict() content_d.pop("ctime", None) else: content_d = None return converters.from_swh( content_d, hashess={"sha1", "sha1_git", "sha256", "blake2s256"} ) def content_get_data(self, cnt_id: str) -> Optional[Dict[str, Any]]: cnt_id_bytes = hash_to_bytes(cnt_id) cnt_data = self.storage.content_get_data(cnt_id_bytes) if cnt_data is None: return None return converters.from_content({"data": cnt_data, "sha1": cnt_id_bytes}) def directory_get(self, dir_id): return {"id": dir_id, "content": self.directory_ls(dir_id)} def directory_ls(self, dir_id): cnt_id_bytes = hash_to_bytes(dir_id) dir_content = map( converters.from_directory_entry, self.storage.directory_ls(cnt_id_bytes) ) return list(dir_content) def release_get(self, rel_id: str) -> Optional[Dict[str, Any]]: rel_id_bytes = hash_to_bytes(rel_id) rel_data = self.storage.release_get([rel_id_bytes])[0] return converters.from_release(rel_data) if rel_data else None def revision_get(self, rev_id: str) -> Optional[Dict[str, Any]]: rev_id_bytes = hash_to_bytes(rev_id) rev_data = self.storage.revision_get([rev_id_bytes])[0] return converters.from_revision(rev_data) if rev_data else None def revision_log(self, rev_id, limit=None): rev_id_bytes = hash_to_bytes(rev_id) return list( map( converters.from_revision, self.storage.revision_log([rev_id_bytes], limit=limit), ) ) def snapshot_get_latest(self, origin_url): snp = snapshot_get_latest(self.storage, origin_url) return converters.from_snapshot(snp.to_dict()) def origin_get(self, origin_urls): origins = self.storage.origin_get(origin_urls) return [converters.from_origin(o.to_dict()) for o in origins] def origin_visit_get(self, origin_url): next_page_token = None visits = [] while True: visit_page = self.storage.origin_visit_get( origin_url, page_token=next_page_token ) next_page_token = visit_page.next_page_token for visit in visit_page.results: visit_status = self.storage.origin_visit_status_get_latest( origin_url, visit.visit ) visits.append( converters.from_origin_visit( {**visit_status.to_dict(), "type": visit.type} ) ) if not next_page_token: break return visits def origin_visit_get_by(self, origin_url: str, visit_id: int) -> OriginVisitInfo: visit = self.storage.origin_visit_get_by(origin_url, visit_id) assert visit is not None visit_status = self.storage.origin_visit_status_get_latest(origin_url, visit_id) assert visit_status is not None return converters.from_origin_visit( {**visit_status.to_dict(), "type": visit.type} ) def origin_visit_status_get_latest( self, origin_url, type: Optional[str] = None, allowed_statuses: Optional[List[str]] = None, require_snapshot: bool = False, ): visit_status = origin_get_latest_visit_status( self.storage, origin_url, type=type, allowed_statuses=allowed_statuses, require_snapshot=require_snapshot, ) return ( converters.from_origin_visit(visit_status.to_dict()) if visit_status else None ) def snapshot_get(self, snapshot_id): snp = snapshot_get_all_branches(self.storage, hash_to_bytes(snapshot_id)) return converters.from_snapshot(snp.to_dict()) def snapshot_get_branches( self, snapshot_id, branches_from="", branches_count=1000, target_types=None ): partial_branches = self.storage.snapshot_get_branches( hash_to_bytes(snapshot_id), branches_from.encode(), branches_count, target_types, ) return converters.from_partial_branches(partial_branches) def snapshot_get_head(self, snapshot): if snapshot["branches"]["HEAD"]["target_type"] == "alias": target = snapshot["branches"]["HEAD"]["target"] head = snapshot["branches"][target]["target"] else: head = snapshot["branches"]["HEAD"]["target"] return head def snapshot_count_branches(self, snapshot_id): counts = dict.fromkeys(("alias", "release", "revision"), 0) counts.update(self.storage.snapshot_count_branches(hash_to_bytes(snapshot_id))) counts.pop(None, None) return counts class _IndexerData: """ Helper class to manage indexer tests data It is initialized with a reference to an in-memory indexer storage containing raw tests data. It also defines class methods to retrieve those tests data in a json serializable format in order to ease tests implementation. """ def __init__(self, tests_data): self.idx_storage = tests_data["idx_storage"] self.mimetype_indexer = tests_data["mimetype_indexer"] self.license_indexer = tests_data["license_indexer"] def content_add_mimetype(self, cnt_id): self.mimetype_indexer.run([hash_to_bytes(cnt_id)]) def content_get_mimetype(self, cnt_id): mimetype = self.idx_storage.content_mimetype_get([hash_to_bytes(cnt_id)])[ 0 ].to_dict() return converters.from_filetype(mimetype) def content_add_license(self, cnt_id): self.license_indexer.run([hash_to_bytes(cnt_id)]) def content_get_license(self, cnt_id): cnt_id_bytes = hash_to_bytes(cnt_id) licenses = self.idx_storage.content_fossology_license_get([cnt_id_bytes]) for license in licenses: yield converters.from_swh(license.to_dict(), hashess={"id"}) @pytest.fixture def keycloak_oidc(keycloak_oidc, mocker): keycloak_config = get_config()["keycloak"] keycloak_oidc.server_url = keycloak_config["server_url"] keycloak_oidc.realm_name = keycloak_config["realm_name"] keycloak_oidc.client_id = settings.OIDC_SWH_WEB_CLIENT_ID keycloak_oidc_client = mocker.patch("swh.web.auth.views.keycloak_oidc_client") keycloak_oidc_client.return_value = keycloak_oidc return keycloak_oidc @pytest.fixture def subtest(request): """A hack to explicitly set up and tear down fixtures. This fixture allows you to set up and tear down fixtures within the test function itself. This is useful (necessary!) for using Hypothesis inside pytest, as hypothesis will call the test function multiple times, without setting up or tearing down fixture state as it is normally the case. Copied from the pytest-subtesthack project, public domain license (https://github.com/untitaker/pytest-subtesthack). """ parent_test = request.node def inner(func): if hasattr(Function, "from_parent"): item = Function.from_parent( parent_test, name=request.function.__name__ + "[]", originalname=request.function.__name__, callobj=func, ) else: item = Function( name=request.function.__name__ + "[]", parent=parent_test, callobj=func ) nextitem = parent_test # prevents pytest from tearing down module fixtures item.ihook.pytest_runtest_setup(item=item) try: item.ihook.pytest_runtest_call(item=item) finally: item.ihook.pytest_runtest_teardown(item=item, nextitem=nextitem) return inner @pytest.fixture def swh_scheduler(swh_scheduler): config = get_config() scheduler = config["scheduler"] config["scheduler"] = swh_scheduler # create load-git and load-hg task types for task_type in TASK_TYPES.values(): # see https://forge.softwareheritage.org/rDSCHc46ffadf7adf24c7eb3ffce062e8ade3818c79cc # noqa task_type["type"] = task_type["type"].replace("load-test-", "load-", 1) swh_scheduler.create_task_type(task_type) # create load-svn task type swh_scheduler.create_task_type( { "type": "load-svn", "description": "Update a Subversion repository", "backend_name": "swh.loader.svn.tasks.DumpMountAndLoadSvnRepository", "default_interval": timedelta(days=64), "min_interval": timedelta(hours=12), "max_interval": timedelta(days=64), "backoff_factor": 2, "max_queue_length": None, "num_retries": 7, "retry_delay": timedelta(hours=2), } ) # create load-cvs task type swh_scheduler.create_task_type( { "type": "load-cvs", "description": "Update a CVS repository", "backend_name": "swh.loader.cvs.tasks.DumpMountAndLoadSvnRepository", "default_interval": timedelta(days=64), "min_interval": timedelta(hours=12), "max_interval": timedelta(days=64), "backoff_factor": 2, "max_queue_length": None, "num_retries": 7, "retry_delay": timedelta(hours=2), } ) # create load-bzr task type swh_scheduler.create_task_type( { "type": "load-bzr", "description": "Update a Bazaar repository", "backend_name": "swh.loader.bzr.tasks.LoadBazaar", "default_interval": timedelta(days=64), "min_interval": timedelta(hours=12), "max_interval": timedelta(days=64), "backoff_factor": 2, "max_queue_length": None, "num_retries": 7, "retry_delay": timedelta(hours=2), } ) # add method to add load-archive-files task type during tests def add_load_archive_task_type(): swh_scheduler.create_task_type( { "type": "load-archive-files", "description": "Load tarballs", "backend_name": "swh.loader.package.archive.tasks.LoadArchive", "default_interval": timedelta(days=64), "min_interval": timedelta(hours=12), "max_interval": timedelta(days=64), "backoff_factor": 2, "max_queue_length": None, "num_retries": 7, "retry_delay": timedelta(hours=2), } ) swh_scheduler.add_load_archive_task_type = add_load_archive_task_type yield swh_scheduler config["scheduler"] = scheduler get_scheduler_load_task_types.cache_clear() @pytest.fixture(scope="session") def django_db_setup(request, django_db_blocker, postgresql_proc): from django.conf import settings settings.DATABASES["default"].update( { ("ENGINE", "django.db.backends.postgresql"), ("NAME", get_config()["test_db"]["name"]), ("USER", postgresql_proc.user), ("HOST", postgresql_proc.host), ("PORT", postgresql_proc.port), } ) with django_db_blocker.unblock(): setup_databases( verbosity=request.config.option.verbose, interactive=False, keepdb=False ) @pytest.fixture def staff_user(): return User.objects.create_user(username="admin", password="", is_staff=True) @pytest.fixture def regular_user(): return User.objects.create_user(username="johndoe", password="") @pytest.fixture def regular_user2(): return User.objects.create_user(username="janedoe", password="") @pytest.fixture def add_forge_moderator(): moderator = User.objects.create_user(username="add-forge moderator", password="") moderator.user_permissions.add( create_django_permission(ADD_FORGE_MODERATOR_PERMISSION) ) return moderator @pytest.fixture def mailmap_admin(): mailmap_admin = User.objects.create_user(username="mailmap-admin", password="") mailmap_admin.user_permissions.add( create_django_permission(MAILMAP_ADMIN_PERMISSION) ) return mailmap_admin @pytest.fixture def mailmap_user(): mailmap_user = User.objects.create_user(username="mailmap-user", password="") mailmap_user.user_permissions.add(create_django_permission(MAILMAP_PERMISSION)) return mailmap_user def reload_urlconf(): from django.conf import settings clear_url_caches() urlconf = settings.ROOT_URLCONF if urlconf in sys.modules: reload(sys.modules[urlconf]) else: import_module(urlconf) class SwhSettingsWrapper(SettingsWrapper): def __setattr__(self, attr: str, value) -> None: super().__setattr__(attr, value) reload_urlconf() def finalize(self) -> None: super().finalize() reload_urlconf() @pytest.fixture def django_settings(): """Override pytest-django settings fixture in order to reload URLs when modifying settings in test and after test execution as most of them depend on installed django apps in swh-web. """ settings = SwhSettingsWrapper() yield settings settings.finalize() diff --git a/assets/src/bundles/vault/index.js b/swh/web/vault/assets/index.js similarity index 100% rename from assets/src/bundles/vault/index.js rename to swh/web/vault/assets/index.js diff --git a/assets/src/bundles/vault/vault-create-tasks.js b/swh/web/vault/assets/vault-create-tasks.js similarity index 100% rename from assets/src/bundles/vault/vault-create-tasks.js rename to swh/web/vault/assets/vault-create-tasks.js diff --git a/assets/src/bundles/vault/vault-table-row.ejs b/swh/web/vault/assets/vault-table-row.ejs similarity index 100% rename from assets/src/bundles/vault/vault-table-row.ejs rename to swh/web/vault/assets/vault-table-row.ejs diff --git a/assets/src/bundles/vault/vault-ui.js b/swh/web/vault/assets/vault-ui.js similarity index 100% rename from assets/src/bundles/vault/vault-ui.js rename to swh/web/vault/assets/vault-ui.js diff --git a/assets/src/bundles/vault/vault.css b/swh/web/vault/assets/vault.css similarity index 100% rename from assets/src/bundles/vault/vault.css rename to swh/web/vault/assets/vault.css diff --git a/swh/web/vault/templates/vault-ui.html b/swh/web/vault/templates/vault-ui.html index 0b0cc7a0..6572db5d 100644 --- a/swh/web/vault/templates/vault-ui.html +++ b/swh/web/vault/templates/vault-ui.html @@ -1,51 +1,49 @@ {% extends "browse.html" %} {% comment %} Copyright (C) 2017-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} -{% load render_bundle from webpack_loader %} - {% block navbar-content %}

Download archived software

{% endblock %} {% block browse-content %}

This interface enables you to track the status of the different Software Heritage Vault cooking tasks created while browsing the archive.

Once a cooking task is finished, a link will be made available in order to download the associated archive.

Origin Bundle type Object info Cooking status
{% include "./includes/vault-common.html" %} {% endblock %} diff --git a/assets/src/bundles/vendors/datatables.css b/swh/web/webapp/assets/vendors/datatables.css similarity index 100% rename from assets/src/bundles/vendors/datatables.css rename to swh/web/webapp/assets/vendors/datatables.css diff --git a/assets/src/bundles/vendors/elementsfrompoint-polyfill.js b/swh/web/webapp/assets/vendors/elementsfrompoint-polyfill.js similarity index 100% rename from assets/src/bundles/vendors/elementsfrompoint-polyfill.js rename to swh/web/webapp/assets/vendors/elementsfrompoint-polyfill.js diff --git a/assets/src/bundles/vendors/index.js b/swh/web/webapp/assets/vendors/index.js similarity index 100% rename from assets/src/bundles/vendors/index.js rename to swh/web/webapp/assets/vendors/index.js diff --git a/assets/src/bundles/webapp/badges.js b/swh/web/webapp/assets/webapp/badges.js similarity index 100% rename from assets/src/bundles/webapp/badges.js rename to swh/web/webapp/assets/webapp/badges.js diff --git a/assets/src/bundles/webapp/breadcrumbs.css b/swh/web/webapp/assets/webapp/breadcrumbs.css similarity index 100% rename from assets/src/bundles/webapp/breadcrumbs.css rename to swh/web/webapp/assets/webapp/breadcrumbs.css diff --git a/assets/src/bundles/webapp/code-highlighting.js b/swh/web/webapp/assets/webapp/code-highlighting.js similarity index 100% rename from assets/src/bundles/webapp/code-highlighting.js rename to swh/web/webapp/assets/webapp/code-highlighting.js diff --git a/assets/src/bundles/webapp/coverage.css b/swh/web/webapp/assets/webapp/coverage.css similarity index 100% rename from assets/src/bundles/webapp/coverage.css rename to swh/web/webapp/assets/webapp/coverage.css diff --git a/assets/src/bundles/webapp/history-counters.css b/swh/web/webapp/assets/webapp/history-counters.css similarity index 100% rename from assets/src/bundles/webapp/history-counters.css rename to swh/web/webapp/assets/webapp/history-counters.css diff --git a/assets/src/bundles/webapp/history-counters.js b/swh/web/webapp/assets/webapp/history-counters.js similarity index 100% rename from assets/src/bundles/webapp/history-counters.js rename to swh/web/webapp/assets/webapp/history-counters.js diff --git a/assets/src/bundles/webapp/index.js b/swh/web/webapp/assets/webapp/index.js similarity index 100% rename from assets/src/bundles/webapp/index.js rename to swh/web/webapp/assets/webapp/index.js diff --git a/assets/src/bundles/webapp/math-typesetting.js b/swh/web/webapp/assets/webapp/math-typesetting.js similarity index 100% rename from assets/src/bundles/webapp/math-typesetting.js rename to swh/web/webapp/assets/webapp/math-typesetting.js diff --git a/assets/src/bundles/webapp/notebook-rendering.js b/swh/web/webapp/assets/webapp/notebook-rendering.js similarity index 100% rename from assets/src/bundles/webapp/notebook-rendering.js rename to swh/web/webapp/assets/webapp/notebook-rendering.js diff --git a/assets/src/bundles/webapp/notebook.css b/swh/web/webapp/assets/webapp/notebook.css similarity index 100% rename from assets/src/bundles/webapp/notebook.css rename to swh/web/webapp/assets/webapp/notebook.css diff --git a/assets/src/bundles/webapp/pdf-rendering.js b/swh/web/webapp/assets/webapp/pdf-rendering.js similarity index 100% rename from assets/src/bundles/webapp/pdf-rendering.js rename to swh/web/webapp/assets/webapp/pdf-rendering.js diff --git a/assets/src/bundles/webapp/readme-rendering.js b/swh/web/webapp/assets/webapp/readme-rendering.js similarity index 100% rename from assets/src/bundles/webapp/readme-rendering.js rename to swh/web/webapp/assets/webapp/readme-rendering.js diff --git a/assets/src/bundles/webapp/sentry.js b/swh/web/webapp/assets/webapp/sentry.js similarity index 100% rename from assets/src/bundles/webapp/sentry.js rename to swh/web/webapp/assets/webapp/sentry.js diff --git a/assets/src/bundles/webapp/status-widget.css b/swh/web/webapp/assets/webapp/status-widget.css similarity index 100% rename from assets/src/bundles/webapp/status-widget.css rename to swh/web/webapp/assets/webapp/status-widget.css diff --git a/assets/src/bundles/webapp/status-widget.js b/swh/web/webapp/assets/webapp/status-widget.js similarity index 100% rename from assets/src/bundles/webapp/status-widget.js rename to swh/web/webapp/assets/webapp/status-widget.js diff --git a/assets/src/bundles/webapp/webapp-utils.js b/swh/web/webapp/assets/webapp/webapp-utils.js similarity index 100% rename from assets/src/bundles/webapp/webapp-utils.js rename to swh/web/webapp/assets/webapp/webapp-utils.js diff --git a/assets/src/bundles/webapp/webapp.css b/swh/web/webapp/assets/webapp/webapp.css similarity index 100% rename from assets/src/bundles/webapp/webapp.css rename to swh/web/webapp/assets/webapp/webapp.css diff --git a/assets/src/bundles/webapp/xss-filtering.js b/swh/web/webapp/assets/webapp/xss-filtering.js similarity index 100% rename from assets/src/bundles/webapp/xss-filtering.js rename to swh/web/webapp/assets/webapp/xss-filtering.js