Merge branch '28-statistics' into 'staging'

Resolve "Statistics"

See merge request s3lph/matemat!48
This commit is contained in:
s3lph 2018-10-01 19:28:15 +00:00
commit 293ad9f06e
7 changed files with 313 additions and 3 deletions

View file

@ -1,11 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Any, Type from typing import Any, Dict, List, Optional, Tuple, Type
import crypt import crypt
from hmac import compare_digest from hmac import compare_digest
from datetime import datetime from datetime import datetime
import logging
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt,\ from matemat.db.primitives import User, Product, ReceiptPreference, Receipt,\
Transaction, Consumption, Deposit, Modification Transaction, Consumption, Deposit, Modification
@ -663,3 +662,74 @@ class MatematDatabase(object):
receipt_id = -1 receipt_id = -1
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow()) receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow())
return receipt return receipt
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
consumptions: Dict[str, Tuple[int, int]] = dict()
total_income: int = 0
total_consumption: int = 0
total_balance: int = 0
positive_balance: int = 0
negative_balance: int = 0
total_deposits: int = 0
with self.db.transaction(exclusive=False) as c:
# Fetch sum of consumptions, grouped by products
c.execute('''
SELECT COALESCE(SUM(t.value), 0), COUNT(c.ta_id), c.product
FROM transactions AS t
JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.date >= :from_date
AND t.date < :to_date
GROUP BY c.product''', {
'from_date': from_date.timestamp(),
'to_date': to_date.timestamp()
})
for value, count, product in c.fetchall():
consumptions[product] = value, count
total_income -= value
total_consumption += count
# Fetch sum of balances, grouped by users
c.execute('''
SELECT COALESCE(
(
SELECT t.old_balance
FROM transactions AS t
WHERE t.date >= :to_date
AND t.user_id = u.user_id
LIMIT 1
), u.balance)
FROM users AS u
''', [to_date.timestamp()])
for balance, in c.fetchall():
if balance > 0:
positive_balance += balance
else:
negative_balance += balance
total_balance += balance
# Fetch sum of deposits
c.execute('''
SELECT COALESCE(SUM(t.value), 0)
FROM transactions AS t
JOIN deposits AS d
ON t.ta_id = d.ta_id
WHERE t.date >= :from_date
AND t.date < :to_date''', {
'from_date': from_date.timestamp(),
'to_date': to_date.timestamp()
})
for deposit, in c.fetchall():
total_deposits += deposit
return {
'consumptions': consumptions,
'total_income': total_income,
'total_consumption': total_consumption,
'total_balance': total_balance,
'positive_balance': positive_balance,
'negative_balance': negative_balance,
'total_deposits': total_deposits,
}

View file

@ -495,7 +495,6 @@ class DatabaseTest(unittest.TestCase):
def test_create_receipt(self): def test_create_receipt(self):
with self.db as db: with self.db as db:
now: datetime = datetime.utcnow()
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True) admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True) user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True)
product: Product = db.create_product('Flora Power Mate', 200, 200) product: Product = db.create_product('Flora Power Mate', 200, 200)
@ -576,4 +575,45 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(500, t22.value) self.assertEqual(500, t22.value)
self.assertEqual(4200, t22.old_balance) self.assertEqual(4200, t22.old_balance)
def test_generate_sales_statistics(self):
with self.db as db:
user1: User = db.create_user('user1', 'supersecurepassword', 'user1@example.com', True, False)
user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False)
user3: User = db.create_user('user3', 'supersecurepassword', 'user3@example.com', True, False)
user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False)
flora: Product = db.create_product('Flora Power Mate', 200, 200)
club: Product = db.create_product('Club Mate', 200, 200)
# Create some transactions
db.deposit(user1, 1337)
db.deposit(user2, 4200)
db.deposit(user3, 1337)
db.deposit(user4, 4200)
for _ in range(10):
db.increment_consumption(user1, flora)
db.increment_consumption(user2, flora)
db.increment_consumption(user3, club)
db.increment_consumption(user4, club)
# Generate statistics
now = datetime.utcnow()
stats = db.generate_sales_statistics(now - timedelta(days=1), now + timedelta(days=1))
self.assertEqual(7, len(stats))
self.assertEqual(8000, stats['total_income'])
self.assertEqual(40, stats['total_consumption'])
self.assertEqual(3074, stats['total_balance'])
self.assertEqual(4400, stats['positive_balance'])
self.assertEqual(-1326, stats['negative_balance'])
self.assertEqual(11074, stats['total_deposits'])
self.assertIn('consumptions', stats)
consumptions = stats['consumptions']
self.assertEqual(2, len(consumptions))
self.assertEqual(-4000, consumptions['Flora Power Mate'][0])
self.assertEqual(20, consumptions['Flora Power Mate'][1])
self.assertEqual(-4000, consumptions['Club Mate'][0])
self.assertEqual(20, consumptions['Club Mate'][1])
# TODO: Test cases for primitive object vs database row mismatch # TODO: Test cases for primitive object vs database row mismatch

View file

@ -15,4 +15,5 @@ from .admin import admin
from .moduser import moduser from .moduser import moduser
from .modproduct import modproduct from .modproduct import modproduct
from .userbootstrap import userbootstrap from .userbootstrap import userbootstrap
from .statistics import statistics
from .receipt_smtp_cron import receipt_smtp_cron from .receipt_smtp_cron import receipt_smtp_cron

View file

