Commit 6c51f5770678962a71a1ebb2719d6ff3248f98b6

Authored by hitier
2 parents 97e76b37 f8a685cd

Projects Stats Chart

@@ -24,6 +24,10 @@ or major refactoring improvments. @@ -24,6 +24,10 @@ or major refactoring improvments.
24 24
25 ## Unreleased 25 ## Unreleased
26 26
  27 +## [0.3.pre-13] - 2021-05-19 - Projects stats
  28 +### New
  29 +Projects stats charts
  30 +
27 ## [0.3.pre-12] - 2021-05-19 - Enhance Front end 31 ## [0.3.pre-12] - 2021-05-19 - Enhance Front end
28 ### Changed 32 ### Changed
29 Speed up loading 33 Speed up loading
1 -0.3.pre-12 1 +0.3.pre-13
@@ -202,6 +202,28 @@ def charges_by_project_stacked(project_id, category="service"): @@ -202,6 +202,28 @@ def charges_by_project_stacked(project_id, category="service"):
202 return all_charges 202 return all_charges
203 203
204 204
  205 +def charges_for_projects_stacked():
  206 + sql_req = """
  207 + select p.name, sum(charge_rate) as tot_charg
  208 + from charge
  209 + join project p on p.id = charge.project_id
  210 + where period_id = {}
  211 + group by project_id
  212 + """
  213 + projects_req = "select name from project order by id"
  214 + projects_names = [name for (name,) in db.session.execute(projects_req)]
  215 + headers = ['period'] + projects_names
  216 + all_charges = [headers]
  217 + for (period_id, period_name) in db.session.execute("select id, name from period order by id"):
  218 + project_charges = [period_name]
  219 + charges_for_projects_req = sql_req.format(period_id)
  220 + for( project_name, project_charge) in db.session.execute(charges_for_projects_req):
  221 + project_charge = str(round(project_charge / charge_unit, 2)) if project_charge else '0'
  222 + project_charges.append(project_charge)
  223 + all_charges.append(project_charges)
  224 + return all_charges
  225 +
  226 +
205 def charges_by_agent_stacked(agent_id): 227 def charges_by_agent_stacked(agent_id):
206 """ 228 """
207 Build the list of charges for all projects of one agent, period by period 229 Build the list of charges for all projects of one agent, period by period
app/main/routes.py
@@ -54,6 +54,13 @@ def projects(): @@ -54,6 +54,13 @@ def projects():
54 projects=all_projects) 54 projects=all_projects)
55 55
56 56
  57 +@bp.route('/projects/stats')
  58 +@role_required('project')
  59 +def projects_stats():
  60 + num_projects = len(Project.query.all())
  61 + return render_template('projects_stats.html', subtitle="Statistiques des projets ({})".format(num_projects))
  62 +
  63 +
57 @bp.route('/agents') 64 @bp.route('/agents')
58 @role_required('project') 65 @role_required('project')
59 def agents(): 66 def agents():
@@ -277,3 +284,16 @@ def charge_agent_csv(agent_id): @@ -277,3 +284,16 @@ def charge_agent_csv(agent_id):
277 resp = make_response("\n".join(csv_table)) 284 resp = make_response("\n".join(csv_table))
278 resp.headers['Content-Type'] = 'text/plain;charset=utf8' 285 resp.headers['Content-Type'] = 'text/plain;charset=utf8'
279 return resp 286 return resp
  287 +
  288 +
  289 +@bp.route('/charge/projects')
  290 +@role_required('project')
  291 +def projects_stats_csv():
  292 + csv_table = []
  293 + for line in db_mgr.charges_for_projects_stacked():
  294 + line = [cell.replace(",", "-") for cell in line]
  295 + line_string = ",".join(line)
  296 + csv_table.append(line_string)
  297 + resp = make_response("\n".join(csv_table))
  298 + resp.headers['Content-Type'] = 'text/plain;charset=utf8'
  299 + return resp
