Просмотр исходного кода

Numerous improvements including GUI and testing workflow

Daniel Sheffield 3 лет назад
Родитель
Сommit
2620cee79d
1 измененных файлов с 269 добавлено и 32 удалено
  1. 269 32
      grocery_transactions.py

+ 269 - 32
grocery_transactions.py

@@ -6,10 +6,112 @@
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 
-import psycopg2
+try:
+    import psycopg2
+    MOCK = False
+except:
+    from faker import Faker
+    Faker.seed(4321)
+    fake = Faker()
+    MOCK = True
+    # first, import a similar Provider or use the default one
+    from faker.providers import BaseProvider
+
+    # create new provider class
+    class Products(BaseProvider):
+        PRODUCTS = {
+            'Dark Chocolate': 'Chocolate',
+            'Milk Chocolate': 'Chocolate',
+            'Cooking Chocolate': 'Chocolate',
+            'Cucumber': 'Veggies',
+            'Bananas': 'Fruit',
+            'Milk': 'Milk and Cream',
+            'Cream': 'Milk and Cream',
+            'Coffee Beans': 'Coffee',
+            'Freerange Eggs': 'Eggs',
+        }
+        GROUPS = [
+            'Fish, Meat, Eggs',
+            'Dairy',
+            'Beverages',
+            'Treats',
+            'Produce',
+        ]
+        CATEGORIES = {
+            'Eggs': 'Fish, Meat, Eggs',
+            'Milk and Cream': 'Dairy',
+            'Chocolate': 'Treats',
+            'Veggies': 'Produce',
+            'Fruit': 'Produce',
+            'Coffee': 'Beverages',
+        }
+        STORES = [
+            'Paknsave',
+            'Countdown',
+            'New World',
+            'Dreamview',
+            'TOFS',
+        ]
+        CATEGORY_UNITS = {
+            'Eggs': ('g',),
+            'Milk and Cream': ('mL', 'L',),
+            'Chocolate': ('g', 'kg',),
+            'Veggies': ('g', 'kg', 'Piece',),
+            'Fruit': ('g', 'kg', 'Piece', 'Bunch',),
+            'Coffee': ('g', 'kg',),
+        }
+        ORGANIC = [
+            True,
+            False,
+        ]
+        def _dict_choice(self, _dict):
+            key = fake.random.choice([ i for i in _dict ])
+            return key, _dict[key]
+        
+        def _product_choice(self):
+            key = None
+            product = []
+            for _dict in (
+                self.PRODUCTS,
+                self.CATEGORIES,
+                dict([
+                    (k, None) for k in self.GROUPS
+                ]),
+            ):
+                if key is None:
+                    k, key = self._dict_choice(_dict)
+                    product.append(k)
+                    continue
+                product.append(key)
+                key = _dict[key]
+            return product
+        
+        def product(self):
+            return self._product_choice()
+        
+        def product_unit(self, product):
+            return fake.random.choice(self.CATEGORY_UNITS[product[1]])
+        
+        def description(self, *args, **kwargs):
+            return fake.sentence(*args, **kwargs)
+            
+        def store(self):
+            return fake.random.choice(self.STORES)
+        
+        def unit(self):
+            return fake.random.choice(self.UNITS)
+        
+        def organic(self):
+            return fake.random.choice(self.ORGANIC)
+
+    # then add new provider to faker instance
+    fake.add_provider(Products)
+
 import urwid
 from urwid import numedit
+from decimal import Decimal
 import time