@ -0,0 +1,79 @@
from typing import Any, Dict, List, Tuple, Union
from math import pi, sin, cos
from datetime import datetime, timedelta
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
from matemat.db import MatematDatabase
from matemat.db.primitives import User
from matemat.exceptions import HttpException
@pagelet('/statistics')
def statistics(method: str,
path: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str]) \
-> Union[str, bytes, PageletResponse]:
"""
The statistics page available from the admin panel.
"""
# If no user is logged in, redirect to the login page
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
return RedirectResponse('/login')
authlevel: int = session_vars['authentication_level']
auth_uid: int = session_vars['authenticated_user']
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2:
raise HttpException(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
authuser: User = db.get_user(auth_uid)
if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403)
todate: datetime = datetime.utcnow()
fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0)
if 'fromdate' in args:
fdarg: str = str(args.fromdate)
fromdate = datetime.strptime(fdarg, '%Y-%m-%d').replace(tzinfo=None)
if 'todate' in args:
tdarg: str = str(args.todate)
todate = datetime.strptime(tdarg, '%Y-%m-%d').replace(tzinfo=None)
todate = (todate + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
stats: Dict[str, Any] = db.generate_sales_statistics(fromdate, todate)
slices: List[Tuple[str, float, float, float, float, int, float, float, int]] = list()
previous_angle: float = 0.0
total = sum([i[1] for i in stats['consumptions'].values()])
# Really hacky pie chart implementation
for (product, (_, count)) in stats['consumptions'].items():
angle: float = (count/total) * 2.0 * pi
longarc: int = 0 if angle < pi else 1
halfangle: float = angle / 2.0
angle = (angle + previous_angle) % (2.0 * pi)
halfangle = (halfangle + previous_angle) % (2.0 * pi)
px: float = cos(previous_angle) * 100
py: float = sin(previous_angle) * 100
x: float = cos(angle) * 100
y: float = sin(angle) * 100
tx: float = cos(halfangle) * 130
ty: float = sin(halfangle) * 130
slices.append((product, px, py, x, y, longarc, tx, ty, count))
previous_angle = angle
# Render the statistics page
return TemplateResponse('statistics.html',
fromdate=fromdate.strftime('%Y-%m-%d'),
todate=(todate - timedelta(days=1)).strftime('%Y-%m-%d'),
product_slices=slices,
authuser=authuser, authlevel=authlevel,
setupname=config['InstanceName'],
**stats)

View file

@ -29,3 +29,15 @@
display: block; display: block;
font-weight: bolder; font-weight: bolder;
} }
@media print {
footer {
position: fixed;
bottom: 0;
font-size: 0.8em;
}
footer li {
display: inline-block;
margin-right: 40px;
}
}

View file

@ -22,6 +22,7 @@
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #} {# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
{% if authuser.is_admin %} {% if authuser.is_admin %}
<a href="/admin">Administration</a> <a href="/admin">Administration</a>
<a href="/statistics">Sales Statistics</a>
{% else %} {% else %}
<a href="/admin">Settings</a> <a href="/admin">Settings</a>
{% endif %} {% endif %}

107
templates/statistics.html Normal file
View file

@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
<h1>{{ setupname|safe }} Sales Statistics</h1>
<style>
@media all {
svg g text {
display: none;
}
svg g:hover text {
display: block;
}
span.input-replacement {
display: none;
}
}
@media print {
svg g text {
display: block;
}
input {
display: none;
}
span.input-replacement {
display: inline;
}
}
</style>
{{ super() }}
{% endblock %}
{% block main %}
<section id="statistics-range">
<h2>Time Range</h2>
<form action="/statistics" method="get" accept-charset="utf-8">
<label for="statistics-range-from">From:</label>
<input type="date" id="statistics-range-from" name="fromdate" value="{{fromdate}}" />
<span class="input-replacement">{{fromdate}}</span>
<label for="statistics-range-to">To:</label>
<input type="date" id="statistics-range-to" name="todate" value="{{todate}}" />
<span class="input-replacement">{{todate}}</span>
<input type="submit" value="Update">
</form>
</section>
<section id="statistics-total">
<h2>Totals</h2>
<ul>
<li>Total products purchased: {{ total_consumption }}</li>
<li>Total income: {{ total_income|chf }}</li>
<li>Total deposited: {{ total_deposits|chf }}</li>
<li>Total accounts balance: {{ total_balance|chf }}</li>
</ul>
</section>
<section>
<h2>Purchases</h2>
<table>
<tr class="head"><td>Product</td><td>Income</td><td>Units</td></tr>
{% for prod, data in consumptions.items() %}
<tr class="{{ loop.cycle('odd', '') }}"><td><b>{{ prod }}</b></td><td>{{ -data[0]|chf }}</td><td>{{ data[1] }}</td></tr>
{% endfor %}
<tr class="foot"><td>Total</td><td>{{ total_income|chf }}</td><td>{{ total_consumption }}</td></tr>
</table>
{# Really hacky pie chart implementation. #}
<svg width="400" height="400">
{% for s in product_slices %}
<g>
<path d="M 200 200 L {{ s[1]+200 }} {{ s[2]+200 }} A 100 100 0 {{ s[5] }} 1 {{ s[3]+200 }} {{ s[4]+200 }} L 200 200"
fill="{{ loop.cycle('green', 'red', 'blue', 'yellow', 'purple', 'orange') }}"></path>
<text text-anchor="middle"
x="{{ 200 + s[6] }}"
y="{{ 200 + s[7] }}">{{ s[0] }} ({{ s[8] }})</text>
</g>
{% endfor %}
</svg>
</section>
<section id="statistics-balances">
<h2>Account Balances</h2>
<ul>
<li>Total account balances: {{ total_balance|chf }}</li>
<li>Total positive account balances: {{ positive_balance|chf }}</li>
<li>Total negative account balances: {{ negative_balance|chf }}</li>
</ul>
</section>
{{ super() }}
{% endblock %}