Fix bug 1680056: Implement community health dashboard frontend (#1776)

Also:
* Add optional `ENABLE_INSIGHTS_TAB` setting to enable the Insights tab
* Make code play more nicely with black
This commit is contained in:
Matjaž Horvat 2020-12-21 21:42:50 +01:00 коммит произвёл GitHub
Родитель db4dd60e60
Коммит 847e43c377
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
17 изменённых файлов: 21967 добавлений и 31 удалений

Просмотреть файл

@ -37,6 +37,7 @@ module.exports = {
Pontoon: false,
jQuery: false,
Clipboard: false,
Chart: false,
NProgress: false,
diff_match_patch: false,
Highcharts: false,

Просмотреть файл

@ -2,6 +2,7 @@ SECRET_KEY=insert_random_key
DJANGO_DEV=True
DJANGO_DEBUG=True
DATABASE_URL=postgres://pontoon:asdf@postgresql/pontoon
ENABLE_INSIGHTS_TAB=True
SESSION_COOKIE_SECURE=False
SITE_URL=#SITE_URL#
FXA_CLIENT_ID=2651b9211a44b7b2

Просмотреть файл

@ -103,6 +103,12 @@ you create:
Optional. Enables Bugs tab on team pages, which pulls team data from
bugzilla.mozilla.org. Specific for Mozilla deployments.
``ENABLE_INSIGHTS_TAB``
Optional. Enables Insights tab on team pages, which presents data that needs
to be collected by the :ref:`collect-insights` scheduled job. It is advised
to run the job at least once before enabling the tab, otherwise the content
will be empty. See `the spec`_ for more information.
``ERROR_PAGE_URL``
Optional. URL to the page displayed to your users when the application encounters
a system error. See `Heroku Reference`_ for more information.
@ -212,6 +218,7 @@ you create:
``VCS_SYNC_EMAIL``
Optional. Default committer's email used when committing translations to version control system.
.. _the spec: https://github.com/mozilla/pontoon/blob/master/specs/0108-community-health-dashboard.md
.. _Heroku Reference: https://devcenter.heroku.com/articles/error-pages#customize-pages
.. _Firefox Accounts: https://developer.mozilla.org/docs/Mozilla/Tech/Firefox_Accounts/Introduction
.. _Microsoft Translator API key: http://msdn.microsoft.com/en-us/library/hh454950
@ -307,6 +314,8 @@ notifications are sent again. The command is designed to run daily.
./manage.py send_deadline_notifications
.. _collect-insights:
Collect Insights
~~~~~~~~~~~~~~~~
The Insights tab in the dashboards presents data that cannot be retrieved from

Просмотреть файл

@ -1040,7 +1040,7 @@ nav .links li {
}
.submenu.tabs .links a {
padding: 12px 30px;
padding: 12px 25px;
width: 100%;
-moz-box-sizing: border-box;

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -76,6 +76,10 @@ $(function () {
updateTabCount(tab, count);
}
if (url.startsWith('/insights/')) {
Pontoon.insights.initialize();
}
if (url === '/') {
$('.controls input').focus();
}

Просмотреть файл

@ -0,0 +1,231 @@
#insights .half {
width: 470px;
float: left;
}
#insights .half:last-child {
float: right;
}
#insights .block {
border-radius: 6px;
background: #333941;
margin-bottom: 40px;
padding: 30px;
}
#insights h3 {
color: #ebebeb;
font-size: 20px;
font-style: normal;
font-weight: bold;
letter-spacing: normal;
margin-bottom: 30px;
}
#insights .controls .period-selector {
float: right;
font-size: 0;
margin-right: 10px;
}
#insights .controls .period-selector li {
display: inline-block;
}
#insights .controls .period-selector li .selector {
font-size: 12px;
text-transform: uppercase;
width: 32px;
}
#insights .controls .period-selector li .selector.active,
#insights .controls .period-selector li .selector:hover {
background: #7bc876;
color: #272a2f;
}
#insights .controls .selector {
background: #3f4752;
color: #aaa;
cursor: pointer;
width: 24px;
height: 24px;
text-align: center;
border-radius: 3px;
margin-left: 5px;
padding-top: 5px;
box-sizing: border-box;
}
#insights .active-users,
#insights #unreviewed-suggestions-lifespan-chart {
height: 160px;
}
#insights .active-users {
float: left;
margin-right: 40px;
position: relative;
text-align: center;
}
#insights .active-users:last-child {
margin-right: 0;
}
#insights .active-users h4 {
font-size: 14px;
font-weight: bold;
margin: 10px auto 0;
width: 100px;
}
#insights .active-users .active-wrapper {
left: 0;
right: 0;
top: 15px;
position: absolute;
}
#insights .active-users .active {
border-bottom: 2px solid #888888;
display: inline-block;
font-size: 40px;
font-weight: bold;
line-height: 48px;
}
#insights .active-users .total {
color: #888;
font-size: 16px;
left: 0;
right: 0;
top: 68px;
position: absolute;
}
#insights figure {
margin-bottom: 40px;
}
/* Info tooltip */
#insights h3 .fa {
float: right;
font-size: 14px;
}
#insights h3 .fa.active,
#insights h3 .fa:hover {
background: #272a2f;
}
#insights h3 .tooltip {
background: #000000dd;
position: absolute;
display: none;
margin-top: 10px;
padding: 10px;
z-index: 1;
font-size: 14px;
font-weight: normal;
line-height: 1.5em;
border-radius: 3px;
right: 0;
max-width: 570px;
}
#insights h3 .tooltip ul {
margin-top: 15px;
margin-left: 15px;
}
#insights h3 .tooltip li {
list-style-type: disc;
}
#insights h3 .tooltip li:not(:last-child) {
padding-bottom: 5px;
}
/* Active users info tooltip */
#insights h3 .tooltip li::marker {
color: #7bc876;
}
/* Translation activity info tooltip */
#insights h3 .tooltip li.human-translations::marker {
color: #4f7256;
}
#insights h3 .tooltip li.machinery-translations::marker {
color: #41554c;
}
#insights h3 .tooltip li.new-source-strings::marker {
color: #272a2f;
}
#insights h3 .tooltip li.completion::marker {
color: #7bc876;
}
/* Review activity info tooltip */
#insights h3 .tooltip li.peer-approved::marker {
color: #3e7089;
}
#insights h3 .tooltip li.self-approved::marker {
color: #385465;
}
#insights h3 .tooltip li.rejected::marker {
color: #843650;
}
#insights h3 .tooltip li.new-suggestions::marker {
color: #272a2f;
}
#insights h3 .tooltip li.unreviewed::marker {
color: #4fc4f6;
}
/* Custom chart legend */
#insights .legend {
text-align: center;
}
#insights .legend li {
display: inline-block;
font-size: 12px;
margin: 15px;
margin-bottom: 5px;
}
#insights .legend li .icon {
display: inline-block;
border-radius: 50%;
margin-right: 3px;
height: 12px;
width: 12px;
}
#insights .legend li .label {
cursor: pointer;
font-weight: bold;
vertical-align: text-top;
}
#insights .legend li.disabled .label {
color: #4d5967;
}
#insights .legend li.disabled .label:hover {
color: #fff;
}