+import datetime
 import itertools
 from collections import (
     OrderedDict,
@@ -17,8 +119,76 @@ from collections import (
 
 COPYRIGHT = "Copyright (c) Daniel Sheffield 2021"
 
-conn = psycopg2.connect("dbname=das user=das")
-cur = conn.cursor()
+class mock_conn(object):
+    
+    def close(self):
+        pass
+
+class mock_cur(object):
+    def __init__(self, records):
+        self.records = records
+    
+    def execute(self, *args, **kwargs):
+        pass
+    
+    def fetchall(self):
+        yield from self.records
+    
+    def close(self):
+        pass
+
+
+if MOCK:
+    records = [{
+        'ts': datetime.datetime(2021, 8, 29, 14, 8, 0, 0),
+        'store': 'Countdown',
+        'price': 4.30,
+        'quantity': 250.0,
+        'unit': 'g',
+        'organic': False,
+        'description': 'Whittakers',
+        'product': 'Dark Chocolate',
+        'group': 'Treats',
+        'category': 'Chocolate',
+    }]
+    for i in range(0,100):
+        product = fake.product()
+        quantity = fake.random.random()*500 + 1
+        price = fake.random.random()*10
+        unit = fake.product_unit(product)
+        organic = fake.organic()
+        words = []
+        words.extend(fake.sentence().split())
+        words.append(
+            f'{quantity:.2f} {unit} @ {price:.2f}'
+        )
+        if organic:
+            words.append('organic')
+        
+        records.append({
+            'ts': datetime.datetime(2021, 8, 29, 14, 8, 0, 0),
+            'store': fake.store(),
+            'price': price,
+            'quantity': quantity,
+            'unit': unit,
+            'organic': organic,
+            'description': ' '.join([
+                product[0],
+                *fake.description(ext_word_list=words).split()
+            ]),
+            'product': product[0],
+            'group': product[2],
+            'category': product[1],
+        })
+    col_idx_map = dict([ ( k, idx ) for idx,k in enumerate(sorted(records[0].keys())) ])
+    records = [
+        [ r[k] for k in sorted(r.keys()) ] for r in records
+    ]
+    conn = mock_conn()
+    cur = mock_cur(records)
+else:
+    conn = psycopg2.connect("dbname=das user=das")
+    cur = conn.cursor()
 
 palette = [
     ('banner', 'light gray', 'dark red'),
@@ -69,7 +239,9 @@ display_map = {
 display = lambda data, name: display_map[name](data) if name in display_map else data
 
 cur.execute("SELECT * FROM transaction_view;")
-col_idx_map = dict([ (d.name, i) for i,d in enumerate(cur.description) if d.name in cols ])
+if not MOCK:
+    col_idx_map = dict([ (d.name, i) for i,d in enumerate(cur.description) if d.name in cols ])
+
 def records(cursor, col_idx_map):
     for row in cursor.fetchall():
         yield dict([
@@ -93,10 +265,19 @@ def record_matches(record, strict=None, **kwargs):
     return True
 
 def unique_suggestions(name, exclude=NON_IDENTIFIER_COLUMNS, **kwargs):
+    exclude = filter(
+        lambda x: x != name or name == 'ts',
+        exclude,
+    )
     [ kwargs.pop(k) for k in exclude if k in kwargs]
-    return sorted(set(map(lambda x: x[name], suggestions(name, exclude=exclude, **kwargs))))
+    items = suggestions(name, exclude=exclude, **kwargs)
+    return sorted(set(map(lambda x: x[name], items)))
 
 def suggestions(name, exclude=NON_IDENTIFIER_COLUMNS, **kwargs):
+    exclude = filter(
+        lambda x: x != name or name == 'ts',
+        exclude,
+    )
     [ kwargs.pop(k) for k in exclude if k in kwargs]
     yield from filter(lambda x: record_matches(
         x, strict=[ k for k in kwargs if k != name ], **kwargs
@@ -130,18 +311,25 @@ class AutoCompleteEdit(urwid.Edit):
         self.apply = apply_change_func
 
     def keypress(self, size, key):
-        if key != 'enter':
-            return super(AutoCompleteEdit, self).keypress(size, key)
+        if key == 'enter':
+            self.apply(self.name)
+            return
+        elif key == 'delete':
+            self.set_edit_text('')
+            return
+        
+        return super(AutoCompleteEdit, self).keypress(size, key)
+
         self.apply(self.name)
 
 class AutoCompleteFloatEdit(numedit.FloatEdit):
     def __init__(self, name, *args, apply_change_func=None, **kwargs):
+        self.last_val = None
         self.op = '='
         self.pallete = None
         if isinstance(name, tuple):
-            self.pallete, title = name
-            self.name = title
-            title = title.title()
+            self.pallete, self.name = name
+            title = self.name.title()
             passthrough = (self.pallete, f'{self.op} ')
         else:
             self.name = name
@@ -157,7 +345,6 @@ class AutoCompleteFloatEdit(numedit.FloatEdit):
         else:
             self.set_caption(f'{self.op} ')
 
-
     def set_op(self, op):
         self.op = op
         self.last_val = self.value()
@@ -166,16 +353,22 @@ class AutoCompleteFloatEdit(numedit.FloatEdit):
         
     def calc(self):
         x = self.last_val
-        y = self.value()
         op = self.op
-        if op == '+':
-            z = x + y
-        elif op == '-':
-            z = x - y
-        elif op == '*':
-            z = x * y
-        elif op == '/':
-            z = x / y
+        if op in ('+', '-',):
+            y = self.value() or Decimal(0.0)
+            if op == '+':
+                z = x + y
+            else:
+                z = x - y
+        elif op in ('*', '/'):
+            y = self.value() or Decimal(1.0)
+            if op == '*':
+                z = x * y
+            else:
+                z = x / y
+        else:
+            y = self.value() or Decimal(0.0)
+            z = y
         
         self.op = '='
         self.update_caption()
@@ -185,19 +378,26 @@ class AutoCompleteFloatEdit(numedit.FloatEdit):
         if isinstance(key, tuple):
             return
         ops = ('+', '-', '*', '/',)
-        if key == 'tab':
-           self.apply(self.name)
-           return
-        elif key in ops:
+
+        if key in ops:
             if self.op in ops:
                 self.calc()
             self.set_op(key)
             return
-        elif key in ('=', 'enter',):
+        elif key == 'enter':
+            if self.get_edit_text() == '' or self.value() == Decimal(0.0):
+                return self.apply(self.name)
+            self.calc()
+            return
+        elif key == '=':
             self.calc()
             return
-        #elif key in ('esc', 'left', 'right', 'up', 'down', 'backspace', 'delete',) or \
-        #     key in '0123456789.':
+        elif key == 'delete':
+            self.set_edit_text('')
+            self.op = '='
+            self.update_caption()
+            return
+
         return super(AutoCompleteFloatEdit, self).keypress(size, key)
 
 class NoTabCheckBox(urwid.CheckBox):
@@ -231,16 +431,47 @@ class GroceryTransactionEditor(urwid.WidgetPlaceholder):
         self.widgets = dict()
         self.show('transaction')
 
-    def advance_focus(self):
+    def iter_focus_paths(self):
+        initial = [2,0,0,0]
+        container = self.original_widget.original_widget.original_widget
+        _set_focus_path(container, initial)
+        while True:
+            path = container.get_focus_path()
+            yield path
+            self.advance_focus()
+            path = container.get_focus_path()
+            if path == initial:
+                self.advance_focus()
+                break
+
+    def advance_focus(self, reverse=False):
         container = self.original_widget.original_widget.original_widget
         path = container.get_focus_path()
-        for idx, part in [ i for i in enumerate(path)][::-1]:
+        if reverse:
+            paths = [ i for i in self.iter_focus_paths() ]
+            zipped_paths = zip(paths, [
+                *paths[1:], paths[0]
+            ])
+            prev_path = map(lambda x: x[0], filter(
+                lambda x: x[1] == path,
+                zipped_paths
+            ))
+            _set_focus_path(container, next(prev_path))
+            return
+        
+        _iter = [ i for i in enumerate(path) ][::-1]
+
+        for idx, part in _iter:
             p = [ i for i in path ]
-            p[idx] += 1
+            if reverse:
+                p[idx] -= 1
+            else:
+                p[idx] += 1
+            
             try:
                 _set_focus_path(container, p)
                 if path == [3]:
-                    self.advance_focus()
+                    self.advance_focus(reverse=reverse)
                 return
             except IndexError:
                 path[idx] = 0
@@ -254,7 +485,9 @@ class GroceryTransactionEditor(urwid.WidgetPlaceholder):
         
         if key == 'tab':
             self.advance_focus()
-        else: #if key == 'esc':
+        elif key == 'shift tab':
+            self.advance_focus(reverse=True)
+        else:
             return super(GroceryTransactionEditor, self).keypress(size, key)
         
 
@@ -432,6 +665,10 @@ class GroceryTransactionEditor(urwid.WidgetPlaceholder):
         widget = urwid.AttrMap(widget, 'bg')
         return widget
 
+#screen = urwid.raw_display.Screen()
+#screen.set_terminal_properties(colors=256, has_underline=True)
+#screen.register_palette(palette)
+
 app = GroceryTransactionEditor(cols)
 loop = urwid.MainLoop(app, palette, unhandled_input=show_or_exit)
 loop.run()