diff --git a/matemat/db/facade.py b/matemat/db/facade.py index ec44ac9..1c8061f 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -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, + } diff --git a/matemat/db/test/test_facade.py b/matemat/db/test/test_facade.py index 16e9b86..2ca28bf 100644 --- a/matemat/db/test/test_facade.py +++ b/matemat/db/test/test_facade.py @@ -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 diff --git a/matemat/webserver/pagelets/__init__.py b/matemat/webserver/pagelets/__init__.py index 3cdcad8..ae40e4d 100644 --- a/matemat/webserver/pagelets/__init__.py +++ b/matemat/webserver/pagelets/__init__.py @@ -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 diff --git a/matemat/webserver/pagelets/statistics.py b/matemat/webserver/pagelets/statistics.py new file mode 100644 index 0000000..61aef7c --- /dev/null +++ b/matemat/webserver/pagelets/statistics.py @@ -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) diff --git a/static/css/matemat.css b/static/css/matemat.css index ef813ca..2778f5c 100644 --- a/static/css/matemat.css +++ b/static/css/matemat.css @@ -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; + } +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index d41a1d0..a33f411 100644 --- a/templates/base.html +++ b/templates/base.html @@ -22,6 +22,7 @@ {# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #} {% if authuser.is_admin %} Administration + Sales Statistics {% else %} Settings {% endif %} diff --git a/templates/statistics.html b/templates/statistics.html new file mode 100644 index 0000000..5f3d757 --- /dev/null +++ b/templates/statistics.html @@ -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. #} +

{{ setupname|safe }} Sales Statistics

+ + {{ super() }} +{% endblock %} + +{% block main %} + +
+ +

Time Range

+ +
+ + + {{fromdate}} + + + + {{todate}} + + +
+ +
+ +
+ +

Totals

+ + + +
+ +
+ +

Purchases

+ + + + {% for prod, data in consumptions.items() %} + + {% endfor %} + +
ProductIncomeUnits
{{ prod }}{{ -data[0]|chf }}{{ data[1] }}
Total{{ total_income|chf }}{{ total_consumption }}
+ + {# Really hacky pie chart implementation. #} + + {% for s in product_slices %} + + + {{ s[0] }} ({{ s[8] }}) + + {% endfor %} + + +
+ +
+ +

Account Balances

+ + + +
+ + {{ super() }} + +{% endblock %}