From 17545b52617c5ea7cb2b81bd849d906213f3e574 Mon Sep 17 00:00:00 2001 From: Nikolai Kristiansen Date: Sun, 29 Nov 2015 18:51:31 +0100 Subject: [PATCH] More stats! --- main/api/serializers.py | 5 +- main/api/views.py | 1 + main/urls.py | 19 +++--- main/views/stats.py | 110 +++++++++++++++++++++++----------- static/app/js/stats.js | 87 +++++++++++---------------- static/app/styles/screen.scss | 9 +++ static/dist/js/app.min.js | 20 +++---- static/dist/service-worker.js | 2 +- static/dist/styles/screen.css | 2 +- static/gulpfile.js | 29 +++++---- templates/base.html | 30 +++++----- templates/profile.html | 6 +- templates/stats.html | 23 +++---- 13 files changed, 189 insertions(+), 154 deletions(-) diff --git a/main/api/serializers.py b/main/api/serializers.py index 74a0fcc..9a503d4 100644 --- a/main/api/serializers.py +++ b/main/api/serializers.py @@ -1,8 +1,8 @@ -from django.db.models import Sum +from django.db.models import Sum, Count from rest_framework import serializers from rest_framework.serializers import ModelSerializer -from main.models import Product, OrderLine +from main.models import Product, OrderLine, Order class ProductSerializer(ModelSerializer): @@ -10,7 +10,6 @@ class ProductSerializer(ModelSerializer): model = Product - class ProductStatSerializer(ModelSerializer): EXTERNAL_USER = 'Ekstern' counts = serializers.SerializerMethodField() diff --git a/main/api/views.py b/main/api/views.py index 3689b39..bc32c0a 100644 --- a/main/api/views.py +++ b/main/api/views.py @@ -19,3 +19,4 @@ class ProductViewSet(mixins.ListModelMixin, serializer = ProductStatSerializer(products, many=True) return Response(serializer.data) + diff --git a/main/urls.py b/main/urls.py index 5ca9a24..bdf83c4 100644 --- a/main/urls.py +++ b/main/urls.py @@ -1,6 +1,7 @@ from django.conf.urls import url, patterns from main.api.views import ProductViewSet -from main.views.stats import stats_list, stats_orders, stats_orders_hourly, stats_products_realtime, stats_products_per_user +from main.views.stats import stats_list, stats_products_realtime, stats_products_per_user, \ + OrdersHourlyView, OrdersDailyView, OrdersMonthlyView, OrdersYearlyView from main.views.general import register, deposit, log from main.views.reports import inventory, inventory_add, report from main.views.users import create_user, profile @@ -12,12 +13,6 @@ urlpatterns = [ url(r'^deposit/$', deposit, name='deposit'), url(r'^log/$', log, name='log'), url(r'^log/all$', log, kwargs={'limit': None}, name='log-all'), - # Stats - url(r'^stats/$', stats_list, name='stats'), - url(r'^stats/orders/$', stats_orders, name='stats-orders'), - url(r'^stats/orders/hourly$', stats_orders_hourly, name='stats-orders-hourly'), - url(r'^stats/orders/products_realtime$', stats_products_realtime, name='stats-products-realtime'), - url(r'^stats/products/by_user/(?P[0-9]+)/$', stats_products_per_user, name='stats-products-per-user'), # Users url(r'^user/create/$', create_user, name='create-user'), url(r'^profile/$', profile, name='profile'), @@ -27,6 +22,16 @@ urlpatterns = [ url(r'^report$', report, name='report'), ] +# Stats +urlpatterns += [ + url(r'^stats/$', stats_list, name='stats'), + url(r'^stats/orders/hourly/$', OrdersHourlyView.as_view(), name='stats-orders-hourly'), + url(r'^stats/orders/daily/', OrdersDailyView.as_view(), name='stats-orders-daily'), + url(r'^stats/orders/monthly/', OrdersMonthlyView.as_view(), name='stats-orders-monthly'), + url(r'^stats/orders/yearly/', OrdersYearlyView.as_view(), name='stats-orders-yearly'), + url(r'^stats/orders/products_realtime$', stats_products_realtime, name='stats-products-realtime'), + url(r'^stats/products/by_user/(?P[0-9]+)/$', stats_products_per_user, name='stats-products-per-user'), +] # Root level service-worker.js urlpatterns += patterns( 'django.contrib.staticfiles.views', diff --git a/main/views/stats.py b/main/views/stats.py index 324e5e4..ff7d98a 100644 --- a/main/views/stats.py +++ b/main/views/stats.py @@ -1,57 +1,97 @@ from __future__ import unicode_literals + import time from datetime import datetime, timedelta -from django.db.models import Sum, Count from django.http import JsonResponse -from django.shortcuts import render, get_object_or_404 +from django.shortcuts import render +from django.utils import timezone from itertools import groupby -from django.utils import six +from django.views.generic import View from main.models import Product, OrderLine, Order def stats_list(request): products = Product.objects.filter(active=True) + order_groups = ['hourly', 'daily', 'monthly', 'yearly'] - return render(request, 'stats.html', {'products': products}) + return render(request, 'stats.html', {'products': products, 'order_groups': order_groups}) -def stats_orders(request): - num_external_orders = Order.objects.filter(customer=None).count() - per_user = Order.objects\ - .values('customer__first_name', 'customer__last_name')\ - .annotate(num=Count('customer'))\ - .order_by('num').reverse() - f_per_user = [] - for row in per_user: - if row['customer__first_name'] is not None: - who = row['customer__first_name'] + " " + row['customer__last_name'] - num = row['num'] - else: - who = "Ekstern" - num = num_external_orders - f_per_user.append([who, num]) +class OrdersView(View): + grouping = 'orders' - return JsonResponse(f_per_user, safe=False) + def get_range(self): + raise NotImplementedError + + def get_key(self, order): + raise NotImplementedError + + def group_by(self, orders): + orders_grouped = dict([(key, 0) for key in self.get_range()]) # init + for key, values in groupby(orders, key=self.get_key): + count = len(list(values)) + orders_grouped[key] += count + + return orders_grouped + + def get(self, request): + orders = Order.objects.all() + if not orders.exists(): + return JsonResponse({}) + + orders_grouped = self.group_by(orders) + data = { + 'start': str(orders[0].created), + 'orders': [[k, v] for k, v in orders_grouped.items()], + 'total': sum(orders_grouped.values()), + 'grouping': self.grouping + } + return JsonResponse(data) -def stats_orders_hourly(request): - orders = Order.objects.all() - f_hourly = dict([(key, 0) for key in range(0, 24)]) # init - # group by hour - for key, values in groupby(orders, key=lambda row: row.created.hour): - count = len(list(values)) - f_hourly[key] += count +class OrdersHourlyView(OrdersView): + grouping = 'hourly' - if len(orders) == 0: - return JsonResponse({}) + def get_range(self): + return range(0, 24) - response = { - 'start': str(orders[0].created), - 'hourly': [[k, v] for k, v in f_hourly.items()], - 'total': sum(f_hourly.values()), - } - return JsonResponse(response) + def get_key(self, order): + return order.created.hour + + +class OrdersDailyView(OrdersView): + grouping = 'daily' + + def get_range(self): + return range(0, 7) + + def get_key(self, order): + return order.created.weekday() + + +class OrdersMonthlyView(OrdersView): + grouping = 'monthly' + + def get_range(self): + return range(1, 13) + + def get_key(self, order): + return order.created.month + + +class OrdersYearlyView(OrdersView): + grouping = 'yearly' + + def get_range(self): + first = Order.objects.order_by('created').first() + last = timezone.now().year + first = first.created.year if first else last + + return range(first, last+1) + + def get_key(self, order): + return order.created.year def stats_products_realtime(request): diff --git a/static/app/js/stats.js b/static/app/js/stats.js index ded630a..fac0820 100644 --- a/static/app/js/stats.js +++ b/static/app/js/stats.js @@ -6,8 +6,7 @@ var products_realtime_chart = null; var products_url = '/api/products/'; var products_stat_url = '/api/products/group_by_user'; -var order_url = '/stats/orders/'; -var order_hourly_url = '/stats/orders/hourly'; +var order_base_url = '/stats/orders/{group}/'; var products_realtime_url = '/stats/orders/products_realtime'; function productFormatter() { @@ -93,47 +92,29 @@ function renderProductCharts(products) { }); } } -function renderOrdersChart(data) { - var render_to = 'order_chart'; - new Highcharts.Chart({ - chart: { - renderTo: render_to, - plotBackgroundColor: null, - plotBorderWidth: null, - plotShadow: false - }, - title: { - text: 'Fordeling av ordre på person' - }, - tooltip: { - formatter: function () { - return '' + this.point.name + ': ' + Highcharts.numberFormat(this.percentage, 1) + ' %'; - } - }, - plotOptions: { - pie: { - allowPointSelect: true, - cursor: 'pointer', - dataLabels: { - enabled: true, - color: '#000000', - connectorColor: '#000000', - formatter: function () { - return '' + this.point.name + ': ' + this.y; - } - } - } - }, - series: [{ - type: 'pie', - name: 'Orders', - data: data - }] - }); -} -function renderOrdersHourlyChart(data) { - var render_to = 'order_hourly_chart'; +var orderFormatters = { + hourly: function() { + return this.value + ":00"; + }, + daily: function() { + var days = {0: 'Mandag', 1: 'Tirsdag', 2: 'Onsdag', 3: 'Torsdag', 4: 'Fredag', 5: 'Lørdag', 6: 'Søndag'}; + return days[this.value]; + }, + monthly: function() { + var months = { + 1: 'Januar', 2: 'Februar', 3: 'Mars', 4: 'April', 5: 'Mai', 6: 'Juni', 7: 'Juli', + 8: 'August', 9: 'September', 10: 'Oktober', 11: 'November', 12: 'Desember'}; + return months[this.value]; + }, + yearly: function() { + return this.value; + } +}; + +function renderOrdersGroupedChart(data) { + var grouping = data.grouping; + var render_to = 'order_chart_' + grouping; new Highcharts.Chart({ chart: { @@ -141,7 +122,7 @@ function renderOrdersHourlyChart(data) { type: 'column' }, title: { - text: 'Ordre - time for time' + text: 'Ordre - ' + grouping }, tooltip: { formatter: function() { @@ -155,17 +136,16 @@ function renderOrdersHourlyChart(data) { }, xAxis: { title: { - text: 'Tid (h)' + text: 'Tid' }, labels: { - formatter: function() { - return this.value + ":00"; - } + formatter: orderFormatters[grouping] } }, plotOptions: { column: { - minPointLength: 3 + minPointLength: 3, + colorByPoint: true }, series: { groupPadding: 0, @@ -174,7 +154,7 @@ function renderOrdersHourlyChart(data) { }, series: [{ name: 'Ordre', - data: data.hourly + data: data.orders }] }); } @@ -188,11 +168,12 @@ $(document).ready(function() { products = data; renderProductCharts(data); }); - /* Orders */ - $.getJSON(order_url, renderOrdersChart); - /* Orders, hourly */ - $.getJSON(order_hourly_url, renderOrdersHourlyChart); + /* Orders, grouped */ + $('[data-group]').each(function () { + var url = order_base_url.replace('{group}', $(this).data('group')); + $.getJSON(url, renderOrdersGroupedChart); + }); /* Realtime purchases */ products_realtime_chart = new Highcharts.Chart({ diff --git a/static/app/styles/screen.scss b/static/app/styles/screen.scss index d7049dc..7d428c7 100644 --- a/static/app/styles/screen.scss +++ b/static/app/styles/screen.scss @@ -56,6 +56,7 @@ body { img.profile { width: 50px; height: 50px; + border-radius: 4px; } } @@ -90,6 +91,7 @@ body { width: 64px; height: auto; max-width: 100%; + border-radius: 4px; } } } @@ -168,6 +170,13 @@ body { } } } +#transactions { + img.customer { + width: 50px; + height: 50px; + border-radius: 4px; + } +} #user-profile { .profile-name { display: inline-block; diff --git a/static/dist/js/app.min.js b/static/dist/js/app.min.js index 66cbb8e..7497d74 100644 --- a/static/dist/js/app.min.js +++ b/static/dist/js/app.min.js @@ -1,10 +1,10 @@ -function enable_all_customers(){$("li.customer").each(function(t){$(this).removeClass("marked"),$(this).show()})}function filter_customers(t){setTimeout(function(){$("li.customer").each(function(e){var i=new RegExp(t,"i"),n=$(this).children("span.name").text();if(i.test(n)){$(this).show();var r=n.toLowerCase()==t.toLowerCase(),o=1==$("li.customer:visible").length;console.log(r,o),r?$(this).addClass("marked"):$(this).removeClass("marked")}else $(this).hide(),$(this).removeClass("marked")})},50)}function enable_all_products(){$("li.product").each(function(t){$(this).removeClass("marked"),$(this).show()})}function reset_qtys(){$(".qty").each(function(t){$(this).html("0")}),$("input[name$=amount]").each(function(t){$(this).val("0")})}function update_total(){var t=0;$("li.product").each(function(){var e=Number($(this).find("[name$=amount]").val()),i=".sale_price_int";"Ekstern"===$("#id_customer_typeahead").val()&&(i=".sale_price_ext");var n=Number($(this).find(i).text());t+=e*n}),0===t&&(t=""),$("span.total_text").html(t)}function productFormatter(){return""+this.point.name+": "+Highcharts.numberFormat(this.percentage,1)+" %"}function parse_products_realtime_data(t,e){for(var i in t.products){null===e.get(i)&&e.addSeries({data:[],id:i,name:i});var n=e.get(i);_.each(t.products[i],function(t){null===e.get(t[0])&&n.addPoint({id:t[0],x:t[0],y:t[1]})})}}function get_products_realtime_data(){$.ajax({url:products_realtime_url,dataType:"json",success:function(t){parse_products_realtime_data(t,products_realtime_chart),setTimeout(function(){get_products_realtime_data()},15e3)},cache:!1})}function renderProductCharts(t){for(var e in t){var i="product_chart"+t[e].id,n=t[e].counts;n=_.map(n,function(t){return[t.full_name,t.amount_total]}),new Highcharts.Chart({chart:{renderTo:i,plotBackgroundColor:null,plotBorderWidth:null,plotShadow:!1},title:{text:t[e].name},tooltip:{formatter:productFormatter},plotOptions:{pie:{allowPointSelect:!0,cursor:"pointer",dataLabels:{enabled:!0,color:"#000000",connectorColor:"#000000",formatter:function(){return""+this.point.name+": "+this.y}}}},series:[{type:"pie",name:"Orders",data:n}]})}}function renderOrdersChart(t){var e="order_chart";new Highcharts.Chart({chart:{renderTo:e,plotBackgroundColor:null,plotBorderWidth:null,plotShadow:!1},title:{text:"Fordeling av ordre på person"},tooltip:{formatter:function(){return""+this.point.name+": "+Highcharts.numberFormat(this.percentage,1)+" %"}},plotOptions:{pie:{allowPointSelect:!0,cursor:"pointer",dataLabels:{enabled:!0,color:"#000000",connectorColor:"#000000",formatter:function(){return""+this.point.name+": "+this.y}}}},series:[{type:"pie",name:"Orders",data:t}]})}function renderOrdersHourlyChart(t){var e="order_hourly_chart";new Highcharts.Chart({chart:{renderTo:e,type:"column"},title:{text:"Ordre - time for time"},tooltip:{formatter:function(){return this.y+" stk"}},yAxis:{title:{text:"Ordre"}},xAxis:{title:{text:"Tid (h)"},labels:{formatter:function(){return this.value+":00"}}},plotOptions:{column:{minPointLength:3},series:{groupPadding:0,pointPadding:.05}},series:[{name:"Ordre",data:t.hourly}]})}!function(t,e){"object"==typeof module&&"object"==typeof module.exports?module.exports=t.document?e(t,!0):function(t){if(!t.document)throw new Error("jQuery requires a window with a document");return e(t)}:e(t)}("undefined"!=typeof window?window:this,function(t,e){function i(t){var e="length"in t&&t.length,i=rt.type(t);return"function"===i||rt.isWindow(t)?!1:1===t.nodeType&&e?!0:"array"===i||0===e||"number"==typeof e&&e>0&&e-1 in t}function n(t,e,i){if(rt.isFunction(e))return rt.grep(t,function(t,n){return!!e.call(t,n,t)!==i});if(e.nodeType)return rt.grep(t,function(t){return t===e!==i});if("string"==typeof e){if(dt.test(e))return rt.filter(e,t,i);e=rt.filter(e,t)}return rt.grep(t,function(t){return rt.inArray(t,e)>=0!==i})}function r(t,e){do t=t[e];while(t&&1!==t.nodeType);return t}function o(t){var e=bt[t]={};return rt.each(t.match(xt)||[],function(t,i){e[i]=!0}),e}function s(){ft.addEventListener?(ft.removeEventListener("DOMContentLoaded",a,!1),t.removeEventListener("load",a,!1)):(ft.detachEvent("onreadystatechange",a),t.detachEvent("onload",a))}function a(){(ft.addEventListener||"load"===event.type||"complete"===ft.readyState)&&(s(),rt.ready())}function l(t,e,i){if(void 0===i&&1===t.nodeType){var n="data-"+e.replace(St,"-$1").toLowerCase();if(i=t.getAttribute(n),"string"==typeof i){try{i="true"===i?!0:"false"===i?!1:"null"===i?null:+i+""===i?+i:Ct.test(i)?rt.parseJSON(i):i}catch(r){}rt.data(t,e,i)}else i=void 0}return i}function h(t){var e;for(e in t)if(("data"!==e||!rt.isEmptyObject(t[e]))&&"toJSON"!==e)return!1;return!0}function c(t,e,i,n){if(rt.acceptData(t)){var r,o,s=rt.expando,a=t.nodeType,l=a?rt.cache:t,h=a?t[s]:t[s]&&s;if(h&&l[h]&&(n||l[h].data)||void 0!==i||"string"!=typeof e)return h||(h=a?t[s]=q.pop()||rt.guid++:s),l[h]||(l[h]=a?{}:{toJSON:rt.noop}),("object"==typeof e||"function"==typeof e)&&(n?l[h]=rt.extend(l[h],e):l[h].data=rt.extend(l[h].data,e)),o=l[h],n||(o.data||(o.data={}),o=o.data),void 0!==i&&(o[rt.camelCase(e)]=i),"string"==typeof e?(r=o[e],null==r&&(r=o[rt.camelCase(e)])):r=o,r}}function u(t,e,i){if(rt.acceptData(t)){var n,r,o=t.nodeType,s=o?rt.cache:t,a=o?t[rt.expando]:rt.expando;if(s[a]){if(e&&(n=i?s[a]:s[a].data)){rt.isArray(e)?e=e.concat(rt.map(e,rt.camelCase)):e in n?e=[e]:(e=rt.camelCase(e),e=e in n?[e]:e.split(" ")),r=e.length;for(;r--;)delete n[e[r]];if(i?!h(n):!rt.isEmptyObject(n))return}(i||(delete s[a].data,h(s[a])))&&(o?rt.cleanData([t],!0):it.deleteExpando||s!=s.window?delete s[a]:s[a]=null)}}}function d(){return!0}function p(){return!1}function f(){try{return ft.activeElement}catch(t){}}function g(t){var e=Ht.split("|"),i=t.createDocumentFragment();if(i.createElement)for(;e.length;)i.createElement(e.pop());return i}function m(t,e){var i,n,r=0,o=typeof t.getElementsByTagName!==Tt?t.getElementsByTagName(e||"*"):typeof t.querySelectorAll!==Tt?t.querySelectorAll(e||"*"):void 0;if(!o)for(o=[],i=t.childNodes||t;null!=(n=i[r]);r++)!e||rt.nodeName(n,e)?o.push(n):rt.merge(o,m(n,e));return void 0===e||e&&rt.nodeName(t,e)?rt.merge([t],o):o}function v(t){Pt.test(t.type)&&(t.defaultChecked=t.checked)}function y(t,e){return rt.nodeName(t,"table")&&rt.nodeName(11!==e.nodeType?e:e.firstChild,"tr")?t.getElementsByTagName("tbody")[0]||t.appendChild(t.ownerDocument.createElement("tbody")):t}function x(t){return t.type=(null!==rt.find.attr(t,"type"))+"/"+t.type,t}function b(t){var e=Gt.exec(t.type);return e?t.type=e[1]:t.removeAttribute("type"),t}function w(t,e){for(var i,n=0;null!=(i=t[n]);n++)rt._data(i,"globalEval",!e||rt._data(e[n],"globalEval"))}function k(t,e){if(1===e.nodeType&&rt.hasData(t)){var i,n,r,o=rt._data(t),s=rt._data(e,o),a=o.events;if(a){delete s.handle,s.events={};for(i in a)for(n=0,r=a[i].length;r>n;n++)rt.event.add(e,i,a[i][n])}s.data&&(s.data=rt.extend({},s.data))}}function T(t,e){var i,n,r;if(1===e.nodeType){if(i=e.nodeName.toLowerCase(),!it.noCloneEvent&&e[rt.expando]){r=rt._data(e);for(n in r.events)rt.removeEvent(e,n,r.handle);e.removeAttribute(rt.expando)}"script"===i&&e.text!==t.text?(x(e).text=t.text,b(e)):"object"===i?(e.parentNode&&(e.outerHTML=t.outerHTML),it.html5Clone&&t.innerHTML&&!rt.trim(e.innerHTML)&&(e.innerHTML=t.innerHTML)):"input"===i&&Pt.test(t.type)?(e.defaultChecked=e.checked=t.checked,e.value!==t.value&&(e.value=t.value)):"option"===i?e.defaultSelected=e.selected=t.defaultSelected:("input"===i||"textarea"===i)&&(e.defaultValue=t.defaultValue)}}function C(e,i){var n,r=rt(i.createElement(e)).appendTo(i.body),o=t.getDefaultComputedStyle&&(n=t.getDefaultComputedStyle(r[0]))?n.display:rt.css(r[0],"display");return r.detach(),o}function S(t){var e=ft,i=Qt[t];return i||(i=C(t,e),"none"!==i&&i||(Zt=(Zt||rt("