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 typing import List, Optional, Any, Type
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
|
||||
import crypt
|
||||
from hmac import compare_digest
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt,\
|
||||
Transaction, Consumption, Deposit, Modification
|
||||
|
@ -663,3 +662,74 @@ class MatematDatabase(object):
|
|||
receipt_id = -1
|
||||
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow())
|
||||
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):
|
||||
with self.db as db:
|
||||
|
||||
now: datetime = datetime.utcnow()
|
||||
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@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)
|
||||
|
@ -576,4 +575,45 @@ class DatabaseTest(unittest.TestCase):
|
|||
self.assertEqual(500, t22.value)
|
||||
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
|
||||
|
|
|
@ -15,4 +15,5 @@ from .admin import admin
|
|||
from .moduser import moduser
|
||||
from .modproduct import modproduct
|
||||
from .userbootstrap import userbootstrap
|
||||
from .statistics import statistics
|
||||
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;
|
||||
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 authuser.is_admin %}
|
||||
<a href="/admin">Administration</a>
|
||||
<a href="/statistics">Sales Statistics</a>
|
||||
{% else %}
|
||||
<a href="/admin">Settings</a>
|
||||
{% 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