app/main/static/js/charges.js
@@ -7,7 +7,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -7,7 +7,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
7 7
8 const main_elt = document.getElementById("main") 8 const main_elt = document.getElementById("main")
9 9
10 - const margin = {top: 60, right: 150, bottom: 200, left: 90}, 10 + var margin = {top: 60, right: 100, bottom: 200, left: 90},
11 width = main_elt.offsetWidth * 0.95 - margin.left - margin.right, 11 width = main_elt.offsetWidth * 0.95 - margin.left - margin.right,
12 height = 600 - margin.top - margin.bottom; 12 height = 600 - margin.top - margin.bottom;
13 13
@@ -18,32 +18,29 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -18,32 +18,29 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
18 18
19 const y_ticks_num = 5 // dont show to many y ticks for lisibility 19 const y_ticks_num = 5 // dont show to many y ticks for lisibility
20 20
21 - const legendRectSize = 15; // size of rectangle in legend  
22 -  
23 - // const legend_cell_size = 15; // size of legend colored scare  
24 - // const legend_x = width + 20; // begin legend a bit after the end of the chart  
25 - // const legend_y = 600 - margin.bottom + 20; // begin legend a bit after the end of the chart 21 + const legend_rect_size = 15; // size of rectangle in legend
  22 + const legend_height = 20;
  23 + const legend_max_length = 20;
  24 + const legend_spacing = 5;
26 25
27 const dot_radius = 6; // size of total line dot 26 const dot_radius = 6; // size of total line dot
28 27
29 - const xScale = d3.scaleBand()  
30 - .range([0, width])  
31 - .padding(0.4);  
32 -  
33 - const yScale = d3.scaleLinear()  
34 - .range([height, 0]);  
35 28
36 var colorScale = d3.scaleOrdinal([]); // Will be really set later by category type 29 var colorScale = d3.scaleOrdinal([]); // Will be really set later by category type
37 30
38 -  
39 // 31 //
40 // Configure chart by the category given as arg 32 // Configure chart by the category given as arg
41 // 33 //
42 var chart_title = "" 34 var chart_title = ""
43 var category_title = "" 35 var category_title = ""
  36 + var draw_areas = () => void 0;
44 var draw_total_line = () => void 0; 37 var draw_total_line = () => void 0;
45 var draw_categories_bars = () => void 0; 38 var draw_categories_bars = () => void 0;
46 39
  40 + var mouseoverlegend = category_mouseoverlegend;
  41 + var mouseleavelegend = category_mouseleavelegend;
  42 +
  43 +
47 if (category_type == 'capacity') { 44 if (category_type == 'capacity') {
48 chart_title = "Charges par fonction" 45 chart_title = "Charges par fonction"
49 category_title = "Fonction" 46 category_title = "Fonction"
@@ -61,10 +58,28 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -61,10 +58,28 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
61 category_title = "Projet" 58 category_title = "Projet"
62 draw_categories_bars = draw_categories_stacked 59 draw_categories_bars = draw_categories_stacked
63 colorScale = d3.scaleOrdinal(d3.schemeTableau10); 60 colorScale = d3.scaleOrdinal(d3.schemeTableau10);
  61 + } else if (category_type == 'area') {
  62 + margin.bottom = 400
  63 + height = 1200 - margin.top - margin.bottom;
  64 + chart_title = "Tous agents confondus"
  65 + category_title = "Projet"
  66 + draw_areas = draw_projects_areas
  67 + colorScale = d3.scaleOrdinal(d3.schemeTableau10);
  68 + mouseoverlegend = () => void 0;
  69 + mouseleavelegend = () => void 0;
64 } else { 70 } else {
65 alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !") 71 alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !")
66 } 72 }
67 73
  74 +
  75 + const xScale = d3.scaleBand()
  76 + .range([0, width])
  77 + .padding(0.4);
  78 +
  79 + const yScale = d3.scaleLinear()
  80 + .range([height, 0]);
  81 +
  82 +
