зеркало из https://github.com/mozilla/pontoon.git
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:
Родитель
db4dd60e60
Коммит
847e43c377
|
@ -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."""
|
||||
|
|
Загрузка…
Ссылка в новой задаче