Merge branch '28-statistics' into 'staging'
Resolve "Statistics" See merge request s3lph/matemat!48
This commit is contained in:
commit
293ad9f06e
7 changed files with 313 additions and 3 deletions
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
79
matemat/webserver/pagelets/statistics.py
Normal file
79
matemat/webserver/pagelets/statistics.py
Normal 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)
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
107
templates/statistics.html
Normal 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 %}
|
Loading…
Reference in a new issue