diff --git a/assets/src/bundles/admin/index.js b/assets/src/bundles/admin/index.js
--- a/assets/src/bundles/admin/index.js
+++ b/assets/src/bundles/admin/index.js
@@ -1,9 +1,10 @@
/**
- * 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
*/
export * from './deposit';
+export * from './mailmap';
export * from './origin-save';
diff --git a/assets/src/bundles/admin/mailmap-form.ejs b/assets/src/bundles/admin/mailmap-form.ejs
new file mode 100644
--- /dev/null
+++ b/assets/src/bundles/admin/mailmap-form.ejs
@@ -0,0 +1,29 @@
+<%#
+ 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
+%>
+
+
\ No newline at end of file
diff --git a/assets/src/bundles/admin/mailmap.js b/assets/src/bundles/admin/mailmap.js
new file mode 100644
--- /dev/null
+++ b/assets/src/bundles/admin/mailmap.js
@@ -0,0 +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/cypress/integration/mailmap.spec.js b/cypress/integration/mailmap.spec.js
new file mode 100644
--- /dev/null
+++ b/cypress/integration/mailmap.spec.js
@@ -0,0 +1,226 @@
+/**
+ * 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 $ = Cypress.$;
+
+function fillFormAndSubmitMailmap(fromEmail, displayName, activated) {
+ if (fromEmail) {
+ cy.get('#swh-mailmap-from-email')
+ .clear()
+ .type(fromEmail, {delay: 0, force: true});
+ }
+
+ if (displayName) {
+ cy.get('#swh-mailmap-display-name')
+ .clear()
+ .type(displayName, {delay: 0, force: true});
+ }
+
+ if (activated) {
+ cy.get('#swh-mailmap-display-name-activated')
+ .check({force: true});
+ } else {
+ cy.get('#swh-mailmap-display-name-activated')
+ .uncheck({force: true});
+ }
+
+ cy.get('#swh-mailmap-form-submit')
+ .click();
+}
+
+function addNewMailmap(fromEmail, displayName, activated) {
+ cy.get('#swh-add-new-mailmap')
+ .click();
+
+ fillFormAndSubmitMailmap(fromEmail, displayName, activated);
+}
+
+function updateMailmap(fromEmail, displayName, activated) {
+ cy.contains('Edit')
+ .click();
+
+ fillFormAndSubmitMailmap(fromEmail, displayName, activated);
+}
+
+function checkMailmapRow(fromEmail, displayName, activated,
+ processed = false, row = 1, nbRows = 1) {
+ cy.get('tbody tr').then(rows => {
+ assert.equal(rows.length, 1);
+ const cells = rows[0].cells;
+ assert.equal($(cells[0]).text(), fromEmail);
+ assert.include($(cells[1]).html(), 'mdi-check-bold');
+ assert.equal($(cells[2]).text(), displayName);
+ assert.include($(cells[3]).html(), activated ? 'mdi-check-bold' : 'mdi-close-thick');
+ assert.notEqual($(cells[4]).text(), '');
+ assert.include($(cells[5]).html(), processed ? 'mdi-check-bold' : 'mdi-close-thick');
+ });
+}
+
+describe('Test mailmap administration', function() {
+
+ before(function() {
+ this.url = this.Urls.admin_mailmap();
+ });
+
+ beforeEach(function() {
+ cy.task('db:user_mailmap:delete');
+ cy.intercept('POST', this.Urls.profile_mailmap_add())
+ .as('mailmapAdd');
+ cy.intercept('POST', this.Urls.profile_mailmap_update())
+ .as('mailmapUpdate');
+ cy.intercept(`${this.Urls.profile_mailmap_list_datatables()}**`)
+ .as('mailmapList');
+ });
+
+ it('should not display mailmap admin link in sidebar when anonymous', function() {
+ cy.visit(this.url);
+ cy.get('.swh-mailmap-admin-item')
+ .should('not.exist');
+ });
+
+ it('should not display mailmap admin link when connected as unprivileged user', function() {
+ cy.userLogin();
+ cy.visit(this.url);
+
+ cy.get('.swh-mailmap-admin-item')
+ .should('not.exist');
+
+ });
+
+ it('should display mailmap admin link in sidebar when connected as privileged user', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ cy.get('.swh-mailmap-admin-item')
+ .should('exist');
+ });
+
+ it('should not create a new mailmap when input data are empty', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ addNewMailmap('', '', true);
+
+ cy.get('#swh-mailmap-form :invalid').should('exist');
+
+ cy.get('#swh-mailmap-form')
+ .should('be.visible');
+
+ });
+
+ it('should not create a new mailmap when from email is invalid', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ addNewMailmap('invalid_email', 'display name', true);
+
+ cy.get('#swh-mailmap-form :invalid').should('exist');
+
+ cy.get('#swh-mailmap-form')
+ .should('be.visible');
+ });
+
+ it('should create a new mailmap when input data are valid', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ const fromEmail = 'user@example.org';
+ const displayName = 'New user display name';
+ addNewMailmap(fromEmail, displayName, true);
+ cy.wait('@mailmapAdd');
+
+ cy.get('#swh-mailmap-form :invalid').should('not.exist');
+
+ // ensure table redraw before next check
+ cy.contains(fromEmail);
+
+ cy.get('#swh-mailmap-form')
+ .should('not.be.visible');
+
+ checkMailmapRow(fromEmail, displayName, true);
+
+ });
+
+ it('should not create a new mailmap for an email already mapped', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ const fromEmail = 'user@example.org';
+ const displayName = 'New user display name';
+ addNewMailmap(fromEmail, displayName, true);
+ cy.wait('@mailmapAdd');
+
+ addNewMailmap(fromEmail, displayName, true);
+ cy.wait('@mailmapAdd');
+
+ cy.get('#swh-mailmap-form')
+ .should('not.be.visible');
+
+ cy.contains('Error')
+ .should('be.visible');
+
+ checkMailmapRow(fromEmail, displayName, true);
+
+ });
+
+ it('should update a mailmap', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ const fromEmail = 'user@example.org';
+ const displayName = 'New display name';
+ addNewMailmap(fromEmail, displayName, false);
+ cy.wait('@mailmapAdd');
+
+ cy.get('#swh-mailmap-form :invalid').should('not.exist');
+
+ // ensure table redraw before next check
+ cy.contains(fromEmail);
+
+ cy.get('#swh-mailmap-form')
+ .should('not.be.visible');
+
+ checkMailmapRow(fromEmail, displayName, false);
+
+ const newDisplayName = 'Updated display name';
+ updateMailmap('', newDisplayName, true);
+ cy.wait('@mailmapUpdate');
+
+ cy.get('#swh-mailmap-form :invalid').should('not.exist');
+
+ // ensure table redraw before next check
+ cy.contains(fromEmail);
+
+ cy.get('#swh-mailmap-form')
+ .should('not.be.visible');
+
+ checkMailmapRow(fromEmail, newDisplayName, true);
+
+ });
+
+ it('should indicate when a mailmap has been processed', function() {
+ cy.mailmapAdminLogin();
+ cy.visit(this.url);
+
+ const fromEmail = 'user@example.org';
+ const displayName = 'New user display name';
+ addNewMailmap(fromEmail, displayName, true);
+ cy.wait('@mailmapAdd');
+
+ // ensure table redraw before next check
+ cy.contains(fromEmail);
+
+ checkMailmapRow(fromEmail, displayName, true, false);
+
+ cy.task('db:user_mailmap:mark_processed');
+
+ cy.visit(this.url);
+ checkMailmapRow(fromEmail, displayName, true, true);
+
+ });
+
+});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -7,6 +7,7 @@
const axios = require('axios');
const fs = require('fs');
+const sqlite3 = require('sqlite3').verbose();
async function httpGet(url) {
const response = await axios.get(url);
@@ -32,6 +33,10 @@
};
};
+function getDatabase() {
+ return new sqlite3.Database('./swh-web-test.sqlite3');
+}
+
module.exports = (on, config) => {
require('@cypress/code-coverage/task')(on, config);
// produce JSON files prior launching browser in order to dynamically generate tests
@@ -124,6 +129,23 @@
global.swhTestsData = swhTestsData;
}
return global.swhTestsData;
+ },
+ 'db:user_mailmap:delete': () => {
+ const db = getDatabase();
+ db.serialize(function() {
+ db.run('DELETE FROM user_mailmap');
+ db.run('DELETE FROM user_mailmap_event');
+ });
+ db.close();
+ return true;
+ },
+ 'db:user_mailmap:mark_processed': () => {
+ const db = getDatabase();
+ db.serialize(function() {
+ db.run('UPDATE user_mailmap SET mailmap_last_processing_date=datetime("now", "+1 hour")');
+ });
+ db.close();
+ return true;
}
});
return config;
diff --git a/cypress/support/index.js b/cypress/support/index.js
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -84,6 +84,9 @@
body: ''
}).as('swhCoverageWidget');
}
+Cypress.Commands.add('mailmapAdminLogin', () => {
+ return loginUser('mailmap-admin', 'mailmap-admin');
+});
before(function() {
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -145,6 +145,7 @@
"schema-utils": "^4.0.0",
"script-loader": "^0.7.2",
"spdx-expression-parse": "^3.0.1",
+ "sqlite3": "^5.0.2",
"style-loader": "^3.3.1",
"stylelint": "^14.5.3",
"stylelint-config-standard": "^25.0.0",
diff --git a/swh/web/admin/mailmap.py b/swh/web/admin/mailmap.py
new file mode 100644
--- /dev/null
+++ b/swh/web/admin/mailmap.py
@@ -0,0 +1,16 @@
+# 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.contrib.auth.decorators import permission_required
+from django.shortcuts import render
+
+from swh.web.admin.adminurls import admin_route
+from swh.web.auth.utils import MAILMAP_ADMIN_PERMISSION
+
+
+@admin_route(r"mailmap/", view_name="admin-mailmap")
+@permission_required(MAILMAP_ADMIN_PERMISSION)
+def _admin_mailmap(request):
+ return render(request, "admin/mailmap.html")
diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py
--- a/swh/web/admin/urls.py
+++ b/swh/web/admin/urls.py
@@ -9,6 +9,7 @@
from swh.web.admin.adminurls import AdminUrls
import swh.web.admin.deposit # noqa
+import swh.web.admin.mailmap # noqa
import swh.web.admin.origin_save # noqa
from swh.web.config import is_feature_enabled
diff --git a/swh/web/auth/mailmap.py b/swh/web/auth/mailmap.py
--- a/swh/web/auth/mailmap.py
+++ b/swh/web/auth/mailmap.py
@@ -4,15 +4,18 @@
# See top-level LICENSE file for more information
import json
+from typing import Any, Dict
from django.conf.urls import url
+from django.core.paginator import Paginator
from django.db import IntegrityError
from django.db.models import Q
+from django.http.request import HttpRequest
from django.http.response import (
HttpResponse,
HttpResponseBadRequest,
- HttpResponseForbidden,
HttpResponseNotFound,
+ JsonResponse,
)
from rest_framework import serializers
from rest_framework.decorators import api_view
@@ -20,7 +23,11 @@
from rest_framework.response import Response
from swh.web.auth.models import UserMailmap, UserMailmapEvent
-from swh.web.auth.utils import MAILMAP_PERMISSION
+from swh.web.auth.utils import (
+ MAILMAP_ADMIN_PERMISSION,
+ MAILMAP_PERMISSION,
+ any_permission_required,
+)
class UserMailmapSerializer(serializers.ModelSerializer):
@@ -30,18 +37,20 @@
@api_view(["GET"])
+@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION)
def profile_list_mailmap(request: Request) -> HttpResponse:
- if not request.user.has_perm(MAILMAP_PERMISSION):
- return HttpResponseForbidden()
+ mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION)
- mms = UserMailmap.objects.filter(user_id=str(request.user.id),).all()
+ mms = UserMailmap.objects.filter(
+ user_id=None if mailmap_admin else str(request.user.id)
+ ).all()
return Response(UserMailmapSerializer(mms, many=True).data)
@api_view(["POST"])
+@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION)
def profile_add_mailmap(request: Request) -> HttpResponse:
- if not request.user.has_perm(MAILMAP_PERMISSION):
- return HttpResponseForbidden()
+ mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION)
event = UserMailmapEvent.objects.create(
user_id=str(request.user.id),
@@ -51,29 +60,46 @@
from_email = request.data.pop("from_email", None)
if not from_email:
- return HttpResponseBadRequest("'from_email' must be provided and non-empty.")
+ return HttpResponseBadRequest(
+ "'from_email' must be provided and non-empty.", content_type="text/plain"
+ )
+
+ user_id = None if mailmap_admin else str(request.user.id)
+
+ from_email_verified = request.data.pop("from_email_verified", False)
+ if mailmap_admin:
+ # consider email verified when mailmap is added by admin
+ from_email_verified = True
try:
UserMailmap.objects.create(
- user_id=str(request.user.id), from_email=from_email, **request.data
+ user_id=user_id,
+ from_email=from_email,
+ from_email_verified=from_email_verified,
+ **request.data,
)
except IntegrityError as e:
- if "user_mailmap_from_email_key" in e.args[0]:
- return HttpResponseBadRequest("This 'from_email' already exists.")
+ if (
+ "user_mailmap_from_email_key" in e.args[0]
+ or "user_mailmap.from_email" in e.args[0]
+ ):
+ return HttpResponseBadRequest(
+ "This 'from_email' already exists.", content_type="text/plain"
+ )
else:
raise
event.successful = True
event.save()
- mm = UserMailmap.objects.get(user_id=str(request.user.id), from_email=from_email)
+ mm = UserMailmap.objects.get(user_id=user_id, from_email=from_email)
return Response(UserMailmapSerializer(mm).data)
@api_view(["POST"])
+@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION)
def profile_update_mailmap(request: Request) -> HttpResponse:
- if not request.user.has_perm(MAILMAP_PERMISSION):
- return HttpResponseForbidden()
+ mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION)
event = UserMailmapEvent.objects.create(
user_id=str(request.user.id),
@@ -83,18 +109,20 @@
from_email = request.data.pop("from_email", None)
if not from_email:
- return HttpResponseBadRequest("'from_email' must be provided and non-empty.")
+ return HttpResponseBadRequest(
+ "'from_email' must be provided and non-empty.", content_type="text/plain"
+ )
- user_id = str(request.user.id)
+ user_id = None if mailmap_admin else str(request.user.id)
try:
to_update = (
- UserMailmap.objects.filter(Q(user_id__isnull=True) | Q(user_id=user_id))
+ UserMailmap.objects.filter(user_id=user_id)
.filter(from_email=from_email)
.get()
)
except UserMailmap.DoesNotExist:
- return HttpResponseNotFound()
+ return HttpResponseNotFound("'from_email' cannot be found in mailmaps.")
for attr, value in request.data.items():
setattr(to_update, attr, value)
@@ -108,6 +136,50 @@
return Response(UserMailmapSerializer(mm).data)
+@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION)
+def profile_list_mailmap_datatables(request: HttpRequest) -> HttpResponse:
+ mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION)
+
+ mailmaps = UserMailmap.objects.filter(
+ user_id=None if mailmap_admin else str(request.user.id)
+ )
+
+ search_value = request.GET.get("search[value]", "")
+
+ column_order = request.GET.get("order[0][column]")
+ field_order = request.GET.get(f"columns[{column_order}][name]", "from_email")
+ order_dir = request.GET.get("order[0][dir]", "asc")
+ if order_dir == "desc":
+ field_order = "-" + field_order
+
+ mailmaps = mailmaps.order_by(field_order)
+
+ table_data: Dict[str, Any] = {}
+ table_data["draw"] = int(request.GET.get("draw", 1))
+ table_data["recordsTotal"] = mailmaps.count()
+
+ length = int(request.GET.get("length", 10))
+ page = int(request.GET.get("start", 0)) / length + 1
+
+ if search_value:
+ mailmaps = mailmaps.filter(
+ Q(from_email__icontains=search_value)
+ | Q(display_name__icontains=search_value)
+ )
+
+ table_data["recordsFiltered"] = mailmaps.count()
+
+ paginator = Paginator(mailmaps, length)
+
+ mailmaps_data = [
+ UserMailmapSerializer(mm).data for mm in paginator.page(int(page)).object_list
+ ]
+
+ table_data["data"] = mailmaps_data
+
+ return JsonResponse(table_data)
+
+
urlpatterns = [
url(r"^profile/mailmap/list/$", profile_list_mailmap, name="profile-mailmap-list",),
url(r"^profile/mailmap/add/$", profile_add_mailmap, name="profile-mailmap-add",),
@@ -116,4 +188,9 @@
profile_update_mailmap,
name="profile-mailmap-update",
),
+ url(
+ r"^profile/mailmap/list/datatables/$",
+ profile_list_mailmap_datatables,
+ name="profile-mailmap-list-datatables",
+ ),
]
diff --git a/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py b/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py
new file mode 100644
--- /dev/null
+++ b/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py
@@ -0,0 +1,41 @@
+# 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 datetime
+
+from django.db import migrations
+
+
+def _set_first_mailmaps_as_edited_by_admin(apps, schema_editor):
+ """First mailmaps in production database have been created by a user
+ with "swh.web.mailmap" permission because no "swh.web.admin.mailmap"
+ permission existed at the time.
+
+ So change user_id to None to indicate these mailmaps have been created
+ by a mailmap administrator.
+ """
+ UserMailmap = apps.get_model("swh_web_auth", "UserMailmap")
+
+ for mailmap in UserMailmap.objects.filter(
+ last_update_date__lte=datetime.datetime(
+ 2022, 2, 12
+ ) # first mailmaps added on 2022/2/11 in production
+ ):
+ if mailmap.user_id is not None:
+ mailmap.user_id = None
+ mailmap.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("swh_web_auth", "0005_usermailmapevent"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ _set_first_mailmaps_as_edited_by_admin, migrations.RunPython.noop
+ ),
+ ]
diff --git a/swh/web/auth/utils.py b/swh/web/auth/utils.py
--- a/swh/web/auth/utils.py
+++ b/swh/web/auth/utils.py
@@ -11,8 +11,11 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+from django.contrib.auth.decorators import user_passes_test
from django.http.request import HttpRequest
+from swh.web.common.exc import ForbiddenExc
+
OIDC_SWH_WEB_CLIENT_ID = "swh-web"
SWH_AMBASSADOR_PERMISSION = "swh.ambassador"
@@ -20,6 +23,7 @@
ADMIN_LIST_DEPOSIT_PERMISSION = "swh.web.admin.list_deposits"
MAILMAP_PERMISSION = "swh.web.mailmap"
ADD_FORGE_MODERATOR_PERMISSION = "swh.web.add_forge_now.moderator"
+MAILMAP_ADMIN_PERMISSION = "swh.web.admin.mailmap"
def _get_fernet(password: bytes, salt: bytes) -> Fernet:
@@ -96,3 +100,16 @@
return user.is_authenticated and (
user.is_staff or any([user.has_perm(perm) for perm in permissions])
)
+
+
+def any_permission_required(*perms):
+ """View decorator granting access to it if user has at least one
+ permission among those passed as parameters.
+ """
+
+ def check_perms(user):
+ if any(user.has_perm(perm) for perm in perms):
+ return True
+ raise ForbiddenExc
+
+ return user_passes_test(check_perms)
diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py
--- a/swh/web/common/utils.py
+++ b/swh/web/common/utils.py
@@ -30,6 +30,7 @@
from swh.web.auth.utils import (
ADD_FORGE_MODERATOR_PERMISSION,
ADMIN_LIST_DEPOSIT_PERMISSION,
+ MAILMAP_ADMIN_PERMISSION,
)
from swh.web.common.exc import BadInputExc
from swh.web.common.typing import QueryParameters
@@ -316,6 +317,7 @@
"ADMIN_LIST_DEPOSIT_PERMISSION": ADMIN_LIST_DEPOSIT_PERMISSION,
"ADD_FORGE_MODERATOR_PERMISSION": ADD_FORGE_MODERATOR_PERMISSION,
"FEATURES": get_config()["features"],
+ "MAILMAP_ADMIN_PERMISSION": MAILMAP_ADMIN_PERMISSION,
}
diff --git a/swh/web/templates/admin/mailmap.html b/swh/web/templates/admin/mailmap.html
new file mode 100644
--- /dev/null
+++ b/swh/web/templates/admin/mailmap.html
@@ -0,0 +1,53 @@
+{% 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' %}
+{% 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.
+