v{{ swh_web_version }}
v{{ swh_web_version|split:"+"|first }}
diff --git a/assets/src/bundles/add_forge/index.js b/assets/src/bundles/add_forge/index.js index 7374a477..87fc3e70 100644 --- a/assets/src/bundles/add_forge/index.js +++ b/assets/src/bundles/add_forge/index.js @@ -1,10 +1,11 @@ /** * 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 */ // bundle for add forge views export * from './create-request'; +export * from './moderation-dashboard'; diff --git a/assets/src/bundles/add_forge/moderation-dashboard.js b/assets/src/bundles/add_forge/moderation-dashboard.js new file mode 100644 index 00000000..0482a7b0 --- /dev/null +++ b/assets/src/bundles/add_forge/moderation-dashboard.js @@ -0,0 +1,55 @@ +/** + * 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 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, + info: false, + 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: $.fn.dataTable.render.text() + }, + { + data: 'submission_date', + name: 'submission_date', + render: $.fn.dataTable.render.text() + }, + { + data: 'forge_type', + name: 'forge_type', + render: $.fn.dataTable.render.text() + }, + { + data: 'forge_url', + name: 'forge_url', + render: $.fn.dataTable.render.text() + }, + { + data: 'status', + name: 'status', + render: $.fn.dataTable.render.text() + } + ] + }); +} diff --git a/cypress/fixtures/add-forge-now-requests.json b/cypress/fixtures/add-forge-now-requests.json new file mode 100644 index 00000000..59c676b4 --- /dev/null +++ b/cypress/fixtures/add-forge-now-requests.json @@ -0,0 +1,79 @@ +{ + "recordsTotal": 6, + "draw": 1, + "recordsFiltered": 6, + "data": [ + { + "id": 1, + "status": "PENDING", + "submission_date": "2022-03-09T14:06:09.092714Z", + "submitter_name": "user", + "submitter_email": "user@example.org", + "forge_type": "cgit", + "forge_url": "cgit.org", + "forge_contact_email": "cgit@cgit.org", + "forge_contact_name": "cgit", + "forge_contact_comment": "please" + }, + { + "id": 2, + "status": "PENDING", + "submission_date": "2022-03-09T14:07:01.442033Z", + "submitter_name": "user", + "submitter_email": "user@example.org", + "forge_type": "cgit", + "forge_url": "cgit2.org", + "forge_contact_email": "cgit2@cgit.org", + "forge_contact_name": "cgit2", + "forge_contact_comment": "please" + }, + { + "id": 3, + "status": "ACCEPTED", + "submission_date": "2022-03-11T14:53:58.576374Z", + "submitter_name": "admin", + "submitter_email": "admin@example.org", + "forge_type": "gitlab", + "forge_url": "https://gitlab-stuff.org", + "forge_contact_email": "admin@gitlab-stuff.org", + "forge_contact_name": "admin", + "forge_contact_comment": "hello" + }, + { + "id": 4, + "status": "PENDING", + "submission_date": "2022-03-15T08:53:29.845342Z", + "submitter_name": "admin", + "submitter_email": "admin@example.org", + "forge_type": "gitlab", + "forge_url": "https://gitlab.com/blah/dot-files", + "forge_contact_email": "blah@org.org", + "forge_contact_name": "blah", + "forge_contact_comment": "blah" + }, + { + "id": 5, + "status": "PENDING", + "submission_date": "2022-03-15T08:54:58.254710Z", + "submitter_name": "admin", + "submitter_email": "admin@example.org", + "forge_type": "heptapod", + "forge_url": "heptapod0", + "forge_contact_email": "pod@hepta.org", + "forge_contact_name": "hepta", + "forge_contact_comment": "heh" + }, + { + "id": 6, + "status": "PENDING", + "submission_date": "2022-03-15T08:55:16.984753Z", + "submitter_name": "admin", + "submitter_email": "admin@example.org", + "forge_type": "heptapod", + "forge_url": "heptapod1", + "forge_contact_email": "pod@hepta1.org", + "forge_contact_name": "hepta1", + "forge_contact_comment": "hi" + } + ] +} diff --git a/cypress/integration/add-forge-now-requests-moderation.spec.js b/cypress/integration/add-forge-now-requests-moderation.spec.js new file mode 100644 index 00000000..5eeec3d0 --- /dev/null +++ b/cypress/integration/add-forge-now-requests-moderation.spec.js @@ -0,0 +1,122 @@ +/** + * 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 + */ + +const defaultRedirect = '/admin/login/'; + +let addForgeModerationUrl; +let listAddForgeRequestsUrl; + +function logout() { + cy.contains('a', 'logout') + .click(); +} + +describe('Test "Add Forge Now" moderation Login/logout', function() { + before(function() { + addForgeModerationUrl = this.Urls.add_forge_now_requests_moderation(); + }); + + it('should redirect to default page', function() { + cy.visit(addForgeModerationUrl) + .get('input[name="username"]') + .type('admin') + .get('input[name="password"]') + .type('admin') + .get('.container form') + .submit(); + + cy.location('pathname') + .should('be.equal', addForgeModerationUrl); + }); + + it('should redirect to correct page after login', function() { + cy.visit(addForgeModerationUrl) + .location('pathname') + .should('be.equal', defaultRedirect); + + cy.adminLogin(); + cy.visit(addForgeModerationUrl) + .location('pathname') + .should('be.equal', addForgeModerationUrl); + + logout(); + }); + + it('should not display moderation link in sidebar when anonymous', function() { + cy.visit(addForgeModerationUrl); + cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`) + .should('not.exist'); + }); + + it('should not display moderation link when connected as unprivileged user', function() { + cy.userLogin(); + cy.visit(addForgeModerationUrl); + + cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`) + .should('not.exist'); + + }); + + it('should display moderation link in sidebar when connected as privileged user', function() { + cy.addForgeModeratorLogin(); + cy.visit(addForgeModerationUrl); + + cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`) + .should('exist'); + }); + + it('should display moderation link in sidebar when connected as staff member', function() { + cy.adminLogin(); + cy.visit(addForgeModerationUrl); + + cy.get(`.sidebar a[href="${addForgeModerationUrl}"]`) + .should('exist'); + }); +}); + +describe('Test "Add Forge Now" moderation listing', function() { + before(function() { + addForgeModerationUrl = this.Urls.add_forge_now_requests_moderation(); + listAddForgeRequestsUrl = this.Urls.add_forge_request_list_datatables(); + }); + + it('should list add-forge-now requests', function() { + cy.intercept(`${listAddForgeRequestsUrl}**`, {fixture: 'add-forge-now-requests'}).as('listRequests'); + + let expectedRequests; + cy.readFile('cypress/fixtures/add-forge-now-requests.json').then((result) => { + expectedRequests = result['data']; + }); + + cy.addForgeModeratorLogin(); + cy.visit(addForgeModerationUrl); + + cy.wait('@listRequests').then((xhr) => { + cy.log('response:', xhr.response); + cy.log(xhr.response.body); + const requests = xhr.response.body.data; + cy.log('Requests: ', requests); + expect(requests.length).to.equal(expectedRequests.length); + + cy.get('#swh-add-forge-now-moderation-list').find('tbody > tr').as('rows'); + + // only 2 entries + cy.get('@rows').each((row, idx, collection) => { + const request = requests[idx]; + const expectedRequest = expectedRequests[idx]; + assert.isNotNull(request); + assert.isNotNull(expectedRequest); + expect(request.id).to.be.equal(expectedRequest['id']); + expect(request.status).to.be.equal(expectedRequest['status']); + expect(request.submission_date).to.be.equal(expectedRequest['submission_date']); + expect(request.forge_type).to.be.equal(expectedRequest['forge_type']); + expect(request.forge_url).to.be.equal(expectedRequest['forge_url']); + }); + }); + }); + +}); diff --git a/cypress/support/index.js b/cypress/support/index.js index 486209c6..15a3ad85 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -1,99 +1,103 @@ /** * 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 */ import 'cypress-hmr-restarter'; import '@cypress/code-coverage/support'; Cypress.Screenshot.defaults({ screenshotOnRunFailure: false }); Cypress.Commands.add('xhrShouldBeCalled', (alias, timesCalled) => { const testRoutes = cy.state('routes'); const aliasRoute = Cypress._.find(testRoutes, {alias}); expect(Object.keys(aliasRoute.requests || {})).to.have.length(timesCalled); }); function loginUser(username, password) { const url = '/admin/login/'; return cy.request({ url: url, method: 'GET' }).then(() => { cy.getCookie('sessionid').should('not.exist'); cy.getCookie('csrftoken').its('value').then((token) => { cy.request({ url: url, method: 'POST', form: true, followRedirect: false, body: { username: username, password: password, csrfmiddlewaretoken: token } }).then(() => { cy.getCookie('sessionid').should('exist'); return cy.getCookie('csrftoken').its('value'); }); }); }); } Cypress.Commands.add('adminLogin', () => { return loginUser('admin', 'admin'); }); Cypress.Commands.add('userLogin', () => { return loginUser('user', 'user'); }); Cypress.Commands.add('ambassadorLogin', () => { return loginUser('ambassador', 'ambassador'); }); Cypress.Commands.add('depositLogin', () => { return loginUser('deposit', 'deposit'); }); +Cypress.Commands.add('addForgeModeratorLogin', () => { + return loginUser('add-forge-moderator', 'add-forge-moderator'); +}); + function mockCostlyRequests() { cy.intercept('https://status.softwareheritage.org/**', { body: { 'result': { 'status': [ { 'id': '5f7c4c567f50b304c1e7bd5f', 'name': 'Save Code Now', 'updated': '2020-11-30T13:51:21.151Z', 'status': 'Operational', 'status_code': 100 } ] } }}).as('swhPlatformStatus'); cy.intercept('/coverage', { body: '' }).as('swhCoverageWidget'); } before(function() { mockCostlyRequests(); cy.task('getSwhTestsData').then(testsData => { Object.assign(this, testsData); }); cy.visit('/').window().then(async win => { this.Urls = win.Urls; }); }); beforeEach(function() { mockCostlyRequests(); }); diff --git a/swh/web/admin/add_forge_now.py b/swh/web/admin/add_forge_now.py new file mode 100644 index 00000000..4d4b2186 --- /dev/null +++ b/swh/web/admin/add_forge_now.py @@ -0,0 +1,30 @@ +# 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 + +from django.conf import settings +from django.contrib.auth.decorators import user_passes_test +from django.shortcuts import render + +from swh.web.admin.adminurls import admin_route +from swh.web.auth.utils import ADD_FORGE_MODERATOR_PERMISSION + + +def _can_access_moderation(user): + return user.is_staff or user.has_perm(ADD_FORGE_MODERATOR_PERMISSION) + + +@admin_route( + r"add-forge/requests/", view_name="add-forge-now-requests-moderation", +) +@user_passes_test(_can_access_moderation, login_url=settings.LOGIN_URL) +def add_forge_now_requests_moderation_dashboard(request): + """Moderation dashboard to allow listing current requests. + + """ + return render( + request, + "add_forge_now/requests-moderation.html", + {"heading": "Add forge now requests moderation"}, + ) diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py index 5e9b8876..2140ea9d 100644 --- a/swh/web/admin/urls.py +++ b/swh/web/admin/urls.py @@ -1,24 +1,25 @@ -# Copyright (C) 2018 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 from django.conf.urls import url from django.contrib.auth.views import LoginView from django.shortcuts import redirect +import swh.web.admin.add_forge_now # noqa from swh.web.admin.adminurls import AdminUrls import swh.web.admin.deposit # noqa import swh.web.admin.origin_save # noqa def _admin_default_view(request): return redirect("admin-origin-save") urlpatterns = [ url(r"^$", _admin_default_view, name="admin"), url(r"^login/$", LoginView.as_view(template_name="login.html"), name="login"), ] urlpatterns += AdminUrls.get_url_patterns() diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py index 85d0df5f..8a0a73c5 100644 --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -1,518 +1,522 @@ # 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 from datetime import datetime, timezone import os import re from typing import Any, Dict, List, Optional import urllib.parse from xml.etree import ElementTree from bs4 import BeautifulSoup from docutils.core import publish_parts import docutils.parsers.rst import docutils.utils from docutils.writers.html5_polyglot import HTMLTranslator, Writer from iso8601 import ParseError, parse_date from pkg_resources import get_distribution from prometheus_client.registry import CollectorRegistry import requests from requests.auth import HTTPBasicAuth from django.core.cache import cache from django.http import HttpRequest, QueryDict from django.shortcuts import redirect from django.urls import resolve from django.urls import reverse as django_reverse -from swh.web.auth.utils import ADMIN_LIST_DEPOSIT_PERMISSION +from swh.web.auth.utils import ( + ADD_FORGE_MODERATOR_PERMISSION, + ADMIN_LIST_DEPOSIT_PERMISSION, +) from swh.web.common.exc import BadInputExc from swh.web.common.typing import QueryParameters from swh.web.config import SWH_WEB_SERVER_NAME, get_config, search SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True) swh_object_icons = { "alias": "mdi mdi-star", "branch": "mdi mdi-source-branch", "branches": "mdi mdi-source-branch", "content": "mdi mdi-file-document", "cnt": "mdi mdi-file-document", "directory": "mdi mdi-folder", "dir": "mdi mdi-folder", "origin": "mdi mdi-source-repository", "ori": "mdi mdi-source-repository", "person": "mdi mdi-account", "revisions history": "mdi mdi-history", "release": "mdi mdi-tag", "rel": "mdi mdi-tag", "releases": "mdi mdi-tag", "revision": "mdi mdi-rotate-90 mdi-source-commit", "rev": "mdi mdi-rotate-90 mdi-source-commit", "snapshot": "mdi mdi-camera", "snp": "mdi mdi-camera", "visits": "mdi mdi-calendar-month", } def reverse( viewname: str, url_args: Optional[Dict[str, Any]] = None, query_params: Optional[QueryParameters] = None, current_app: Optional[str] = None, urlconf: Optional[str] = None, request: Optional[HttpRequest] = None, ) -> str: """An override of django reverse function supporting query parameters. Args: viewname: the name of the django view from which to compute a url url_args: dictionary of url arguments indexed by their names query_params: dictionary of query parameters to append to the reversed url current_app: the name of the django app tighten to the view urlconf: url configuration module request: build an absolute URI if provided Returns: str: the url of the requested view with processed arguments and query parameters """ if url_args: url_args = {k: v for k, v in url_args.items() if v is not None} url = django_reverse( viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app ) if query_params: query_params = {k: v for k, v in query_params.items() if v is not None} if query_params and len(query_params) > 0: query_dict = QueryDict("", mutable=True) for k in sorted(query_params.keys()): query_dict[k] = query_params[k] url += "?" + query_dict.urlencode(safe="/;:") if request is not None: url = request.build_absolute_uri(url) return url def datetime_to_utc(date): """Returns datetime in UTC without timezone info Args: date (datetime.datetime): input datetime with timezone info Returns: datetime.datetime: datetime in UTC without timezone info """ if date.tzinfo and date.tzinfo != timezone.utc: return date.astimezone(tz=timezone.utc) else: return date def parse_iso8601_date_to_utc(iso_date: str) -> datetime: """Given an ISO 8601 datetime string, parse the result as UTC datetime. Returns: a timezone-aware datetime representing the parsed date Raises: swh.web.common.exc.BadInputExc: provided date does not respect ISO 8601 format Samples: - 2016-01-12 - 2016-01-12T09:19:12+0100 - 2007-01-14T20:34:22Z """ try: date = parse_date(iso_date) return datetime_to_utc(date) except ParseError as e: raise BadInputExc(e) def shorten_path(path): """Shorten the given path: for each hash present, only return the first 8 characters followed by an ellipsis""" sha256_re = r"([0-9a-f]{8})[0-9a-z]{56}" sha1_re = r"([0-9a-f]{8})[0-9a-f]{32}" ret = re.sub(sha256_re, r"\1...", path) return re.sub(sha1_re, r"\1...", ret) def format_utc_iso_date(iso_date, fmt="%d %B %Y, %H:%M UTC"): """Turns a string representation of an ISO 8601 datetime string to UTC and format it into a more human readable one. For instance, from the following input string: '2017-05-04T13:27:13+02:00' the following one is returned: '04 May 2017, 11:27 UTC'. Custom format string may also be provided as parameter Args: iso_date (str): a string representation of an ISO 8601 date fmt (str): optional date formatting string Returns: str: a formatted string representation of the input iso date """ if not iso_date: return iso_date date = parse_iso8601_date_to_utc(iso_date) return date.strftime(fmt) def gen_path_info(path): """Function to generate path data navigation for use with a breadcrumb in the swh web ui. For instance, from a path /folder1/folder2/folder3, it returns the following list:: [{'name': 'folder1', 'path': 'folder1'}, {'name': 'folder2', 'path': 'folder1/folder2'}, {'name': 'folder3', 'path': 'folder1/folder2/folder3'}] Args: path: a filesystem path Returns: list: a list of path data for navigation as illustrated above. """ path_info = [] if path: sub_paths = path.strip("/").split("/") path_from_root = "" for p in sub_paths: path_from_root += "/" + p path_info.append({"name": p, "path": path_from_root.strip("/")}) return path_info def parse_rst(text, report_level=2): """ Parse a reStructuredText string with docutils. Args: text (str): string with reStructuredText markups in it report_level (int): level of docutils report messages to print (1 info 2 warning 3 error 4 severe 5 none) Returns: docutils.nodes.document: a parsed docutils document """ parser = docutils.parsers.rst.Parser() components = (docutils.parsers.rst.Parser,) settings = docutils.frontend.OptionParser( components=components ).get_default_values() settings.report_level = report_level document = docutils.utils.new_document("rst-doc", settings=settings) parser.parse(text, document) return document def get_client_ip(request): """ Return the client IP address from an incoming HTTP request. Args: request (django.http.HttpRequest): the incoming HTTP request Returns: str: The client IP address """ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: ip = x_forwarded_for.split(",")[0] else: ip = request.META.get("REMOTE_ADDR") return ip def is_swh_web_development(request: HttpRequest) -> bool: """Indicate if we are running a development version of swh-web. """ site_base_url = request.build_absolute_uri("/") return any( host in site_base_url for host in ("localhost", "127.0.0.1", "testserver") ) def is_swh_web_staging(request: HttpRequest) -> bool: """Indicate if we are running a staging version of swh-web. """ config = get_config() site_base_url = request.build_absolute_uri("/") return any( server_name in site_base_url for server_name in config["staging_server_names"] ) def is_swh_web_production(request: HttpRequest) -> bool: """Indicate if we are running the public production version of swh-web. """ return SWH_WEB_SERVER_NAME in request.build_absolute_uri("/") browsers_supported_image_mimes = set( [ "image/gif", "image/png", "image/jpeg", "image/bmp", "image/webp", "image/svg", "image/svg+xml", ] ) def context_processor(request): """ Django context processor used to inject variables in all swh-web templates. """ config = get_config() if ( hasattr(request, "user") and request.user.is_authenticated and not hasattr(request.user, "backend") ): # To avoid django.template.base.VariableDoesNotExist errors # when rendering templates when standard Django user is logged in. request.user.backend = "django.contrib.auth.backends.ModelBackend" return { "swh_object_icons": swh_object_icons, "available_languages": None, "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, "keycloak": config["keycloak"], "site_base_url": request.build_absolute_uri("/"), "DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"], "status": config["status"], "swh_web_dev": is_swh_web_development(request), "swh_web_staging": is_swh_web_staging(request), "swh_web_version": get_distribution("swh.web").version, "iframe_mode": False, "ADMIN_LIST_DEPOSIT_PERMISSION": ADMIN_LIST_DEPOSIT_PERMISSION, + "ADD_FORGE_MODERATOR_PERMISSION": ADD_FORGE_MODERATOR_PERMISSION, } def resolve_branch_alias( snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """ Resolve branch alias in snapshot content. Args: snapshot: a full snapshot content branch: a branch alias contained in the snapshot Returns: The real snapshot branch that got aliased. """ while branch and branch["target_type"] == "alias": if branch["target"] in snapshot["branches"]: branch = snapshot["branches"][branch["target"]] else: from swh.web.common import archive snp = archive.lookup_snapshot( snapshot["id"], branches_from=branch["target"], branches_count=1 ) if snp and branch["target"] in snp["branches"]: branch = snp["branches"][branch["target"]] else: branch = None return branch class _NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] _HTML_WRITER = Writer() _HTML_WRITER.translator_class = _NoHeaderHTMLTranslator def rst_to_html(rst: str) -> str: """ Convert reStructuredText document into HTML. Args: rst: A string containing a reStructuredText document Returns: Body content of the produced HTML conversion. """ settings = { "initial_header_level": 2, "halt_level": 4, "traceback": True, "file_insertion_enabled": False, "raw_enabled": False, } pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings) return f'
ID | +Submission date | +Forge type | +Forge URL | +Status | +
---|