Просмотреть файл

@ -0,0 +1,677 @@
var Pontoon = (function (my) {
return $.extend(true, my, {
insights: {
initialize: function () {
// Show/hide info tooltip
$('#insights h3 .fa-info').on('click', function () {
$(this).next('.tooltip').toggle();
$(this).toggleClass('active');
});
// Select active users period
$('#insights h3 .period-selector .selector').on(
'click',
function () {
$(
'#insights h3 .period-selector .selector'
).removeClass('active');
$(this).addClass('active');
Pontoon.insights.renderActiveUsers();
}
);
// Set up canvas to be HiDPI display ready
$('#insights canvas.chart').each(function () {
var canvas = this;
var dpr = window.devicePixelRatio || 1;
canvas.style.width = canvas.width + 'px';
canvas.style.height = canvas.height + 'px';
canvas.width = canvas.width * dpr;
canvas.height = canvas.height * dpr;
});
// Set up default Chart.js configuration
Chart.defaults.global.defaultFontColor = '#AAA';
Chart.defaults.global.defaultFontFamily = 'Open Sans';
Chart.defaults.global.defaultFontStyle = '100';
Chart.defaults.global.datasets.bar.barPercentage = 0.7;
Chart.defaults.global.datasets.bar.categoryPercentage = 0.7;
Pontoon.insights.renderActiveUsers();
Pontoon.insights.renderUnreviewedSuggestionsLifespan();
Pontoon.insights.renderTranslationActivity();
Pontoon.insights.renderReviewActivity();
},
renderActiveUsers: function () {
$('#insights canvas.chart').each(function () {
// Collect data
var parent = $(this).parents('.active-users');
var id = parent.attr('id');
var period = $('.period-selector .active')
.data('period')
.toString();
var active = $('#active-users').data(period)[id];
var total = $('#active-users').data('total')[id];
// Clear old canvas content to avoid aliasing
var canvas = this;
var context = canvas.getContext('2d');
var dpr = window.devicePixelRatio || 1;
context.clearRect(0, 0, canvas.width, canvas.height);
context.lineWidth = 3 * dpr;
var x = canvas.width / 2;
var y = canvas.height / 2;
var radius = (canvas.width - context.lineWidth) / 2;
var activeLength = 0;
if (total !== 0) {
activeLength = (active / total) * 2;
}
var activeStart = -0.5;
var activeEnd = activeStart + activeLength;
plot(activeStart, activeEnd, '#7BC876');
var inactiveLength = 2;
if (total !== 0) {
inactiveLength = ((total - active) / total) * 2;
}
var inactiveStart = activeEnd;
var inactiveEnd = inactiveStart + inactiveLength;
plot(inactiveStart, inactiveEnd, '#5F7285');
// Update number
parent.find('.active').html(active);
parent.find('.total').html(total);
function plot(start, end, color) {
context.beginPath();
context.arc(
x,
y,
radius,
start * Math.PI,
end * Math.PI
);
context.strokeStyle = color;
context.stroke();
}
});
},
renderUnreviewedSuggestionsLifespan: function () {
var chart = $('#unreviewed-suggestions-lifespan-chart');
var ctx = chart[0].getContext('2d');
var gradient = ctx.createLinearGradient(0, 0, 0, 130);
gradient.addColorStop(0, '#4fc4f666');
gradient.addColorStop(1, 'transparent');
new Chart(chart, {
type: 'line',
data: {
labels: $('#insights').data('dates'),
datasets: [
{
label: 'Unreviewed suggestion lifespan',
data: chart.data('lifespans'),
backgroundColor: gradient,
borderColor: ['#4fc4f6'],
borderWidth: 2,
pointBackgroundColor: '#4fc4f6',
pointHitRadius: 10,
pointRadius: 4,
pointHoverRadius: 6,
pointHoverBackgroundColor: '#4fc4f6',
pointHoverBorderColor: '#FFF',
},
],
},
options: {
legend: {
display: false,
},
tooltips: {
borderColor: '#4fc4f6',
borderWidth: 1,
caretPadding: 5,
xPadding: 10,
yPadding: 10,
displayColors: false,
callbacks: {
label: function (tooltipItem) {
return tooltipItem.value + ' days';
},
},
},
scales: {
xAxes: [
{
type: 'time',
time: {
displayFormats: {
month: 'MMM',
},
tooltipFormat: 'MMMM YYYY',
},
gridLines: {
display: false,
},
ticks: {
source: 'data',
},
},
],
yAxes: [
{
gridLines: {
display: false,
},
position: 'right',
ticks: {
beginAtZero: true,
maxTicksLimit: 3,
precision: 0,
callback: function (value) {
return value + ' days';
},
},
},
],
},
},
});
},
renderTranslationActivity: function () {
var chart = $('#translation-activity-chart');
var ctx = chart[0].getContext('2d');
var gradient = ctx.createLinearGradient(0, 0, 0, 250);
gradient.addColorStop(0, '#7BC87633');
gradient.addColorStop(1, 'transparent');
var translationActivityChart = new Chart(chart, {
type: 'bar',
data: {
labels: $('#insights').data('dates'),
datasets: [
{
type: 'line',
label: 'Completion',
data: chart.data('completion'),
yAxisID: 'completion-y-axis',
backgroundColor: gradient,
borderColor: ['#7BC876'],
borderWidth: 2,
pointBackgroundColor: '#7BC876',
pointHitRadius: 10,
pointRadius: 4,
pointHoverRadius: 6,
pointHoverBackgroundColor: '#7BC876',
pointHoverBorderColor: '#FFF',
},
{
type: 'bar',
label: 'Human translations',
data: chart.data('human-translations'),
yAxisID: 'strings-y-axis',
backgroundColor: '#4f7256',
hoverBackgroundColor: '#4f7256',
stack: 'translations',
order: 2,
},
{
type: 'bar',
label: 'Machinery translations',
data: chart.data('machinery-translations'),
yAxisID: 'strings-y-axis',
backgroundColor: '#41554c',
hoverBackgroundColor: '#41554c',
stack: 'translations',
order: 1,
},
{
type: 'bar',
label: 'New source strings',
data: chart.data('new-source-strings'),
yAxisID: 'strings-y-axis',
backgroundColor: '#272a2f',
hoverBackgroundColor: '#272a2f',
stack: 'source-strings',
order: 3,
hidden: true,
},
],
},
options: {
legend: {
display: false,
},
legendCallback: Pontoon.insights.customLegend(chart),
tooltips: {
mode: 'index',
intersect: false,
borderColor: '#7BC876',
borderWidth: 1,
caretPadding: 5,
xPadding: 10,
yPadding: 10,
itemSort: function (a, b) {
// Dataset order affects stacking, tooltip and
// legend, but it doesn't work intuitively, so
// we need to manually sort tooltip items.
if (
a.datasetIndex === 2 &&
b.datasetIndex === 1
) {
return 1;
}
},
callbacks: {
label: function (tooltipItems, chart) {
var label =
chart.datasets[
tooltipItems.datasetIndex
].label;
var value = tooltipItems.yLabel;
var human =
chart.datasets[1].data[
tooltipItems.index
];
var machinery =
chart.datasets[2].data[
tooltipItems.index
];
var total = human + machinery;
var suffix = '';
if (label === 'Completion') {
suffix = '%';
}
if (label === 'Human translations') {
suffix =
' (' +
Pontoon.insights.getPercent(
value,
total
) +
'% of all translations)';
}
if (label === 'Machinery translations') {
suffix =
' (' +
Pontoon.insights.getPercent(
value,
total
) +
'% of all translations)';
}
return label + ': ' + value + suffix;
},
},
},
scales: {
xAxes: [
{
stacked: true,
type: 'time',
time: {
displayFormats: {
month: 'MMM',
},
tooltipFormat: 'MMMM YYYY',
},
gridLines: {
display: false,
},
offset: true,
ticks: {
source: 'data',
},
},
],
yAxes: [
{
id: 'completion-y-axis',
position: 'right',
scaleLabel: {
display: true,
labelString: 'COMPLETION',
fontColor: '#FFF',
fontStyle: 100,
},
gridLines: {
display: false,
},
ticks: {
beginAtZero: true,
max: 100,
stepSize: 20,
callback: function (value) {
return value + ' %';
},
},
},
{
stacked: true,
id: 'strings-y-axis',
position: 'left',
scaleLabel: {
display: true,
labelString: 'STRINGS',
fontColor: '#FFF',
fontStyle: 100,
},
gridLines: {
display: false,
},
ticks: {
beginAtZero: true,
precision: 0,
},
},
],
},
},
});
// Render custom legend
$('#translation-activity-chart-legend').html(
translationActivityChart.generateLegend()
);
Pontoon.insights.attachCustomLegendHandler(
translationActivityChart,
'#translation-activity-chart-legend .label'
);
},
renderReviewActivity: function () {
var chart = $('#review-activity-chart');
var ctx = chart[0].getContext('2d');
var gradient = ctx.createLinearGradient(0, 0, 0, 250);
gradient.addColorStop(0, '#4fc4f688');
gradient.addColorStop(1, 'transparent');
var reviewActivityChart = new Chart(chart, {
type: 'bar',
data: {
labels: $('#insights').data('dates'),
datasets: [
{
type: 'line',
label: 'Unreviewed',
data: chart.data('unreviewed'),
yAxisID: 'unreviewed-y-axis',
backgroundColor: gradient,
borderColor: ['#4fc4f6'],
borderWidth: 2,
pointBackgroundColor: '#4fc4f6',
pointHitRadius: 10,
pointRadius: 4,
pointHoverRadius: 6,
pointHoverBackgroundColor: '#4fc4f6',
pointHoverBorderColor: '#FFF',
},
{
type: 'bar',
label: 'Peer-approved',
data: chart.data('peer-approved'),
yAxisID: 'strings-y-axis',
backgroundColor: '#3e7089',
hoverBackgroundColor: '#3e7089',
stack: 'review-actions',
order: 3,
},
{
type: 'bar',
label: 'Self-approved',
data: chart.data('self-approved'),
yAxisID: 'strings-y-axis',
backgroundColor: '#385465',
hoverBackgroundColor: '#385465',
stack: 'review-actions',
order: 2,
},
{
type: 'bar',
label: 'Rejected',
data: chart.data('rejected'),
yAxisID: 'strings-y-axis',
backgroundColor: '#843650',
hoverBackgroundColor: '#843650',
stack: 'review-actions',
order: 1,
},
{
type: 'bar',
label: 'New suggestions',
data: chart.data('new-suggestions'),
yAxisID: 'strings-y-axis',
backgroundColor: '#272a2f',
hoverBackgroundColor: '#272a2f',
stack: 'new-suggestions',
order: 4,
hidden: true,
},
],
},
options: {
legend: {
display: false,
},
legendCallback: Pontoon.insights.customLegend(chart),
tooltips: {
mode: 'index',
intersect: false,
borderColor: '#4fc4f6',
borderWidth: 1,
caretPadding: 5,
xPadding: 10,
yPadding: 10,
itemSort: function (a, b) {
// Dataset order affects stacking, tooltip and
// legend, but it doesn't work intuitively, so
// we need to manually sort tooltip items.
if (
(a.datasetIndex === 3 &&
b.datasetIndex === 2) ||
(a.datasetIndex === 3 &&
b.datasetIndex === 1) ||
(a.datasetIndex === 2 &&
b.datasetIndex === 1)
) {
return 1;
}
},
callbacks: {
label: function (tooltipItems, chart) {
var label =
chart.datasets[
tooltipItems.datasetIndex
].label;
var value = tooltipItems.yLabel;
var peerApproved =
chart.datasets[1].data[
tooltipItems.index
];
var selfApproved =
chart.datasets[2].data[
tooltipItems.index
];
var rejecetd =
chart.datasets[3].data[
tooltipItems.index
];
var totalPeerReviews =
peerApproved + rejecetd;
var totalApprovals =
peerApproved + selfApproved;
var suffix = '';
if (label === 'Peer-approved') {
suffix =
' (' +
Pontoon.insights.getPercent(
value,
totalPeerReviews
) +
'% of peer-reviews)';
}
if (label === 'Self-approved') {
suffix =
' (' +
Pontoon.insights.getPercent(
value,
totalApprovals
) +
'% of all approvals)';
}
if (label === 'Rejected') {
suffix =
' (' +
Pontoon.insights.getPercent(
value,
totalPeerReviews
) +
'% of peer-reviews)';
}
return label + ': ' + value + suffix;
},
},
},
scales: {
xAxes: [
{
stacked: true,
type: 'time',
time: {
displayFormats: {
month: 'MMM',
},
tooltipFormat: 'MMMM YYYY',
},
gridLines: {
display: false,
},
offset: true,
ticks: {
source: 'data',
},
},
],
yAxes: [
{
id: 'unreviewed-y-axis',
position: 'right',
scaleLabel: {
display: true,
labelString: 'UNREVIEWED',
fontColor: '#FFF',
fontStyle: 100,
},
gridLines: {
display: false,
},
ticks: {
beginAtZero: true,
precision: 0,
stepSize: 20,
},
},
{
stacked: true,
id: 'strings-y-axis',
position: 'left',
scaleLabel: {
display: true,
labelString: 'SUGGESTIONS',
fontColor: '#FFF',
fontStyle: 100,
},
gridLines: {
display: false,
},
ticks: {
beginAtZero: true,
precision: 0,
},
},
],
},
},
});
// Render custom legend
$('#review-activity-chart-legend').html(
reviewActivityChart.generateLegend()
);
Pontoon.insights.attachCustomLegendHandler(
reviewActivityChart,
'#review-activity-chart-legend .label'
);
},
// Safely divide value by total, convert to percent
// and round to max. 2 decimals
getPercent: function (value, total) {
if (total !== 0) {
return +parseFloat((value / total) * 100).toFixed(2);
}
return 0;
},
// Legend configuration doesn't allow for enough flexibility,
// so we build our own legend
// eslint-disable-next-line no-unused-vars
customLegend: function (chart) {
return function (chart) {
function renderLabels(chart) {
return chart.data.datasets
.map(function (dataset) {
var disabled = dataset.hidden ? 'disabled' : '';
var color =
dataset.borderColor ||
dataset.backgroundColor;
return (
'<li class="' +
disabled +
'">' +
'<i class="icon" style="background-color:' +
color +
'"></i>' +
'<span class="label">' +
dataset.label +
'</span>' +
'</li>'
);
})
.join('');
}
return '<ul>' + renderLabels(chart) + '</ul>';
};
},
// Custom legend item event handler
attachCustomLegendHandler: function (chart, selector) {
$('body').on('click', selector, function () {
var li = $(this).parent();
var index = li.index();
var meta = chart.getDatasetMeta(index);
var dataset = chart.data.datasets[index];
meta.hidden = meta.hidden === null ? !dataset.hidden : null;
chart.update();
li.toggleClass('disabled');
});
},
},
});
})(Pontoon || {});