68 // Configure the tooltip to export svg to png or csv 83 // Configure the tooltip to export svg to png or csv
69 var update_export_menu = function (e, d) { 84 var update_export_menu = function (e, d) {
70 85
@@ -134,6 +149,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -134,6 +149,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
134 var category_name = d3.select(this.parentNode).datum().key 149 var category_name = d3.select(this.parentNode).datum().key
135 var category_charge = d.data[category_name] 150 var category_charge = d.data[category_name]
136 show_tooltip(e, category_name, category_charge) 151 show_tooltip(e, category_name, category_charge)
  152 + console.log("HELLO")
137 } 153 }
138 154
139 var mouseovergrouped = function (e, d) { 155 var mouseovergrouped = function (e, d) {
@@ -173,34 +189,34 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -173,34 +189,34 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
173 .style("top", (e.pageY - tooltip_offset_dot.dy) + "px") 189 .style("top", (e.pageY - tooltip_offset_dot.dy) + "px")
174 } 190 }
175 191
176 - function mouseoverlegend() { 192 + function category_mouseoverlegend() {
177 var legend_category = $(this).attr("class"); 193 var legend_category = $(this).attr("class");
178 var bar_category = document.getElementsByClassName(legend_category); 194 var bar_category = document.getElementsByClassName(legend_category);
179 if (bar_category[0].tagName === "g") { 195 if (bar_category[0].tagName === "g") {
180 var rect_to_hover = bar_category[0].children; 196 var rect_to_hover = bar_category[0].children;
181 (bar_category[1].children[0]).classList.add("brillance"); 197 (bar_category[1].children[0]).classList.add("brillance");
182 - for (var i=0;i<rect_to_hover.length;i++) { 198 + for (var i = 0; i < rect_to_hover.length; i++) {
183 rect_to_hover[i].classList.add("brillance"); 199 rect_to_hover[i].classList.add("brillance");
184 } 200 }
185 } else { 201 } else {
186 - for (var i=0;i<bar_category.length;i++) { 202 + for (var i = 0; i < bar_category.length; i++) {
187 bar_category[i].classList.add("brillance"); 203 bar_category[i].classList.add("brillance");
188 } 204 }
189 } 205 }
190 } 206 }
191 207
192 - function mouseleavelegend() { 208 + function category_mouseleavelegend() {
193 var legend_category = $(this).attr("class"); 209 var legend_category = $(this).attr("class");
194 var bar_category = document.getElementsByClassName(legend_category); 210 var bar_category = document.getElementsByClassName(legend_category);
195 if (bar_category[0].tagName === "g") { 211 if (bar_category[0].tagName === "g") {
196 var rect_to_hover = bar_category[0].children; 212 var rect_to_hover = bar_category[0].children;
197 (bar_category[1].children[0]).classList.remove("brillance"); 213 (bar_category[1].children[0]).classList.remove("brillance");
198 - for (var i=0;i<rect_to_hover.length;i++) { 214 + for (var i = 0; i < rect_to_hover.length; i++) {
199 rect_to_hover[i].classList.remove("brillance"); 215 rect_to_hover[i].classList.remove("brillance");
200 } 216 }
201 } else { 217 } else {
202 var lenght_i = bar_category.length - 1; 218 var lenght_i = bar_category.length - 1;
203 - for (var i=lenght_i;i>=0;i--) { 219 + for (var i = lenght_i; i >= 0; i--) {
204 bar_category[i].classList.remove("brillance"); 220 bar_category[i].classList.remove("brillance");
205 } 221 }
206 } 222 }
@@ -210,7 +226,13 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -210,7 +226,13 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
210 226
211 // add horizontal legend 227 // add horizontal legend
212 let legend_keys = color_scale.domain(); 228 let legend_keys = color_scale.domain();
213 - var legendSpacing = 5; 229 +
  230 + // Truncate legend keys when too long
  231 + legend_keys = legend_keys.map(function (l) {
  232 + const n = legend_max_length;
  233 + return (l.length > n) ? l.substr(0, n - 1) : l;
  234 + });
  235 +
214 236
215 var legendWrap = svg.append('g') 237 var legendWrap = svg.append('g')
216 .attr('width', '100%') 238 .attr('width', '100%')
@@ -220,38 +242,40 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -220,38 +242,40 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
220 .data(legend_keys) 242 .data(legend_keys)
221 .enter() 243 .enter()
222 .append('g') 244 .append('g')
223 - .attr('class', function (d) { return d}) 245 + .attr('class', d => d)
224 .on("mouseover", mouseoverlegend) 246 .on("mouseover", mouseoverlegend)
225 .on("mouseleave", mouseleavelegend); 247 .on("mouseleave", mouseleavelegend);
226 248
227 legend.append('rect') 249 legend.append('rect')
228 - .attr('width', legendRectSize)  
229 - .attr('height', legendRectSize) 250 + .attr('width', legend_rect_size)
  251 + .attr('height', legend_rect_size)
230 .style('fill', color_scale) 252 .style('fill', color_scale)
231 .attr('class', 'legend'); 253 .attr('class', 'legend');
232 254
233 legend.append('text') 255 legend.append('text')
234 .attr('class', 'legend') 256 .attr('class', 'legend')
235 - .attr('x', legendRectSize + legendSpacing * 1.5) 257 + .attr('x', legend_rect_size + legend_spacing * 1.5)
236 .attr('y', 13) 258 .attr('y', 13)
237 - .text(function (d) {  
238 - return d;  
239 - }); 259 + .text(d => d);
240 260
241 var ypos = 0, 261 var ypos = 0,
242 newxpos = 0, 262 newxpos = 0,
243 maxwidth = 0, 263 maxwidth = 0,
244 xpos; 264 xpos;
245 265
  266 + // Get Max legend key length for later display
  267 + const text_length_list = legend.selectAll('text.legend')._groups.map(el => el[0].getComputedTextLength())
  268 + const keycol_length = Math.max(...text_length_list) + 40
  269 +
246 legend 270 legend
247 .attr("transform", function (d, i) { 271 .attr("transform", function (d, i) {
248 - var length = d3.select(this).select("text").node().getComputedTextLength() + 40;  
249 xpos = newxpos; 272 xpos = newxpos;
250 - if (width < xpos + length) { 273 + if (width < xpos + keycol_length) {
251 newxpos = xpos = 0; 274 newxpos = xpos = 0;
252 - ypos += 30; 275 + ypos += legend_height;
  276 +
253 } 277 }
254 - newxpos += length; 278 + newxpos += keycol_length;
255 if (newxpos > maxwidth) maxwidth = newxpos; 279 if (newxpos > maxwidth) maxwidth = newxpos;
256 280
257 return 'translate(' + xpos + ',' + ypos + ')'; 281 return 'translate(' + xpos + ',' + ypos + ')';
@@ -263,11 +287,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -263,11 +287,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
263 .attr("transform", function (d, i) { 287 .attr("transform", function (d, i) {
264 return "translate(0 ," + (height + 100) + ")"; 288 return "translate(0 ," + (height + 100) + ")";
265 }); 289 });
266 -  
267 } 290 }
268 291
269 function draw_categories_grouped(data, categories) { 292 function draw_categories_grouped(data, categories) {
270 293
  294 + // TODO: use ymax_from_stacked
271 // Get the max y to plot 295 // Get the max y to plot
272 // If no data at all, force to 0 296 // If no data at all, force to 0
273 if (categories.length == 0) { 297 if (categories.length == 0) {
@@ -307,14 +331,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -307,14 +331,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
307 .enter().append("rect") 331 .enter().append("rect")
308 .attr("x", d => xCategories(d.key)) 332 .attr("x", d => xCategories(d.key))
309 .attr("width", xCategories.bandwidth()) 333 .attr("width", xCategories.bandwidth())
310 - /* Transition part */  
311 - .attr("y", d => { return height; })  
312 - .attr("height", 0)  
313 - .transition()  
314 - .duration(750)  
315 - .delay(function (d, i) {  
316 - return i * 150;  
317 - }) 334 + /* Transition part */
  335 + .attr("y", d => {
  336 + return height;
  337 + })
  338 + .attr("height", 0)
  339 + .transition()
  340 + .duration(750)
  341 + .delay(function (d, i) {
  342 + return i * 150;
  343 + })
318 .attr("y", d => yScale(d.value)) 344 .attr("y", d => yScale(d.value))
319 .attr("height", d => height - yScale(d.value)) 345 .attr("height", d => height - yScale(d.value))
320 .attr("fill", d => colorScale(d.key)) 346 .attr("fill", d => colorScale(d.key))
@@ -328,15 +354,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -328,15 +354,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
328 354
329 } 355 }
330 356
331 - function draw_categories_stacked(data, categories) {  
332 -  
333 - // Now build the stacked data for stacked bars  
334 - //  
335 - var stack = d3.stack()  
336 - .keys(categories)  
337 - // .order(d3.stackOrderNone)  
338 - // .offset(d3.stackOffsetNone);  
339 - var stacked_data = stack(data) 357 + function ymax_from_stacked(stacked_data) {
340 358
341 // Get the max y to plot 359 // Get the max y to plot
342 // If no data at all, force to 0 360 // If no data at all, force to 0
@@ -352,6 +370,52 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -352,6 +370,52 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
352 // Enhance it by %ratio to leave room on the top of the graph 370 // Enhance it by %ratio to leave room on the top of the graph
353 y_max = y_max * height_ratio 371 y_max = y_max * height_ratio
354 372
  373 + return y_max
  374 + }
  375 +
  376 + function draw_projects_areas(data, projects) {
  377 +
  378 + // Now build the stacked data for stacked bars
  379 + //
  380 + var stack = d3.stack()
  381 + .keys(projects)
  382 + // .order(d3.stackOrderNone)
  383 + // .offset(d3.stackOffsetNone);
  384 + var stacked_data = stack(data)
  385 +
  386 + const y_max = ymax_from_stacked(stacked_data);
  387 + yScale.domain([0, y_max]);
  388 +
  389 + const area = d3.area()
  390 + .x(d => xScale(d.data.period))
  391 + .y0(d => yScale(d[0]))
  392 + .y1(d => yScale(d[1]))
  393 +
  394 + let paths = svg.append("g")
  395 + .selectAll("path")
  396 + .data(stacked_data)
  397 + .join("path")
  398 + .attr("fill", ({key}) => colorScale(key))
  399 + .attr("stroke", 'black')
  400 + .attr("stroke-width", '0.2pt')
  401 + .attr("d", area)
  402 + .append("title")
  403 + .text(({key}) => key);
  404 +
  405 + }
  406 +
  407 +
  408 + function draw_categories_stacked(data, categories) {
  409 +
  410 + // Now build the stacked data for stacked bars
  411 + //
  412 + var stack = d3.stack()
  413 + .keys(categories)
  414 + // .order(d3.stackOrderNone)
  415 + // .offset(d3.stackOffsetNone);
  416 + var stacked_data = stack(data)
  417 +
  418 + const y_max = ymax_from_stacked(stacked_data);
355 yScale.domain([0, y_max]); 419 yScale.domain([0, y_max]);
356 420
357 // first draw one group for one category containing all bars over periods 421 // first draw one group for one category containing all bars over periods
@@ -370,14 +434,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -370,14 +434,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
370 .attr("class", "bar") 434 .attr("class", "bar")
371 .attr("x", d => xScale(d.data.period)) 435 .attr("x", d => xScale(d.data.period))
372 .attr("width", xScale.bandwidth()) 436 .attr("width", xScale.bandwidth())
373 - /* transition part */  
374 - .attr("y", d => { return height; })  
375 - .attr("height", 0)  
376 - .transition()  
377 - .duration(750)  
378 - .delay(function (d, i) {  
379 - return i * 150;  
380 - }) 437 + /* transition part */
  438 + .attr("y", d => {
  439 + return height;
  440 + })
  441 + .attr("height", 0)
  442 + .transition()
  443 + .duration(750)
  444 + .delay(function (d, i) {
  445 + return i * 150;
  446 + })
381 .attr("y", d => yScale(d[1])) 447 .attr("y", d => yScale(d[1]))
382 .attr("height", d => height - yScale(d[1] - d[0])) 448 .attr("height", d => height - yScale(d[1] - d[0]))
383 449
@@ -437,8 +503,8 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -437,8 +503,8 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
437 503
438 d3.csv(data_url).then(data => { 504 d3.csv(data_url).then(data => {
439 // we could get categories that way, 505 // we could get categories that way,
440 - // but instead we want to filter by non zero, see later  
441 // var categories = data.columns.slice(1) 506 // var categories = data.columns.slice(1)
  507 + // but instead we want to filter by non zero, see later
442 508
443 var periods = d3.map(data, d => d.period) 509 var periods = d3.map(data, d => d.period)
444 xScale.domain(periods) 510 xScale.domain(periods)
@@ -477,6 +543,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) { @@ -477,6 +543,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
477 } 543 }
478 544
479 // 545 //
  546 + // Draw the areas, stacked.
  547 + //
  548 + draw_areas(data, categories)
  549 +
  550 + //
480 // Draw the bars, stacked or grouped 551 // Draw the bars, stacked or grouped
481 // 552 //
482 draw_categories_bars(data, categories) 553 draw_categories_bars(data, categories)
app/main/templates/projects_stats.html 0 → 100644
@@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
  1 +{% extends "base_page.html" %}
  2 +{% block more_heads %}
  3 + <link href="{{ url_for('main.static', filename='css/charges.css', version=config.VERSION) }}" rel="stylesheet"
  4 + type="text/css"/>
  5 +{% endblock %}
  6 +
  7 +{% block content %}
  8 +
  9 + <!-- Invisible span to definte wich ul and a in the navbar are actived -->
  10 + <span id="nav_actived" style="display: none">project,projects_stats</span>
  11 +
  12 + <div class="charge_chart" id="projects_stats_chart"></div>
  13 +{% endblock %}
  14 +
  15 +{% block more_scripts %}
  16 + {% include 'd3js-includes.html' %}
  17 + {% include 'charges-includes.html' %}
  18 +
  19 + <script>
  20 + build_chart("#projects_stats_chart",
  21 + "{{url_for('main.projects_stats_csv')}}",
  22 + "Charge des projets",
  23 + "area");
  24 + </script>
  25 +{% endblock %}
app/templates/base_page.html
@@ -71,7 +71,7 @@ @@ -71,7 +71,7 @@
71 <li class="nav-item"><a id="categories" class="sub_link nav-link" href="{{ url_for('main.categories') }}">Liste des catégories</a></li> 71 <li class="nav-item"><a id="categories" class="sub_link nav-link" href="{{ url_for('main.categories') }}">Liste des catégories</a></li>
72 <li class="nav-item"><a id="labels" class="sub_link nav-link" href="{{ url_for('main.labels') }}">Liste des labels</a></li> 72 <li class="nav-item"><a id="labels" class="sub_link nav-link" href="{{ url_for('main.labels') }}">Liste des labels</a></li>
73 <li class="nav-item"><a class="sub_link nav-link disabled" href="#">Listes des statuts de projets</a> 73 <li class="nav-item"><a class="sub_link nav-link disabled" href="#">Listes des statuts de projets</a>
74 - <li class="nav-item"><a class="sub_link nav-link disabled" href="#">Statistiques</a></li> 74 + <li class="nav-item"><a id="projects_stats" class="sub_link nav-link" href="{{ url_for('main.projects_stats') }}">Statistiques</a></li>
75 </li> 75 </li>
76 </ul> 76 </ul>
77 </li> 77 </li>
tests/backend_tests.py
@@ -75,6 +75,12 @@ class DbMgrTestCase(BaseTestCase): @@ -75,6 +75,12 @@ class DbMgrTestCase(BaseTestCase):
75 # Waiting for 17 periods + headers line 75 # Waiting for 17 periods + headers line
76 self.assertEqual(18, len(stacked_charges)) 76 self.assertEqual(18, len(stacked_charges))
77 77
  78 + def test_charges_for_projects_stacked(self):
  79 + stacked_charges = db_mgr.charges_for_projects_stacked()
  80 + # Waiting for 17 periods + headers line
  81 + self.assertEqual(18, len(stacked_charges))
  82 + self.assertEqual(102, len(stacked_charges[0]))
  83 +
78 def test_category_labels(self): 84 def test_category_labels(self):
79 category_labels = db_mgr.category_labels() 85 category_labels = db_mgr.category_labels()
80 categories = [_cl['name'] for _cl in category_labels] 86 categories = [_cl['name'] for _cl in category_labels]