Commit 6c51f5770678962a71a1ebb2719d6ff3248f98b6

Authored by hitier
2 parents 97e76b37 f8a685cd

Projects Stats Chart

CHANGELOG.md
... ... @@ -24,6 +24,10 @@ or major refactoring improvments.
24 24  
25 25 ## Unreleased
26 26  
  27 +## [0.3.pre-13] - 2021-05-19 - Projects stats
  28 +### New
  29 +Projects stats charts
  30 +
27 31 ## [0.3.pre-12] - 2021-05-19 - Enhance Front end
28 32 ### Changed
29 33 Speed up loading
... ...
VERSION.txt
1   -0.3.pre-12
  1 +0.3.pre-13
... ...
app/db_mgr.py
... ... @@ -202,6 +202,28 @@ def charges_by_project_stacked(project_id, category="service"):
202 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 227 def charges_by_agent_stacked(agent_id):
206 228 """
207 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 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 64 @bp.route('/agents')
58 65 @role_required('project')
59 66 def agents():
... ... @@ -277,3 +284,16 @@ def charge_agent_csv(agent_id):
277 284 resp = make_response("\n".join(csv_table))
278 285 resp.headers['Content-Type'] = 'text/plain;charset=utf8'
279 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  
8 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 11 width = main_elt.offsetWidth * 0.95 - margin.left - margin.right,
12 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 18  
19 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 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 29 var colorScale = d3.scaleOrdinal([]); // Will be really set later by category type
37 30  
38   -
39 31 //
40 32 // Configure chart by the category given as arg
41 33 //
42 34 var chart_title = ""
43 35 var category_title = ""
  36 + var draw_areas = () => void 0;
44 37 var draw_total_line = () => void 0;
45 38 var draw_categories_bars = () => void 0;
46 39  
  40 + var mouseoverlegend = category_mouseoverlegend;
  41 + var mouseleavelegend = category_mouseleavelegend;
  42 +
  43 +
47 44 if (category_type == 'capacity') {
48 45 chart_title = "Charges par fonction"
49 46 category_title = "Fonction"
... ... @@ -61,10 +58,28 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
61 58 category_title = "Projet"
62 59 draw_categories_bars = draw_categories_stacked
63 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 70 } else {
65 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 83 // Configure the tooltip to export svg to png or csv
69 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 149 var category_name = d3.select(this.parentNode).datum().key
135 150 var category_charge = d.data[category_name]
136 151 show_tooltip(e, category_name, category_charge)
  152 + console.log("HELLO")
137 153 }
138 154  
139 155 var mouseovergrouped = function (e, d) {
... ... @@ -173,34 +189,34 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
173 189 .style("top", (e.pageY - tooltip_offset_dot.dy) + "px")
174 190 }
175 191  
176   - function mouseoverlegend() {
  192 + function category_mouseoverlegend() {
177 193 var legend_category = $(this).attr("class");
178 194 var bar_category = document.getElementsByClassName(legend_category);
179 195 if (bar_category[0].tagName === "g") {
180 196 var rect_to_hover = bar_category[0].children;
181 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 199 rect_to_hover[i].classList.add("brillance");
184 200 }
185 201 } else {
186   - for (var i=0;i<bar_category.length;i++) {
  202 + for (var i = 0; i < bar_category.length; i++) {
187 203 bar_category[i].classList.add("brillance");
188 204 }
189 205 }
190 206 }
191 207  
192   - function mouseleavelegend() {
  208 + function category_mouseleavelegend() {
193 209 var legend_category = $(this).attr("class");
194 210 var bar_category = document.getElementsByClassName(legend_category);
195 211 if (bar_category[0].tagName === "g") {
196 212 var rect_to_hover = bar_category[0].children;
197 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 215 rect_to_hover[i].classList.remove("brillance");
200 216 }
201 217 } else {
202 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 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 226  
211 227 // add horizontal legend
212 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 237 var legendWrap = svg.append('g')
216 238 .attr('width', '100%')
... ... @@ -220,38 +242,40 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
220 242 .data(legend_keys)
221 243 .enter()
222 244 .append('g')
223   - .attr('class', function (d) { return d})
  245 + .attr('class', d => d)
224 246 .on("mouseover", mouseoverlegend)
225 247 .on("mouseleave", mouseleavelegend);
226 248  
227 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 252 .style('fill', color_scale)
231 253 .attr('class', 'legend');
232 254  
233 255 legend.append('text')
234 256 .attr('class', 'legend')
235   - .attr('x', legendRectSize + legendSpacing * 1.5)
  257 + .attr('x', legend_rect_size + legend_spacing * 1.5)
236 258 .attr('y', 13)
237   - .text(function (d) {
238   - return d;
239   - });
  259 + .text(d => d);
240 260  
241 261 var ypos = 0,
242 262 newxpos = 0,
243 263 maxwidth = 0,
244 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 270 legend
247 271 .attr("transform", function (d, i) {
248   - var length = d3.select(this).select("text").node().getComputedTextLength() + 40;
249 272 xpos = newxpos;
250   - if (width < xpos + length) {
  273 + if (width < xpos + keycol_length) {
251 274 newxpos = xpos = 0;
252   - ypos += 30;
  275 + ypos += legend_height;
  276 +
253 277 }
254   - newxpos += length;
  278 + newxpos += keycol_length;
255 279 if (newxpos > maxwidth) maxwidth = newxpos;
256 280  
257 281 return 'translate(' + xpos + ',' + ypos + ')';
... ... @@ -263,11 +287,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
263 287 .attr("transform", function (d, i) {
264 288 return "translate(0 ," + (height + 100) + ")";
265 289 });
266   -
267 290 }
268 291  
269 292 function draw_categories_grouped(data, categories) {
270 293  
  294 + // TODO: use ymax_from_stacked
271 295 // Get the max y to plot
272 296 // If no data at all, force to 0
273 297 if (categories.length == 0) {
... ... @@ -307,14 +331,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) {
307 331 .enter().append("rect")
308 332 .attr("x", d => xCategories(d.key))
309 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 344 .attr("y", d => yScale(d.value))
319 345 .attr("height", d => height - yScale(d.value))
320 346 .attr("fill", d => colorScale(d.key))
... ... @@ -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 359 // Get the max y to plot
342 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 370 // Enhance it by %ratio to leave room on the top of the graph
353 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 419 yScale.domain([0, y_max]);
356 420  
357 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 434 .attr("class", "bar")
371 435 .attr("x", d => xScale(d.data.period))
372 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 447 .attr("y", d => yScale(d[1]))
382 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 503  
438 504 d3.csv(data_url).then(data => {
439 505 // we could get categories that way,
440   - // but instead we want to filter by non zero, see later
441 506 // var categories = data.columns.slice(1)
  507 + // but instead we want to filter by non zero, see later
442 508  
443 509 var periods = d3.map(data, d => d.period)
444 510 xScale.domain(periods)
... ... @@ -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 551 // Draw the bars, stacked or grouped
481 552 //
482 553 draw_categories_bars(data, categories)
... ...
app/main/templates/projects_stats.html 0 → 100644
... ... @@ -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 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 72 <li class="nav-item"><a id="labels" class="sub_link nav-link" href="{{ url_for('main.labels') }}">Liste des labels</a></li>
73 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 75 </li>
76 76 </ul>
77 77 </li>
... ...
tests/backend_tests.py
... ... @@ -75,6 +75,12 @@ class DbMgrTestCase(BaseTestCase):
75 75 # Waiting for 17 periods + headers line
76 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 84 def test_category_labels(self):
79 85 category_labels = db_mgr.category_labels()
80 86 categories = [_cl['name'] for _cl in category_labels]
... ...