Просмотреть файл

@ -0,0 +1,11 @@
{# Widget to display active users chart. #}
{% macro chart(id, title) %}
<div id="{{ id }}" class="active-users">
<canvas class="chart" height="110" width="110"></canvas>
<div class="active-wrapper">
<span class="active noselect"></span>
</div>
<span class="total noselect"></span>
<h4>{{ title }}</h4>
</div>
{% endmacro %}

Просмотреть файл

@ -0,0 +1,147 @@
{% import "insights/widgets/tooltip.html" as Tooltip %}
{% import "insights/widgets/active_users.html" as ActiveUsers %}
{# Widget to display insights. #}
{% macro display() %}
<div id="insights" data-dates="{{ dates }}">
<section class="clearfix">
<section
id="active-users"
class="half"
data-total="{{ total_users|to_json() }}"
data-1="{{ active_users_last_month|to_json() }}"
data-3="{{ active_users_last_3_months|to_json() }}"
data-6="{{ active_users_last_6_months|to_json() }}"
data-12="{{ active_users_last_12_months|to_json() }}"
>
<div class="block clearfix">
<h3 class="controls">
Active users
{{ Tooltip.display(
intro='Ratios of active vs. all managers, reviewers, contributors.',
items=[{
'class': 'active-managers',
'name': 'Active managers',
'definition': 'Managers who logged into Pontoon within the selected time frame.',
}, {
'class': 'active-reviewers',
'name': 'Active reviewers',
'definition': 'Users who reviewed translations within the selected time frame.',
}, {
'class': 'active-contributors',
'name': 'Active contributors',
'definition': 'Users who submitted translations within the selected time frame.',
}]
) }}
<ul class="period-selector noselect clearfix">
<li><div class="active selector" data-period="12">12m</div></li>
<li><div class="selector" data-period="6">6m</div></li>
<li><div class="selector" data-period="3">3m</div></li>
<li><div class="selector" data-period="1">1m</div></li>
</ul>
</h3>
{{ ActiveUsers.chart('managers', 'Active managers') }}
{{ ActiveUsers.chart('reviewers', 'Active reviewers') }}
{{ ActiveUsers.chart('contributors', 'Active contributors') }}
</div>
</section>
<section class="half">
<div class="block clearfix">
<h3 class="controls">
Unreviewed suggestions lifespan
{{ Tooltip.display(
intro='How much time it takes on average to review a suggestion.',
) }}
</h3>
<canvas
id="unreviewed-suggestions-lifespan-chart"
data-lifespans="{{ unreviewed_lifespans }}"
width="410"
height="160">
</canvas>
</div>
</section>
</section>
<section class="translation-activity">
<figure class="block">
<h3 class="controls">
Translation activity
{{ Tooltip.display(
intro='Impact of adding translations and source strings on the overal translation completion.',
items=[{
'class': 'completion',
'name': 'Completion',
'definition': 'Share of translated strings.',
}, {
'class': 'human-translations',
'name': 'Human translations',
'definition': 'Translations authored by users.',
}, {
'class': 'machinery-translations',
'name': 'Machinery translations',
'definition': 'Translations copied from Machinery.',
}, {
'class': 'new-source-strings',
'name': 'New source strings',
'definition': 'Newly added source strings (hidden by default).',
}]
) }}
</h3>
<canvas
id="translation-activity-chart"
data-completion="{{ translation_activity.completion }}"
data-human-translations="{{ translation_activity.human_translations }}"
data-machinery-translations="{{ translation_activity.machinery_translations }}"
data-new-source-strings="{{ translation_activity.new_source_strings }}"
width="920"
height="400">
</canvas>
<div id="translation-activity-chart-legend" class="legend"></div>
</figure>
</section>
<section class="review-activity">
<figure class="block">
<h3 class="controls">
Review activity
{{ Tooltip.display(
intro='Impact of the review process and adding suggestions on the overal amount of unreviewed suggestions.',
items=[{
'class': 'unreviewed',
'name': 'Unreviewed',
'definition': 'Suggestions pending a review.',
}, {
'class': 'peer-approved',
'name': 'Peer-approved',
'definition': 'Suggestions approved by peers of the author.',
}, {
'class': 'self-approved',
'name': 'Self-approved',
'definition': 'Directly submitted translations.',
}, {
'class': 'rejected',
'name': 'Rejected',
'definition': 'Rejected suggestions.',
}, {
'class': 'new-suggestions',
'name': 'New suggestions',
'definition': 'Newly added suggestions (hidden by default).',
}]
) }}
</h3>
<canvas
id="review-activity-chart"
data-unreviewed="{{ review_activity.unreviewed }}"
data-peer-approved="{{ review_activity.peer_approved }}"
data-self-approved="{{ review_activity.self_approved }}"
data-rejected="{{ review_activity.rejected }}"
data-new-suggestions="{{ review_activity.new_suggestions }}"
width="920"
height="400">
</canvas>
<div id="review-activity-chart-legend" class="legend"></div>
</figure>
</section>
</div>
{% endmacro %}

Просмотреть файл

@ -0,0 +1,17 @@
{# Widget to display tooltip with more information about the section. #}
{% macro display(intro, items=[]) %}
<div class="fa fa-info selector"></div>
<div class="tooltip">
<p>{{ intro }}</p>
{% if items %}
<ul>
{% for item in items %}
<li class="{{ item.class }}">
<b>{{ item.name }}:</b>
{{ item.definition }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

Просмотреть файл

@ -2,7 +2,7 @@ from django.db.models.functions import TruncMonth
from django.db.models import Avg, Sum
from pontoon.base.utils import aware_datetime, convert_to_unix_time, get_last_months
from pontoon.insights.models import LocaleInsightsSnapshot
from pontoon.insights.models import LocaleInsightsSnapshot, active_users_default
def get_insights(query_filters=None):
@ -53,33 +53,54 @@ def get_insights(query_filters=None):
.order_by("month")
)
output = {}
latest = snapshots.latest("created_at") if snapshots else None
active_users = latest.active_users_last_12_months if latest else None
return {
"dates": [convert_to_unix_time(month) for month in months],
# Active users
"total_managers": latest.total_managers if latest else 0,
"total_reviewers": latest.total_reviewers if latest else 0,
"total_contributors": latest.total_contributors if latest else 0,
"active_managers": active_users["managers"] if active_users else 0,
"active_reviewers": active_users["reviewers"] if active_users else 0,
"active_contributors": active_users["contributors"] if active_users else 0,
# Unreviewed suggestions lifespan
"unreviewed_lifespans": [x["unreviewed_lifespan_avg"].days for x in insights],
# Translation activity
"translation_activity": {
"completion": [round(x["completion_avg"], 2) for x in insights],
"human_translations": [x["human_translations_sum"] for x in insights],
"machinery_translations": [x["machinery_sum"] for x in insights],
"new_source_strings": [x["new_source_strings_sum"] for x in insights],
},
# Review activity
"review_activity": {
"unreviewed": [int(round(x["unreviewed_avg"])) for x in insights],
"peer_approved": [x["peer_approved_sum"] for x in insights],
"self_approved": [x["self_approved_sum"] for x in insights],
"rejected": [x["rejected_sum"] for x in insights],
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
}
if latest:
output.update(
{
"total_users": {
"managers": latest.total_managers,
"reviewers": latest.total_reviewers,
"contributors": latest.total_contributors,
},
"active_users_last_month": latest.active_users_last_month,
"active_users_last_3_months": latest.active_users_last_3_months,
"active_users_last_6_months": latest.active_users_last_6_months,
"active_users_last_12_months": latest.active_users_last_12_months,
}
)
else:
output.update(
{
"total_users": active_users_default(),
"active_users_last_month": active_users_default(),
"active_users_last_3_months": active_users_default(),
"active_users_last_6_months": active_users_default(),
"active_users_last_12_months": active_users_default(),
}
)
output.update(
{
"dates": [convert_to_unix_time(month) for month in months],
"unreviewed_lifespans": [
x["unreviewed_lifespan_avg"].days for x in insights
],
"translation_activity": {
"completion": [round(x["completion_avg"], 2) for x in insights],
"human_translations": [x["human_translations_sum"] for x in insights],
"machinery_translations": [x["machinery_sum"] for x in insights],
"new_source_strings": [x["new_source_strings_sum"] for x in insights],
},
"review_activity": {
"unreviewed": [int(round(x["unreviewed_avg"])) for x in insights],
"peer_approved": [x["peer_approved_sum"] for x in insights],
"self_approved": [x["self_approved_sum"] for x in insights],
"rejected": [x["rejected_sum"] for x in insights],
"new_suggestions": [x["new_suggestions_sum"] for x in insights],
},
}
)
return output

Просмотреть файл

@ -316,8 +316,9 @@ PIPELINE_CSS = {
"css/contributors.css",
"css/heading_info.css",
"css/team.css",
"css/info.css",
"css/request.css",
"css/insights.css",
"css/info.css",
),
"output_filename": "css/team.min.css",
},
@ -422,6 +423,7 @@ PIPELINE_JS = {
},
"team": {
"source_filenames": (
"js/lib/Chart.bundle.js",
"js/table.js",
"js/progress-chart.js",
"js/double_list_selector.js",
@ -429,6 +431,7 @@ PIPELINE_JS = {
"js/tabs.js",
"js/request.js",
"js/permissions.js",
"js/insights.js",
"js/info.js",
),
"output_filename": "js/team.min.js",
@ -696,6 +699,10 @@ USE_L10N = False
# See bug 1567402 for details. A Mozilla-specific variable.
ENABLE_BUGS_TAB = os.environ.get("ENABLE_BUGS_TAB", "False") != "False"
# Enable Insights tab on the team pages, which presents data that needs to be
# collected by a scheduled job. See docs/admin/deployment.rst for more information.
ENABLE_INSIGHTS_TAB = os.environ.get("ENABLE_INSIGHTS_TAB", "False") != "False"
# Bleach tags and attributes
ALLOWED_TAGS = [
"a",

Просмотреть файл

@ -0,0 +1,3 @@
{% import "insights/widgets/insights.html" as Insights with context %}
{{ Insights.display() }}

Просмотреть файл

@ -91,6 +91,16 @@
icon = 'users',
)
}}
{% if settings.ENABLE_INSIGHTS_TAB %}
{{ Menu.item(
'Insights',
url('pontoon.teams.insights', locale.code),
is_active = (current_page == 'insights'),
count = False,
icon = 'chart-line',
)
}}
{% endif %}
{% if settings.ENABLE_BUGS_TAB %}
{{ Menu.item(
'Bugs',

Просмотреть файл

@ -20,6 +20,8 @@ urlpatterns = [
[
# Team contributors
path("contributors/", views.team, name="pontoon.teams.contributors",),
# Team insights
path("insights/", views.team, name="pontoon.teams.insights",),
# Team bugs
path("bugs/", views.team, name="pontoon.teams.bugs",),
# Team info
@ -51,6 +53,12 @@ urlpatterns = [
views.LocaleContributorsView.as_view(),
name="pontoon.teams.ajax.contributors",
),
# Team insights
path(
"insights/",
views.ajax_insights,
name="pontoon.teams.ajax.insights",
),
# Team info
path(
"info/",

Просмотреть файл

@ -21,6 +21,7 @@ from pontoon.base.models import Locale, Project
from pontoon.base.utils import require_AJAX
from pontoon.contributors.utils import users_with_translations_counts
from pontoon.contributors.views import ContributorsMixin
from pontoon.insights.utils import get_insights
from pontoon.teams.forms import LocaleRequestForm
@ -96,6 +97,18 @@ def ajax_projects(request, locale):
)
@require_AJAX
def ajax_insights(request, locale):
"""Insights tab."""
if not settings.ENABLE_INSIGHTS_TAB:
raise ImproperlyConfigured("ENABLE_INSIGHTS_TAB variable not set in settings.")
locale = get_object_or_404(Locale, code=locale)
insights = get_insights(Q(locale=locale))
return render(request, "teams/includes/insights.html", insights)
@require_AJAX
def ajax_info(request, locale):
"""Info tab."""