Browse Source

Merge branch 'limit-url-length' of gogsadmin/grocery-manager into master

Cache by hash and limit URL length by referencing hash instead when parameters are too many
gogsadmin 1 year ago
parent
commit
14d0464663
53 changed files with 2348 additions and 691 deletions
  1. 2 3
      app/activities/ActivityManager.py
  2. 16 0
      app/activities/Banner.py
  3. 40 88
      app/activities/PriceCheck.py
  4. 10 29
      app/activities/RecipeEditor.py
  5. 45 124
      app/activities/TransactionEditor.py
  6. 29 0
      app/activities/grouped_widget_util.py
  7. 34 0
      app/data/dataframe_util.py
  8. 15 0
      app/data/decimal_util.py
  9. 12 1
      app/palette/__init__.py
  10. 0 44
      app/rest/Cache.py
  11. 14 7
      app/rest/CachedLoadingPage.py
  12. 151 0
      app/rest/PageCache.py
  13. 162 0
      app/rest/QueryCache.py
  14. 9 2
      app/rest/__init__.py
  15. 6 2
      app/rest/cherrypy.py
  16. 32 31
      app/rest/form.py
  17. 180 0
      app/rest/hash_util.py
  18. 92 200
      app/rest/pyapi.py
  19. 143 0
      app/rest/query_to_xml.py
  20. 1 1
      app/rest/requirements.txt
  21. 90 0
      app/rest/route_decorators.py
  22. BIN
      app/rest/static/favicon.png
  23. 72 0
      app/rest/static/favicon.svg
  24. 306 0
      app/rest/static/favicon_square.svg
  25. 22 0
      app/rest/static/manifest.json
  26. 1 0
      app/rest/templates/done.tpl
  27. 1 0
      app/rest/templates/error-500.tpl
  28. 7 1
      app/rest/templates/filter-set.tpl
  29. 19 0
      app/rest/templates/form-filter.tpl
  30. 0 27
      app/rest/templates/loading.tpl
  31. 5 3
      app/rest/templates/progress.tpl
  32. 2 0
      app/rest/templates/query-to-xml.tpl
  33. 14 0
      app/rest/templates/range-organic.tpl
  34. 8 11
      app/rest/templates/select-one.tpl
  35. 2 2
      app/rest/templates/select.tpl
  36. 59 28
      app/rest/templates/trend.tpl
  37. 47 33
      app/rest/trend.py
  38. 3 9
      grocery_transactions.py
  39. 0 1
      requirements.txt
  40. 1 2
      test/activities/test_Rating.py
  41. 83 0
      test/activities/test_activity_manager.py
  42. 24 0
      test/activities/test_banner.py
  43. 77 0
      test/activities/test_grouped_widget_util.py
  44. 67 0
      test/data/test_dataframe_util.py
  45. 20 0
      test/data/test_decimal.py
  46. 1 1
      test/data/test_util.py
  47. 2 2
      test/rest/templates/test_include-exclude.py
  48. 5 8
      test/rest/templates/test_select-one.py
  49. 3 3
      test/rest/templates/test_select.py
  50. 27 26
      test/rest/test_Cache.py
  51. 3 2
      test/rest/test_CachedLoadingPage.py
  52. 308 0
      test/rest/test_hash_util.py
  53. 76 0
      test/rest/test_route_decorators.py

+ 2 - 3
app/activities/ActivityManager.py

@@ -5,8 +5,8 @@
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 from typing import Iterator
 from typing import Iterator
-import urwid
 from urwid.display_common import BaseScreen
 from urwid.display_common import BaseScreen
+from urwid import ExitMainLoop
 
 
 def show_or_exit(key,
 def show_or_exit(key,
     screen: BaseScreen = None, palettes: Iterator = None
     screen: BaseScreen = None, palettes: Iterator = None
@@ -25,7 +25,7 @@ def show_or_exit(key,
         screen.clear()
         screen.clear()
 
 
     if key in ('esc',):
     if key in ('esc',):
-        raise urwid.ExitMainLoop()
+        raise ExitMainLoop()
 
 
 class ActivityManager():
 class ActivityManager():
 
 
@@ -58,4 +58,3 @@ class ActivityManager():
         if self.app is None:
         if self.app is None:
             return None
             return None
         return self.app.original_widget
         return self.app.original_widget
-

+ 16 - 0
app/activities/Banner.py

@@ -0,0 +1,16 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from urwid import AttrMap, Padding, Pile, Text, AttrMap
+from .. import COPYRIGHT
+
+def banner(title: str) -> AttrMap:
+    header = Text(title, 'center')
+    _copyright = Text(COPYRIGHT, 'center')
+    return AttrMap(Pile([
+            Padding(header, 'center', width=('relative', 100)),
+            Padding(_copyright, 'center', width=('relative', 100)),
+        ]), 'banner')

+ 40 - 88
app/activities/PriceCheck.py

@@ -4,7 +4,6 @@
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from decimal import Decimal, InvalidOperation
 from itertools import chain
 from itertools import chain
 from urwid import (
 from urwid import (
     connect_signal,
     connect_signal,
@@ -14,13 +13,13 @@ from urwid import (
     Divider,
     Divider,
     Filler,
     Filler,
     LineBox,
     LineBox,
-    Padding,
     Pile,
     Pile,
     RadioButton,
     RadioButton,
     Text,
     Text,
 )
 )
 
 
-from .. import COPYRIGHT
+from ..data.decimal_util import decimal_or_none
+from ..data.dataframe_util import get_caption, get_time_range, stats
 from ..widgets import (
 from ..widgets import (
     AutoCompleteEdit,
     AutoCompleteEdit,
     AutoCompleteFloatEdit,
     AutoCompleteFloatEdit,
@@ -32,6 +31,8 @@ from ..widgets import (
 from ..data.QueryManager import QueryManager
 from ..data.QueryManager import QueryManager
 from .ActivityManager import ActivityManager, show_or_exit
 from .ActivityManager import ActivityManager, show_or_exit
 from .Rating import Rating
 from .Rating import Rating
+from .Banner import banner
+
 
 
 def get_historic_prices(df):
 def get_historic_prices(df):
     return df.drop(labels=[
     return df.drop(labels=[
@@ -118,46 +119,23 @@ class PriceCheck(FocusWidget):
         ).truncate(
         ).truncate(
             before=max(0, len(df.index)-self.graph._canvas_width)
             before=max(0, len(df.index)-self.graph._canvas_width)
         )
         )
-        data = df[['$/unit','quantity']].apply(
-            lambda x: (float(x['$/unit']), float(x['quantity'])),
+        data = df[['price', '$/unit','quantity']].apply(
+            lambda x: (float(x['price']), float(x['$/unit']), float(x['quantity'])),
             axis=1, result_type='broadcast'
             axis=1, result_type='broadcast'
         )
         )
-        data['avg'] = (data['$/unit']*data['quantity']).sum()/data['quantity'].sum()
-        data_max = data.max()['$/unit'] #.max()
-        assert len(data['avg'].unique()) == 1
         norm = [ (x,) for x in data['$/unit'] ] #.to_records(index=False)
         norm = [ (x,) for x in data['$/unit'] ] #.to_records(index=False)
-        self.graph.set_data(norm, data_max,
-            vscale=list(map(float, [
-                data['$/unit'].min(),
-                data['$/unit'].median(),
-                data['avg'].iloc[0],
-                data_max
-            ]))
-        )
-        #self.graph.set_bar_width(1)
-        # canvas_width = 10 + pad + pad + 10
-        date_strlen = (self.graph.canvas_width - 20)
-        ex = "─" if date_strlen % 2 else ""
-        plen = date_strlen//2
-        caption = f"{df['ts_raw'].min():%d/%m/%Y}"
-        caption += f"{{p:>{plen}}}{ex}{{p:<{plen}}}".format(
-            p="─")
-        caption += f"{df['ts_raw'].max():%d/%m/%Y}"
+        scale = stats(data, 'price', 'quantity', '$/unit')
+        self.graph.set_data(norm, scale[-1], vscale=scale)
+        time_range = get_time_range(df, 'ts_raw')
+        caption = get_caption(time_range, self.graph.canvas_width)
         self.graph.set_caption(caption)
         self.graph.set_caption(caption)
 
 
     def update_historic_prices(self, data):
     def update_historic_prices(self, data):
         organic = None if data['organic'] == 'mixed' else data['organic']
         organic = None if data['organic'] == 'mixed' else data['organic']
         sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
         sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
         product, unit = data['product'] or None, data['unit'] or None
         product, unit = data['product'] or None, data['unit'] or None
-        try:
-            price = Decimal(data['price'])
-        except InvalidOperation:
-            price = None
-
-        try:
-            quantity = Decimal(data['quantity'])
-        except InvalidOperation:
-            quantity = None
+        price = decimal_or_none(data['price'])
+        quantity = decimal_or_none(data['quantity'])
 
 
         if None in (sort, product, unit):
         if None in (sort, product, unit):
             self.text_fields['dbview'].set_text('')
             self.text_fields['dbview'].set_text('')
@@ -177,10 +155,11 @@ class PriceCheck(FocusWidget):
         ]
         ]
         self.rating.update_rating(_avg, _min, _max, unit, price=price, quantity=quantity)
         self.rating.update_rating(_avg, _min, _max, unit, price=price, quantity=quantity)
 
 
+        self.update_graph(df)
         self.text_fields['dbview'].set_text(
         self.text_fields['dbview'].set_text(
             get_historic_prices(df)
             get_historic_prices(df)
         )
         )
-        self.update_graph(df)
+
 
 
     def __init__(self,
     def __init__(self,
         activity_manager: ActivityManager,
         activity_manager: ActivityManager,
@@ -210,21 +189,6 @@ class PriceCheck(FocusWidget):
           self.text_fields.items()
           self.text_fields.items()
         )))
         )))
 
 
-        left_pane = [
-            'product',
-            'organic',
-        ]
-        badge = [
-            'rating',
-            'spread',
-            'marker',
-        ]
-        right_pane = [
-            'unit',
-            'quantity',
-            'price',
-        ]
-
         self.query_manager = query_manager
         self.query_manager = query_manager
         self.organic_checkbox = self.checkboxes['organic']
         self.organic_checkbox = self.checkboxes['organic']
         connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
         connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
@@ -248,15 +212,6 @@ class PriceCheck(FocusWidget):
 
 
         self.clear()
         self.clear()
 
 
-        header = Text(u'Price Check', 'center')
-        _copyright = Text(COPYRIGHT, 'center')
-
-        banner = Pile([
-            Padding(header, 'center', width=('relative', 100)),
-            Padding(_copyright, 'center', width=('relative', 100)),
-        ])
-        banner = AttrMap(banner, 'banner')
-
         _widgets = dict(chain(*list(map(lambda x: x.items(), [
         _widgets = dict(chain(*list(map(lambda x: x.items(), [
                 self.edit_fields, self.text_fields, self.checkboxes
                 self.edit_fields, self.text_fields, self.checkboxes
             ])
             ])
@@ -280,35 +235,32 @@ class PriceCheck(FocusWidget):
             ),
             ),
         })
         })
         components = {
         components = {
-          'top_pane': Columns([
-              (9, Pile([
-                 Divider(),
-                 AttrMap(self.buttons['clear'], 'streak'),
-                 Divider(),
-              ])),
-              LineBox(
-                Columns([ v for k,v in self.buttons.items() if 'sort' in k]),
-                title="Sort price by",
-                title_align='left',
-              ),
-              (9, Pile([
-                Divider(),
-                AttrMap(self.buttons['exit'], 'streak'),
-                Divider(),
-              ]))
+            'top_pane': Columns([
+                (9, Pile([
+                    Divider(),
+                    AttrMap(self.buttons['clear'], 'streak'),
+                    Divider(),
+                ])),
+                LineBox(
+                    Columns([ v for k,v in self.buttons.items() if 'sort' in k]),
+                    title="Sort price by",
+                    title_align='left',
+                ),
+                (9, Pile([
+                    Divider(),
+                    AttrMap(self.buttons['exit'], 'streak'),
+                    Divider(),
+                ]))
             ], dividechars=1),
             ], dividechars=1),
-          'right_pane': (16, Pile(map(
-            lambda x: _widgets[x] if x is not None else Divider,
-            right_pane
-          ))),
-          'left_pane': Pile(map(
-            lambda x: _widgets[x] if x is not None else Divider,
-            left_pane
-          )),
-          'badge': Pile(map(
-            lambda x: _widgets[x] if x is not None else Divider,
-            badge
-          )),
+            'right_pane': (16, Pile([
+                _widgets[x] for x in ['unit', 'quantity', 'price']
+            ])),
+            'left_pane': Pile([
+                _widgets[x] for x in ['product', 'organic']
+            ]),
+            'badge': Pile([
+                _widgets[x] for x in ['rating', 'spread', 'marker']
+            ]),
         }
         }
         _widgets.update({
         _widgets.update({
             'graph': LineBox(
             'graph': LineBox(
@@ -329,7 +281,7 @@ class PriceCheck(FocusWidget):
         ])})
         ])})
 
 
         widget = Pile([
         widget = Pile([
-            banner,
+            banner(u'Price Check'),
             Divider(),
             Divider(),
             components['top_pane'],
             components['top_pane'],
             Columns((components['left_pane'], components['right_pane']),
             Columns((components['left_pane'], components['right_pane']),

+ 10 - 29
app/activities/RecipeEditor.py

@@ -29,7 +29,13 @@ from urwid.numedit import FloatEdit
 import yaml
 import yaml
 from yaml.representer import SafeRepresenter
 from yaml.representer import SafeRepresenter
 
 
-from .. import COPYRIGHT
+from .grouped_widget_util import (
+    in_same_row,
+    to_numbered_field,
+    to_unnumbered_field,
+    to_named_value,
+)
+
 from ..widgets import (
 from ..widgets import (
     AutoCompleteEdit,
     AutoCompleteEdit,
     FocusWidget,
     FocusWidget,
@@ -38,6 +44,7 @@ from ..widgets import (
 )
 )
 from ..data.QueryManager import QueryManager
 from ..data.QueryManager import QueryManager
 from .ActivityManager import ActivityManager, show_or_exit
 from .ActivityManager import ActivityManager, show_or_exit
+from .Banner import banner
 
 
 def change_style(style, representer):
 def change_style(style, representer):
     def new_representer(dumper, data):
     def new_representer(dumper, data):
@@ -76,28 +83,13 @@ f"""<root>
     for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
     for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
         yield e.text
         yield e.text
 
 
-def to_numbered_field(x):
-    if len(x[0].split('#', 1)) > 1:
-        name, idx = x[0].split('#', 1)
-        idx = int(idx)
-    else:
-        name, idx = x[0], 0
-
-    return (name, int(idx)), x[1]
-
-def to_unnumbered_field(x):
-    return x[0][0], x[1]
-
-def in_same_row(name):
-    if len(name.split('#', 1)) > 1:
-        _, row = name.split('#', 1)
-    return lambda x: x[0][1] == int(row)
 
 
 def unzip(_iter: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]]) -> Tuple[
 def unzip(_iter: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]]) -> Tuple[
     List[AutoCompleteEdit], List[FloatEdit], List[AutoCompleteEdit]
     List[AutoCompleteEdit], List[FloatEdit], List[AutoCompleteEdit]
 ]:
 ]:
     return zip(*_iter)
     return zip(*_iter)
 
 
+
 def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterable[str]:
 def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterable[str]:
     if isinstance(x, (list, tuple)):
     if isinstance(x, (list, tuple)):
         if len(x) == 0:
         if len(x) == 0:
@@ -105,8 +97,6 @@ def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterabl
         return ( v.get_edit_text() for v in x )
         return ( v.get_edit_text() for v in x )
     raise Exception(f"Unsupported type: {type(x)}")
     raise Exception(f"Unsupported type: {type(x)}")
 
 
-def to_named_value(name: str) -> Callable[[str], Tuple[str,str]]:
-    return lambda e: (f'{name}#{e[0]}', e[1])
 
 
 def blank_ingredients_row(idx: int) -> Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]:
 def blank_ingredients_row(idx: int) -> Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]:
     return (
     return (
@@ -470,15 +460,6 @@ class RecipeEditor(FocusWidget):
         connect_signal(self.buttons['exit'], 'click', lambda _: show_or_exit('esc'))
         connect_signal(self.buttons['exit'], 'click', lambda _: show_or_exit('esc'))
         connect_signal(self.instructions, 'postchange', lambda w,_: self.update(w))
         connect_signal(self.instructions, 'postchange', lambda w,_: self.update(w))
 
 
-        header = Text(u'Recipe Editor', 'center')
-        _copyright = Text(COPYRIGHT, 'center')
-
-        banner = Pile([
-            Padding(header, 'center', width=('relative', 100)),
-            Padding(_copyright, 'center', width=('relative', 100)),
-        ])
-        banner = AttrMap(banner, 'banner')
-        
         left_pane, middle_pane, right_pane, gutter = self.init_ingredients()
         left_pane, middle_pane, right_pane, gutter = self.init_ingredients()
 
 
         self.components = {
         self.components = {
@@ -507,7 +488,7 @@ class RecipeEditor(FocusWidget):
         }
         }
 
 
         widget = Pile([
         widget = Pile([
-            banner,
+            banner(u'Recipe Editor'),
             Divider(),
             Divider(),
             self.components['top_pane'],
             self.components['top_pane'],
             Columns([
             Columns([

+ 45 - 124
app/activities/TransactionEditor.py

@@ -4,10 +4,8 @@
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from decimal import Decimal, InvalidOperation
 from itertools import chain
 from itertools import chain
 from typing import (
 from typing import (
-    Callable,
     Union,
     Union,
     Tuple,
     Tuple,
     List,
     List,
@@ -25,13 +23,18 @@ from urwid import (
     Edit,
     Edit,
     Filler,
     Filler,
     LineBox,
     LineBox,
-    Padding,
     Pile,
     Pile,
     Text,
     Text,
 )
 )
 
 
-from .. import COPYRIGHT
+from ..data.decimal_util import decimal_or_none
+from ..data.dataframe_util import get_caption, get_time_range, stats
 from ..data.QueryManager import QueryManager
 from ..data.QueryManager import QueryManager
+from .grouped_widget_util import (
+    to_numbered_field,
+    to_unnumbered_field,
+    to_named_value,
+)
 from ..widgets import (
 from ..widgets import (
     AutoCompleteEdit,
     AutoCompleteEdit,
     AutoCompleteFloatEdit,
     AutoCompleteFloatEdit,
@@ -43,31 +46,14 @@ from ..widgets import (
 from . import ActivityManager
 from . import ActivityManager
 from .Rating import Rating
 from .Rating import Rating
 from .NewProduct import NewProduct
 from .NewProduct import NewProduct
-
-def to_numbered_field(x):
-    if len(x[0].split('#', 1)) > 1:
-        name, idx = x[0].split('#', 1)
-        idx = int(idx)
-    else:
-        name, idx = x[0], 0
-
-    return (name, int(idx)), x[1]
-
-def to_unnumbered_field(x):
-    return x[0][0], x[1]
-
-def in_same_row(name):
-    if len(name.split('#', 1)) > 1:
-        _, row = name.split('#', 1)
-    else:
-        row = 0
-    return lambda x: x[0][1] == int(row)
+from .Banner import banner
 
 
 def unzip(_iter: List[Tuple[AutoCompleteEdit, Edit]]) -> Tuple[
 def unzip(_iter: List[Tuple[AutoCompleteEdit, Edit]]) -> Tuple[
     List[AutoCompleteEdit], List[Edit]
     List[AutoCompleteEdit], List[Edit]
 ]:
 ]:
     return zip(*_iter)
     return zip(*_iter)
 
 
+
 def extract_values(x: Union[List[AutoCompleteEdit], List[Edit]]) -> Iterable[str]:
 def extract_values(x: Union[List[AutoCompleteEdit], List[Edit]]) -> Iterable[str]:
     if isinstance(x, (list, tuple)):
     if isinstance(x, (list, tuple)):
         if len(x) == 0:
         if len(x) == 0:
@@ -75,8 +61,6 @@ def extract_values(x: Union[List[AutoCompleteEdit], List[Edit]]) -> Iterable[str
         return ( v.get_edit_text() for v in x )
         return ( v.get_edit_text() for v in x )
     raise Exception(f"Unsupported type: {type(x)}")
     raise Exception(f"Unsupported type: {type(x)}")
 
 
-def to_named_value(name: str) -> Callable[[str], Tuple[str,str]]:
-    return lambda e: (f'{name}#{e[0]}', e[1])
 
 
 def blank_tags_row(idx: int) -> Tuple[AutoCompleteEdit, Edit]:
 def blank_tags_row(idx: int) -> Tuple[AutoCompleteEdit, Edit]:
     return (
     return (
@@ -110,10 +94,11 @@ class TransactionEditor(FocusWidget):
 
 
     def apply_choice(self, name, value):
     def apply_choice(self, name, value):
         self.apply_changes(name, value)
         self.apply_changes(name, value)
-        data = dict(#filter(
-        #    in_same_row(name),
-            map(to_numbered_field, self.data.items())
-        )#)
+        data = {
+            (field, idx): v for field, idx, v in map(
+                to_numbered_field, self.data.items()
+            )
+        }
         for k,v in data.items():
         for k,v in data.items():
             if f'{k[0]}#{k[1]}' == name or v:
             if f'{k[0]}#{k[1]}' == name or v:
                 continue
                 continue
@@ -172,16 +157,12 @@ class TransactionEditor(FocusWidget):
 
 
 
 
     def init_tags(self):
     def init_tags(self):
-        #_tags = LineBox(Pile([AttrMap(
         _tags = Pile([AttrMap(
         _tags = Pile([AttrMap(
             AutoCompletePopUp(
             AutoCompletePopUp(
                 tag[0],
                 tag[0],
                 self.apply_choice,
                 self.apply_choice,
                 lambda: self.activity_manager.show(self.update())
                 lambda: self.activity_manager.show(self.update())
             ), 'streak') for tag in self._tags])
             ), 'streak') for tag in self._tags])
-        #    title=f'Tags',
-        #    title_align='left'
-        #)
         gutter = Pile([
         gutter = Pile([
             *[ Divider() for _ in self._tags[:-1] ],
             *[ Divider() for _ in self._tags[:-1] ],
             Divider(),
             Divider(),
@@ -196,7 +177,6 @@ class TransactionEditor(FocusWidget):
             blank_tags_row(len(self._tags))
             blank_tags_row(len(self._tags))
         )
         )
         _tags, gutter = self.init_tags()
         _tags, gutter = self.init_tags()
-        #self.components['tags'][1].original_widget.contents = list(_tags.original_widget.contents)
         self.components['tags'].contents = list(_tags.contents)
         self.components['tags'].contents = list(_tags.contents)
         self.components['gutter'][1].contents = list(gutter.contents)
         self.components['gutter'][1].contents = list(gutter.contents)
         for widget in self._tags:
         for widget in self._tags:
@@ -204,16 +184,13 @@ class TransactionEditor(FocusWidget):
             connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
             connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
                 w, name, self.autocomplete_options(name, dict(map(
                 w, name, self.autocomplete_options(name, dict(map(
                     to_unnumbered_field,
                     to_unnumbered_field,
-                    #filter(
-                    #    in_same_row(name),
-                        map(to_numbered_field, self.data.items()
-                    #)
+                    map(to_numbered_field, self.data.items()
                 ))))
                 ))))
             ))
             ))
 
 
 
 
     def clear(self):
     def clear(self):
-        self._tags = []
+        self._tags: List[Tuple[AutoCompleteEdit, Edit]] = []
         self.add_tag()
         self.add_tag()
         for (k, ef) in self.edit_fields.items():
         for (k, ef) in self.edit_fields.items():
             if k in ('ts', 'store',):
             if k in ('ts', 'store',):
@@ -249,46 +226,23 @@ class TransactionEditor(FocusWidget):
         ).truncate(
         ).truncate(
             before=max(0, len(df.index)-self.graph._canvas_width)
             before=max(0, len(df.index)-self.graph._canvas_width)
         )
         )
-        data = df[['$/unit','quantity']].apply(
-            lambda x: (float(x['$/unit']), float(x['quantity'])),
+        data = df[['price', '$/unit','quantity']].apply(
+            lambda x: (float(x['price']), float(x['$/unit']), float(x['quantity'])),
             axis=1, result_type='broadcast'
             axis=1, result_type='broadcast'
         )
         )
-        data['avg'] = (data['$/unit']*data['quantity']).sum()/data['quantity'].sum()
-        data_max = data.max()['$/unit'] #.max()
-        assert len(data['avg'].unique()) == 1
         norm = [ (x,) for x in data['$/unit'] ] #.to_records(index=False)
         norm = [ (x,) for x in data['$/unit'] ] #.to_records(index=False)
-        self.graph.set_data(norm, data_max,
-            vscale=list(map(float, [
-                data['$/unit'].min(),
-                data['$/unit'].median(),
-                data['avg'].iloc[0],
-                data_max
-            ]))
-        )
-        #self.graph.set_bar_width(1)
-        # canvas_width = 10 + pad + pad + 10
-        date_strlen = (self.graph.canvas_width - 20)
-        ex = "─" if date_strlen % 2 else ""
-        plen = date_strlen//2
-        caption = f"{df['ts_raw'].min():%d/%m/%Y}"
-        caption += f"{{p:>{plen}}}{ex}{{p:<{plen}}}".format(
-            p="─")
-        caption += f"{df['ts_raw'].max():%d/%m/%Y}"
+        scale = stats(data, 'price', 'quantity', '$/unit')
+        self.graph.set_data(norm, scale[-1], vscale=scale)
+        time_range = get_time_range(df, 'ts_raw')
+        caption = get_caption(time_range, self.graph.canvas_width)
         self.graph.set_caption(caption)
         self.graph.set_caption(caption)
 
 
     def update_historic_prices(self, data):
     def update_historic_prices(self, data):
         organic = None if data['organic'] == 'mixed' else data['organic']
         organic = None if data['organic'] == 'mixed' else data['organic']
         #sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
         #sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
         product, unit = data['product'] or None, data['unit'] or None
         product, unit = data['product'] or None, data['unit'] or None
-        try:
-            price = Decimal(data['price'])
-        except InvalidOperation:
-            price = None
-
-        try:
-            quantity = Decimal(data['quantity'])
-        except InvalidOperation:
-            quantity = None
+        price = decimal_or_none(data['price'])
+        quantity = decimal_or_none(data['quantity'])
 
 
         if None in (product, unit):
         if None in (product, unit):
             self.rating.update_rating(None, None, None, unit)
             self.rating.update_rating(None, None, None, unit)
@@ -341,7 +295,7 @@ class TransactionEditor(FocusWidget):
         query_manager: QueryManager,
         query_manager: QueryManager,
     ):
     ):
         self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
         self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
-        self.activity_manager = activity_manager
+        self.activity_manager: ActivityManager = activity_manager
         self.query_manager = query_manager
         self.query_manager = query_manager
         self.buttons = {
         self.buttons = {
             'done': Button(('streak', u'Done')),
             'done': Button(('streak', u'Done')),
@@ -385,26 +339,7 @@ class TransactionEditor(FocusWidget):
         )))
         )))
         self.organic_checkbox = self.checkboxes['organic']
         self.organic_checkbox = self.checkboxes['organic']
         connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
         connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
-        layout = [
-            [ 'ts',          'store',     ],
-            [ 'organic',     'product',   ],
-            [ 'category',    'group',     ],
-        ]
-        side_pane = [
-            'unit',
-            'quantity',
-            'price',
-        ]
-        bottom_pane = [
-            'description',
-            'dbview',
-        ]
-        badge = [
-            'rating',
-            'spread',
-            'marker',
-        ]
-        #self.clear()
+
         _widgets = dict(chain(*list(map(lambda x: x.items(), [
         _widgets = dict(chain(*list(map(lambda x: x.items(), [
                 self.edit_fields, self.text_fields, self.checkboxes
                 self.edit_fields, self.text_fields, self.checkboxes
             ])
             ])
@@ -421,10 +356,7 @@ class TransactionEditor(FocusWidget):
             connect_signal(ef, 'apply', lambda w, name: self.autocomplete_callback(
             connect_signal(ef, 'apply', lambda w, name: self.autocomplete_callback(
                 w, name, self.autocomplete_options(name, dict(map(
                 w, name, self.autocomplete_options(name, dict(map(
                     to_unnumbered_field,
                     to_unnumbered_field,
-                    #filter(
-                    #    in_same_row(name),
                         map(to_numbered_field, self.data.items()
                         map(to_numbered_field, self.data.items()
-                    #)
                 ))))
                 ))))
             ))
             ))
 
 
@@ -438,24 +370,20 @@ class TransactionEditor(FocusWidget):
                 title=k.title(), title_align='left'
                 title=k.title(), title_align='left'
             ) for k in self.edit_fields if k != 'product'
             ) for k in self.edit_fields if k != 'product'
         })
         })
-        header = Text(u'Fill Transaction', 'center')
-        _copyright = Text(COPYRIGHT, 'center')
 
 
         self.components.update({
         self.components.update({
             'bottom_button_bar': Columns(
             'bottom_button_bar': Columns(
                 [(8, self.buttons['done']), Divider(), (9, self.buttons['clear'])]
                 [(8, self.buttons['done']), Divider(), (9, self.buttons['clear'])]
             ),
             ),
-            'badge': Pile(map(
-                lambda x: _widgets[x] if x is not None else Divider,
-                badge
-            )),
+            'badge': Pile([
+                _widgets[x] for x in ['rating', 'spread', 'marker']
+            ]),
         })
         })
         self.components.update({
         self.components.update({
             'bottom_pane': Columns([
             'bottom_pane': Columns([
-                Pile(map(
-                    lambda x: _widgets[x] if x is not None else Divider(),
-                    bottom_pane
-                )),
+                Pile([
+                    _widgets[x] for x in ['description', 'dbview']
+                ]),
                 (self.graph.total_width+2, Pile([
                 (self.graph.total_width+2, Pile([
                     LineBox(
                     LineBox(
                         AttrMap(self.components['badge'], 'badge'),
                         AttrMap(self.components['badge'], 'badge'),
@@ -472,11 +400,6 @@ class TransactionEditor(FocusWidget):
         connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
         connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
         connect_signal(self.buttons['add'], 'click', lambda _: self.add_tag())
         connect_signal(self.buttons['add'], 'click', lambda _: self.add_tag())
 
 
-        banner = Pile([
-            Padding(header, 'center', width=('relative', 100)),
-            Padding(_copyright, 'center', width=('relative', 100)),
-        ])
-        banner = AttrMap(banner, 'banner')
         _widgets.update({
         _widgets.update({
             'product': LineBox(Columns([
             'product': LineBox(Columns([
                 AttrMap(AutoCompletePopUp(
                 AttrMap(AutoCompletePopUp(
@@ -489,25 +412,23 @@ class TransactionEditor(FocusWidget):
         })
         })
 
 
         self.components['side_pane'] = (12, Pile([
         self.components['side_pane'] = (12, Pile([
-            _widgets[r] if r is not None else Divider() for r in side_pane
+            _widgets[r] for r in ['unit', 'quantity', 'price']
         ]))
         ]))
-        self.components['main_pane'] = []
-        for _, r in enumerate(layout):
-            col = []
-            for c in r:
-                if c is not None:
-                    if c == 'organic':
-                        continue
-                    col.append(_widgets[c])
-                else:
-                    col.append(Divider())
-            self.components['main_pane'].append(Columns(col))
-
-        self.components['main_pane'] = Pile(self.components['main_pane'])
+
+        self.components['main_pane'] = Pile([
+            Columns([
+                _widgets[c] for c in ['ts', 'store']
+            ]),
+            _widgets['product'],
+            Columns([
+                _widgets[c] for c in ['category', 'group']
+            ])
+        ])
+
         self.add_tag()
         self.add_tag()
 
 
         widget = Pile([
         widget = Pile([
-            banner,
+            banner(u'Fill Transaction'),
             Divider(),
             Divider(),
             Columns([
             Columns([
                 self.components['main_pane'],
                 self.components['main_pane'],

+ 29 - 0
app/activities/grouped_widget_util.py

@@ -0,0 +1,29 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from typing import Callable, Tuple
+
+def to_numbered_field(x: Tuple[str, str]) -> Tuple[str, int, str]:
+    if len(x[0].split('#', 1)) > 1:
+        name, idx = x[0].split('#', 1)
+        idx = int(idx)
+    else:
+        name, idx = x[0], 0
+
+    return name, int(idx), x[1]
+
+def to_unnumbered_field(x: Tuple[str, int, str]) -> Tuple[str, str]:
+    return x[0], x[2]
+
+def in_same_row(name: str) -> Callable[[Tuple[str, int, str]], bool]:
+    if len(name.split('#', 1)) > 1:
+        _, row = name.split('#', 1)
+    else:
+        row = 0
+    return lambda x: x[1] == int(row)
+
+def to_named_value(name: str) -> Callable[[Tuple[int, str]], Tuple[str, str]]:
+    return lambda e: (f'{name}#{e[0]}', e[1])

+ 34 - 0
app/data/dataframe_util.py

@@ -0,0 +1,34 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from typing import List, Tuple
+from pandas import DataFrame
+
+def stats(data: DataFrame, num: str, den: str, frac: str) -> List[float]:
+    return list(map(float, [
+        data[frac].min(),
+        data[frac].median(),
+        data[num].sum()/data[den].sum(), # mean
+        data[frac].max()
+    ]))
+
+def get_time_range(data: DataFrame, ts_col: str) -> Tuple[str, str]:
+    return f"{data[ts_col].min():%d/%m/%Y}" , f"{data[ts_col].max():%d/%m/%Y}"
+
+def get_divider(width: int, marker:str = "─") -> str:
+    if width <= 3:
+        return " ─ "
+
+    plen = width//2 - 1
+    ex = marker if (width % 2) and plen > 0 else ""
+    return f" {{p:>{plen}}}{ex}{{p:<{plen}}} ".format(p=marker)
+
+def get_caption(time_range: Tuple[str, str], width: int) -> str:
+    left, right = time_range
+    pad = width - len(left) - len(right)
+    return ''.join([
+        left, get_divider(pad, marker="─"), right
+    ])

+ 15 - 0
app/data/decimal_util.py

@@ -0,0 +1,15 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from decimal import Decimal, InvalidOperation
+from typing import Any
+
+
+def decimal_or_none(maybe_decimal: Any) -> Decimal:
+    try:
+        return Decimal(maybe_decimal)
+    except InvalidOperation:
+        return None

+ 12 - 1
app/palette/__init__.py

@@ -1 +1,12 @@
-
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+def iter_palettes(theme):
+    palettes = [ v for k,v in theme.items() ]
+    while True:
+        p = palettes.pop(0)
+        palettes.append(p)
+        yield p

+ 0 - 44
app/rest/Cache.py

@@ -1,44 +0,0 @@
-#
-# Copyright (c) Daniel Sheffield 2023
-# All rights reserved
-#
-# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from typing import Dict
-
-from .CachedLoadingPage import CachedLoadingPage
-
-class Cache:
-    def __init__(self, limit) -> None:
-        self._cache: Dict[str, CachedLoadingPage] = dict()
-        self._limit = limit
-
-    def get(self, key: str) -> str:
-        if key not in self._cache:
-            return None
-        
-        page = self._cache[key]
-        if page.stale:
-            del self._cache[key]
-            return None
-        return page.value if page.loaded else page.update()
-    
-    def _enforce_limit(self, limit):
-        for idx, (_, k) in enumerate(sorted([
-                (v.age, k) for k, v in self._cache.items()
-            ])):
-            if idx >= limit: del self._cache[k]
-
-    def _clear_stale(self):
-        for k in [k for k, v in self._cache.items() if v.stale]:
-            del self._cache[k]
-    
-    def add(self, key: str, page: CachedLoadingPage) -> str:
-        self._clear_stale()
-        self._enforce_limit(self._limit)
-        self._cache[key] = page
-        return page.value
-    
-    def remove(self, key: str):
-        if key in self._cache:
-            del self._cache[key]
-

+ 14 - 7
app/rest/CachedLoadingPage.py

@@ -6,19 +6,22 @@
 from queue import Queue, Empty
 from queue import Queue, Empty
 from time import time
 from time import time
 from threading import Lock
 from threading import Lock
-from typing import Callable
+from typing import Callable, Union
+
+STALE = 7*24*60*60
 
 
 class CachedLoadingPage():
 class CachedLoadingPage():
     
     
     value: str
     value: str
 
 
-    def __init__(self, initial_value: str, provider: Callable[[Queue], None]):
+    def __init__(self, initial_value: Union[str, list], provider: Callable[[Queue], None], incremental: bool = True):
         self._created = time()
         self._created = time()
         self._queue = Queue()
         self._queue = Queue()
         self._loaded = False
         self._loaded = False
         self.value = initial_value
         self.value = initial_value
         self._lock = Lock()
         self._lock = Lock()
         self.provider = provider
         self.provider = provider
+        self.incremental = incremental
 
 
     @property
     @property
     def age(self) -> float:
     def age(self) -> float:
@@ -38,16 +41,16 @@ class CachedLoadingPage():
     
     
     @property
     @property
     def stale(self) -> bool:
     def stale(self) -> bool:
-        return self.age > 10*60
+        return self.age > STALE
     
     
     def _start(self) -> None:
     def _start(self) -> None:
         if not self.provider:
         if not self.provider:
             return
             return
-        
+
         self.provider(self.queue)
         self.provider(self.queue)
         self.provider = None
         self.provider = None
 
 
-    def update(self) -> str:
+    def update(self) -> Union[str, list]:
         if not self._lock.acquire(blocking=True, timeout=0.5):
         if not self._lock.acquire(blocking=True, timeout=0.5):
             return self.value
             return self.value
         try:
         try:
@@ -57,9 +60,13 @@ class CachedLoadingPage():
                 self._queue.task_done()
                 self._queue.task_done()
                 self._set_loaded(True)
                 self._set_loaded(True)
             else:
             else:
-                self.value = item
+                if self.incremental:
+                    self.value.append(item)
+                else:
+                    self.value = item
                 self.queue.task_done()
                 self.queue.task_done()
         except Empty:
         except Empty:
             pass
             pass
-        self._lock.release()
+        finally:
+            self._lock.release()
         return self.value
         return self.value

+ 151 - 0
app/rest/PageCache.py

@@ -0,0 +1,151 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+
+import os
+from time import time
+from typing import Dict
+import cherrypy
+
+from .hash_util import blake, bytes_to_hash, hash_to_base32
+from .CachedLoadingPage import STALE, CachedLoadingPage
+
+def delete_page(name: str, root: str = 'app/rest/static/files'):
+    directory = f'{root}/{name}'
+    try:
+        os.remove(f'{directory}/{name}.file')
+    except FileNotFoundError:
+        pass
+
+
+def save_page(name: str, content: bytes, tool: str, root='app/rest/static/files') -> str:
+    directory = f'{root}/{name}'
+    try:
+        os.mkdir(directory, mode=0o700, dir_fd=None)
+    except FileExistsError:
+        pass
+
+    fd = os.open(f'{directory}/{name}.file', os.O_WRONLY | os.O_TRUNC | os.O_CREAT, 0o600)
+    with open(fd, "wb") as f:
+        f.write(content)
+
+
+def get_page(name: str, root: str = 'app/rest/static/files') -> str:
+    directory = f'{root}/{name}'
+
+    try:
+        mtime = os.stat(f'{directory}/{name}.file').st_mtime
+    except:
+        mtime = None
+    
+    if mtime is None:
+        return None
+    
+    if mtime and time() - mtime > STALE:
+        delete_page(name)
+        return None
+
+    # todo: store file hash and validate it
+    fd = os.open(f'{directory}/{name}.file', os.O_RDONLY, 0o600)
+    with open(fd, "rb") as f:
+        f.seek(0)
+        page = f.read()
+    return page.decode('utf-8')
+
+
+def key_to_hash(key):
+
+    if isinstance(key, tuple):
+        orig, _hash = key
+    else:
+        if isinstance(key, int):
+            orig, _hash = None, key
+        else:
+            orig, _hash = key, None
+
+    if None not in (orig, _hash):
+        if get_hash(orig) != _hash:
+            raise KeyError(f"Invalid key: {key}")
+
+        return _hash
+
+    if (_hash, orig) is (None, None):
+        raise KeyError(f"Invalid key: {key}")
+    
+    return get_hash(orig) if _hash is None else _hash
+
+
+def get_hash(key):
+    _bytes = blake(key.encode('utf-8'), person='grocery'.encode('utf-8'))
+    return bytes_to_hash(_bytes)
+
+
+class PageCache:
+    def __init__(self, limit) -> None:
+        self._cache: Dict[str, CachedLoadingPage] = dict()
+        self._limit = limit
+
+    def __delitem__(self, key):
+        key = key_to_hash(key)
+        self._cache.pop(key, None)
+        delete_page(hash_to_base32(key))
+
+    def __getitem__(self, key):
+        return self.get(key)
+
+    def __setitem__(self, key, value):
+        self._cache[key_to_hash(key)] = value
+
+    def get(self, key: str) -> CachedLoadingPage:
+        key = key_to_hash(key)
+
+        if key not in self._cache:
+            try:
+                existing = get_page(hash_to_base32(key))
+            except:
+                existing = None
+            if existing is None:
+                return None
+            
+            return self.add(key, CachedLoadingPage(existing, lambda q: q.put(None), incremental=True))
+        
+        page = self._cache[key]
+        if page.stale:
+            del self._cache[key]
+            delete_page(hash_to_base32(key))
+            return None
+
+        if not page.loaded:
+            page.update()
+
+        if page.loaded:
+            try:
+                existing = get_page(hash_to_base32(key))
+            except:
+                existing = None
+            if existing is None:
+                content = ''.join(page.value) if isinstance(page.value, list) else page.value
+                save_page(hash_to_base32(key), content.encode('utf-8'), tool='grocery')
+
+        return page
+
+    def _enforce_limit(self, limit):
+        for idx, (_, k) in enumerate(sorted([
+                (v.age, k) for k, v in self._cache.items()
+            ])):
+            if idx >= limit: del self[k]
+
+    def _clear_stale(self):
+        for k in [k for k, v in self._cache.items() if v.stale]:
+            del self[k]
+    
+    def add(self, key: str, page: CachedLoadingPage) -> CachedLoadingPage:
+        self._clear_stale()
+        self._enforce_limit(self._limit)
+        self._cache[key_to_hash(key)] = page
+        return page
+    
+    def remove(self, key: str):
+        del self[key]

+ 162 - 0
app/rest/QueryCache.py

@@ -0,0 +1,162 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+import os
+from typing import Dict, Iterable, Tuple
+from urllib.parse import urlencode
+
+from bottle import FormsDict
+from ..data.filter import get_filter, get_query_param
+
+from . import BOOLEAN, PARAMS
+from .hash_util import base32_to_hash, blake, bytes_to_hash, hash_to_base32, normalize_base32
+
+def delete_query(name: str, root: str = 'app/rest/static/files'):
+    directory = f'{root}/{name}'
+    try:
+        os.remove(f'{directory}/{name}.query')
+    except FileNotFoundError:
+        pass
+
+def save_query(name: str, content: bytes, tool: str, root='app/rest/static/files') -> str:
+    directory = f'{root}/{name}'
+    try:
+        os.mkdir(directory, mode=0o700, dir_fd=None)
+    except FileExistsError:
+        pass
+
+    fd = os.open(f'{directory}/{name}.query', os.O_WRONLY | os.O_TRUNC | os.O_CREAT, 0o600)
+    with open(fd, "wb") as f:
+        f.write(content)
+
+def get_query(name: str, root: str = 'app/rest/static/files') -> str:
+    directory = f'{root}/{name}'
+
+    try:
+        mtime = os.stat(f'{directory}/{name}.query').st_mtime
+    except:
+        mtime = None
+    
+    # if mtime and time() - mtime > STALE:
+    #     delete_query(name)
+    #     return None
+
+    # todo: store query hash and validate it
+    fd = os.open(f'{directory}/{name}.query', os.O_RDONLY, 0o600)
+    with open(fd, "rb") as f:
+        f.seek(0)
+        page = f.read()
+    return page.decode('utf-8')
+
+
+def norm(key):
+
+    if isinstance(key, tuple):
+        query, _hash = key
+    else:
+        if isinstance(key, int):
+            query, _hash = None, key
+        else:
+            query, _hash = key, None
+
+    if _hash and not isinstance(_hash, int):
+        _hash = base32_to_hash(normalize_base32(query.hash))
+        # TODO: normalize should be implicit
+        #_hash = base32_to_hash(query.hash)
+
+    if None not in (query, _hash):
+        if get_hash(query) != _hash:
+            raise KeyError(f"Invalid key: {key}")
+
+        return query, _hash
+
+    if (_hash, query) is (None, None):
+        raise KeyError(f"Invalid key: {key}")
+    
+    return query, _hash if _hash else get_hash(query)
+
+def get_hash(key):
+    _bytes = blake(key.encode('utf-8'), person='grocery'.encode('utf-8'))
+    return bytes_to_hash(_bytes)
+
+
+def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> Tuple[str, str]:
+    _hash = query.hash
+    allow = allow or PARAMS
+    param = get_filter(query, allow=allow)
+    norm = urlencode(map(
+        lambda k: (
+            k, get_query_param(*param[k]) if k != 'organic' else BOOLEAN[
+                BOOLEAN.get(query.organic, None)
+            ]
+        ),
+        sorted(filter(bool, param))
+    ))
+    return norm, _hash
+
+
+class QueryCache:
+    def __init__(self, limit) -> None:
+        self._cache: Dict[int, str] = dict()
+        self._limit = limit
+
+    def __delitem__(self, key):
+        return self.remove(key)
+
+    def __getitem__(self, key):
+        return self.get(key)
+
+    def __setitem__(self, key, value):
+        return self.add(key, value)
+
+    def get(self, key: str) -> str:
+        query, _hash = norm(key)
+        if _hash not in self._cache:
+            if query:
+                return self.add(_hash, query)
+            
+            try:
+                existing = get_query(hash_to_base32(_hash))
+            except:
+                existing = None
+            
+            if existing:
+                return self.add(_hash, existing)
+            
+            return self.add(_hash, query)
+        
+        value = self._cache[_hash]
+        # if value.stale:
+        #     del self._cache[key]
+        #     delete_query(hash_to_base32(key))
+        #     return None
+        return value
+
+    # def _enforce_limit(self, limit):
+    #     for idx, (_, k) in enumerate(sorted([
+    #             (v.age, k) for k, v in self._cache.items()
+    #         ])):
+    #         if idx >= limit: del self[k]
+
+    # def _clear_stale(self):
+    #     for k in [k for k, v in self._cache.items() if v.stale]:
+    #         del self[k]
+    
+    def add(self, key: str, value: str) -> str:
+        #self._clear_stale()
+        #self._enforce_limit(self._limit)
+        query, _hash = norm(key)
+        value = value or query
+        
+        if not value:
+            raise ValueError("Invalid query string: {value}")
+        self._cache[_hash] = value
+        save_query(hash_to_base32(_hash), value.encode("utf-8"), 'query')
+        return value
+    
+    def remove(self, key: str):
+        key = norm(key)
+        self._cache.pop(key, None)
+        delete_query(hash_to_base32(key))

+ 9 - 2
app/rest/__init__.py

@@ -7,5 +7,12 @@
 from bottle import TEMPLATE_PATH
 from bottle import TEMPLATE_PATH
 
 
 TEMPLATE_PATH.append("app/rest/templates")
 TEMPLATE_PATH.append("app/rest/templates")
-ALL_UNITS = {'g', 'kg', 'mL', 'L', 'Pieces', 'Bunches', 'Bags'}
-PARAMS = { 'group', 'category', 'product', 'unit', 'tag' }
+ALL_UNITS = { 'g', 'kg', 'mL', 'L', 'Pieces', 'Bunches', 'Bags' }
+PARAMS = { 'group', 'category', 'product', 'unit', 'tag', 'organic' }
+BOOLEAN = {
+    "1": True,
+    True: "1",
+    "0": False,
+    False: "0",
+    None: "0.5",
+}

+ 6 - 2
app/rest/cherrypy.py

@@ -1,13 +1,17 @@
 import cherrypy
 import cherrypy
-import wsgigzip
 import bottle
 import bottle
 from .pyapi import *
 from .pyapi import *
 
 
-application = wsgigzip.GzipMiddleware(bottle.default_app())
+application = bottle.default_app()
 
 
+cherrypy.config.update({'environment' : 'staging'})
 cherrypy.config.update({
 cherrypy.config.update({
     'server.socket_host': "0.0.0.0",
     'server.socket_host': "0.0.0.0",
     'server.socket_port': 6772,
     'server.socket_port': 6772,
+    'engine.autoreload.on': True,
+    'request.show_tracebacks': True,
+    'request.show_mismatched_params': True,
+    'log.screen': True,
 })
 })
 
 
 cherrypy.tree.graft(application, "/")
 cherrypy.tree.graft(application, "/")

+ 32 - 31
app/rest/form.py

@@ -8,14 +8,18 @@ from typing import Dict, Tuple
 from bottle import template
 from bottle import template
 from pandas import DataFrame
 from pandas import DataFrame
 from itertools import chain
 from itertools import chain
-from ..rest import ALL_UNITS
+from . import ALL_UNITS, BOOLEAN
 
 
 def get_option_groups(
 def get_option_groups(
     data: DataFrame, filter_data: Dict[str, Tuple[set, set]],
     data: DataFrame, filter_data: Dict[str, Tuple[set, set]],
     k: str, g: str, _type: str
     k: str, g: str, _type: str
 ):
 ):
-    in_chart = data['$/unit'].apply(lambda x: (x or False) and True)
-    groups = sorted(set(data[g] if g is not None else []))
+    if k in data or (k == 'tag' and 'tags' in data):
+        k_data_in_chart = chain(*data["tags"]) if k == "tag" else data[k]
+    else:
+        k_data_in_chart = []
+    
+    groups = sorted(set(data[g] if g is not None and g in data else []))
     groups.append(None)
     groups.append(None)
     if _type == "exclude":
     if _type == "exclude":
         prefix = "!"
         prefix = "!"
@@ -28,47 +32,43 @@ def get_option_groups(
         if group is None:
         if group is None:
             if _type == "include":
             if _type == "include":
                 selected.extend(filter_data[k][0] - (
                 selected.extend(filter_data[k][0] - (
-                    set((chain(*data[in_chart]["tags"]) if k == "tag" else data[in_chart][k]) if g is not None else set())
+                    set(k_data_in_chart if g is not None else set())
                 ))
                 ))
                 unselected.extend(((
                 unselected.extend(((
-                    set((chain(*data[in_chart]["tags"]) if k == "tag" else data[in_chart][k]) if g is None else set())
+                    set(k_data_in_chart if g is None else set())
                 ) | filter_data[k][1]) - filter_data[k][0])
                 ) | filter_data[k][1]) - filter_data[k][0])
             else:
             else:
                 selected.extend(filter_data[k][1])
                 selected.extend(filter_data[k][1])
                 unselected.extend((
                 unselected.extend((
-                    set((chain(*data[in_chart]["tags"]) if k == "tag" else data[in_chart][k]) if g is None else set())
+                    set(k_data_in_chart if g is None else set())
                 ) | (
                 ) | (
-                    filter_data[k][0] - set(chain(*data[in_chart]["tags"]) if k == "tag" else data[in_chart][k]) - filter_data[k][1]
+                    filter_data[k][0] - set(k_data_in_chart) - filter_data[k][1]
                 ))
                 ))
         else:
         else:
+            k_grouped_data_in_chart = set(data[data[g].apply(lambda x,axis=None: x == group)][k]) if k in data else set()
             if _type == "include":
             if _type == "include":
-                selected.extend(filter_data[k][0] & set(
-                    data[in_chart & (data[g].apply(lambda x,axis=None: x == group))][k]
-                ))
-                unselected.extend(set(
-                    data[in_chart & (data[g].apply(lambda x,axis=None: x == group))][k]
-                ) - filter_data[k][0])
+                selected.extend(filter_data[k][0] & set(k_grouped_data_in_chart))
+                unselected.extend(k_grouped_data_in_chart - filter_data[k][0])
             else:
             else:
-                unselected.extend(set(
-                    data[in_chart & (data[g].apply(lambda x,axis=None: x == group))][k]
-                ))
+                unselected.extend(set(k_grouped_data_in_chart))
         assert set(selected) - set(unselected) == set(selected), f"{set(selected)} {set(unselected)}"
         assert set(selected) - set(unselected) == set(selected), f"{set(selected)} {set(unselected)}"
-        
-        yield {
-            "optgroup": group,
-            "options": sorted(map(lambda x: {
-                "selected": x[0],
-                "value": f"{prefix}{x[1]}",
-                "display": x[1]  
-            }, chain(
-                map(lambda x: (True, x), set(selected)),
-                map(lambda x: (False, x), set(unselected)),
-            )), key=lambda x: x["display"] if "display" in x else x["value"])
-        }
+        options = sorted(map(lambda x: {
+            "selected": x[0],
+            "value": f"{prefix}{x[1]}",
+            "display": x[1]  
+        }, chain(
+            map(lambda x: (True, x), set(selected)),
+            map(lambda x: (False, x), set(unselected)),
+        )), key=lambda x: x["display"] if "display" in x else x["value"])
+        if len(options) > 0:
+            yield {
+                "optgroup": group,
+                "options": options,
+            }
 
 
 
 
-def get_form(action: str, method: str, filter_data, data: DataFrame):
-    keys = sorted(filter(lambda x: x not in ('unit', 'tag'), filter_data), key=lambda x: {
+def get_form(action: str, method: str, filter_data: Dict[str, Tuple[set, set]], organic: bool, data: DataFrame):
+    keys = sorted(filter(lambda x: x not in ('unit', 'tag', 'organic'), filter_data), key=lambda x: {
         'product': 0,
         'product': 0,
         'category': 1,
         'category': 1,
         'group': 2,
         'group': 2,
@@ -104,5 +104,6 @@ def get_form(action: str, method: str, filter_data, data: DataFrame):
                 map(lambda x: (True, x), filter_data['unit'][0]),
                 map(lambda x: (True, x), filter_data['unit'][0]),
                 map(lambda x: (False, x), ALL_UNITS - filter_data['unit'][0])
                 map(lambda x: (False, x), ALL_UNITS - filter_data['unit'][0])
             )), key=lambda x: x["display"] if "display" in x else x["value"])
             )), key=lambda x: x["display"] if "display" in x else x["value"])
-        }
+        },
+        organic=BOOLEAN[organic],
     )
     )

+ 180 - 0
app/rest/hash_util.py

@@ -0,0 +1,180 @@
+from hashlib import blake2b, shake_128, md5, sha256, sha1
+import os
+from base64 import b64encode, b64decode, b85encode, b85decode
+import base32_lib as b32
+
+DIGEST_SIZE_BYTES = 3
+DIGEST_SIZE_BITMASK = 0xffffff
+DIGEST_SIZE_SIGNED_TO_UNSIGNED_BITMASK = 0x1ffffff
+DIGEST_SIZE_SIGNED_TO_UNSIGNED_BIT = 0x1000000
+DIGEST_SIZE_NIBBLES = DIGEST_SIZE_BYTES * 2
+B64ALTCHARS = b'.-'
+
+def sha1hash(data: str):
+    return sha1(data.encode("utf-8"), usedforsecurity=False).hexdigest()[:DIGEST_SIZE_BYTES]
+
+def sha256hash(data: str):
+    return sha256(data.encode("utf-8"), usedforsecurity=False).hexdigest()[:DIGEST_SIZE_BYTES]
+
+def md5hash(data: str):
+    return md5(data.encode("utf-8"), usedforsecurity=False).hexdigest()[:DIGEST_SIZE_BYTES]
+
+def shake(data: str):
+    return shake_128(data.encode("utf-8"), usedforsecurity=False).hexdigest(DIGEST_SIZE_BYTES)
+
+def blake(data: bytes, person: bytes = None) -> bytes:
+    return blake2b(
+        data,
+        usedforsecurity=False,
+        digest_size=DIGEST_SIZE_BYTES,
+        person=person
+    ).digest()
+
+def blake_file(path: str, person: bytes = None, root: str ='rest/static') -> bytes:
+    fd = os.open(f'{root}/{path}', os.O_RDONLY, 0o600)
+    with open(fd, "rb") as f:
+        f.seek(0)
+        _blake = blake2b(usedforsecurity=False, digest_size=DIGEST_SIZE_BYTES, person=person)
+        while f.peek(1):
+            _blake.update(f.read(1024))
+    return _blake.digest()
+        
+
+def python(data: str):
+    return hash(data)
+
+def normalize_hash(_hash: int) -> int:
+    #hex = hash_to_hex(_hash)
+    #return int(hex, 16)
+    #_bytes = _hash.to_bytes(8, byteorder='big', signed=True)
+    #return bytes_to_hash(_bytes)
+    return _hash & DIGEST_SIZE_BITMASK
+
+def normalize_bytes(_bytes: bytes) -> bytes:
+    return (b'\x00' * DIGEST_SIZE_BYTES + _bytes)[-DIGEST_SIZE_BYTES:]
+
+def normalize_hex(_hex: str) -> str:
+    #_bytes = hex_to_bytes(hex)
+    #return _bytes.hex()
+    return _hex.zfill(DIGEST_SIZE_NIBBLES)[-DIGEST_SIZE_NIBBLES:]
+
+def hex_to_bytes(_hex: str) -> bytes:
+    _bytes = bytes.fromhex(_hex.zfill(DIGEST_SIZE_NIBBLES))
+    return normalize_bytes(_bytes)
+
+def bytes_to_hex(_bytes: bytes) -> str:
+    return normalize_bytes(_bytes).hex()
+
+def hash_to_bytes(_hash: int) -> bytes:
+    _bytes = _hash.to_bytes(8, byteorder='big', signed=True)
+    return normalize_bytes(_bytes)
+
+def bytes_to_hash(_bytes: bytes) -> int:
+    norm = normalize_bytes(_bytes)
+    return int.from_bytes(norm, byteorder='big', signed=False)
+
+def hash_to_hex(_hash: int) -> str:
+    #return hash_to_bytes(_hash).hex()
+    #return normalize_hex(
+    #return f"{_hash + (1 << 64):x}"[-4:]
+    #return hex(_hash + (1<<64))[2:][-4:]
+    #return f"{_hash & 0xffff:04x}"
+    return hex((_hash|DIGEST_SIZE_SIGNED_TO_UNSIGNED_BIT) & DIGEST_SIZE_SIGNED_TO_UNSIGNED_BITMASK)[3:]
+
+def hex_to_hash(_hex: str) -> int:
+    #_bytes = bytes.fromhex(hex.zfill(4))
+    #return bytes_to_hash(_bytes)
+    #return int(normalize_hex(hex), 16)
+    return int(_hex, 16) & DIGEST_SIZE_BITMASK
+
+def remove_padding(f):
+    def wrap(*args, **kwargs):
+        return f(*args, **kwargs).split('=')[0]
+    return wrap
+
+def fix_padding(f):
+    def wrap(_b64, *args, **kwargs):
+        pad = (4 - len(_b64)) % 4
+        fixed = _b64 + '='*pad
+        return f(fixed, *args, **kwargs)
+    return wrap
+
+def normalize_base32(_b32: str):
+    return b32.encode(b32.decode(_b32)).upper().zfill(DIGEST_SIZE_BYTES*8//5+1)
+
+def add_padding_base32(f):
+    def wrap(*args, **kwargs):
+        return normalize_base32(f(*args, **kwargs))
+    return wrap
+
+@remove_padding
+def hash_to_base64(_hash: int) -> str:
+    return b64encode(hash_to_bytes(_hash), altchars=B64ALTCHARS).decode("utf-8")
+
+@remove_padding
+def hex_to_base64(_hex: str) -> str:
+    return b64encode(hex_to_bytes(_hex), altchars=B64ALTCHARS).decode("utf-8")
+
+@remove_padding
+def bytes_to_base64(_bytes: str) -> str:
+    return b64encode(normalize_bytes(_bytes), altchars=B64ALTCHARS).decode("utf-8")
+
+@fix_padding
+def base64_to_hash(_b64: str) -> str:
+    return bytes_to_hash(b64decode(_b64, altchars=B64ALTCHARS))
+
+@fix_padding
+def base64_to_hex(_b64: str) -> str:
+    return bytes_to_hex(b64decode(_b64, altchars=B64ALTCHARS))
+
+@fix_padding
+def base64_to_bytes(_b64: str) -> str:
+    return normalize_bytes(b64decode(_b64, altchars=B64ALTCHARS))
+
+#@remove_padding
+def hash_to_base85(_hash: int) -> str:
+    return b85encode(hash_to_bytes(_hash)).decode("utf-8")
+
+#@remove_padding
+def hex_to_base85(_hex: str) -> str:
+    return b85encode(hex_to_bytes(_hex)).decode("utf-8")
+
+#@remove_padding
+def bytes_to_base85(_bytes: str) -> str:
+    return b85encode(normalize_bytes(_bytes)).decode("utf-8")
+
+#@fix_padding
+def base85_to_hash(_b64: str) -> str:
+    return bytes_to_hash(b85decode(_b64))
+
+#@fix_padding
+def base85_to_hex(_b64: str) -> str:
+    return bytes_to_hex(b85decode(_b64))
+
+#@fix_padding
+def base85_to_bytes(_b64: str) -> str:
+    return normalize_bytes(b85decode(_b64))
+
+@add_padding_base32
+def hash_to_base32(_hash: int) -> str:
+    return b32.encode(_hash & DIGEST_SIZE_BITMASK)
+
+@add_padding_base32
+def hex_to_base32(_hex: str) -> str:
+    return b32.encode(hex_to_hash(_hex))
+
+@add_padding_base32
+def bytes_to_base32(_bytes: bytes) -> str:
+    return b32.encode(bytes_to_hash(_bytes))
+
+#@fix_padding
+def base32_to_hash(_b64: str) -> str:
+    return b32.decode(_b64)
+
+#@fix_padding
+def base32_to_hex(_b64: str) -> str:
+    return hash_to_hex(base32_to_hash(_b64))
+
+#@fix_padding
+def base32_to_bytes(_b64: str) -> str:
+    return hash_to_bytes(base32_to_hash(_b64))

+ 92 - 200
app/rest/pyapi.py

@@ -3,34 +3,24 @@
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from typing import Iterable, Dict
 import os
 import os
-from urllib.parse import urlencode
+from threading import Thread
+from typing import Tuple
 from bottle import (
 from bottle import (
-    route,
-    request,
-    response,
-    FormsDict,
-    redirect,
-    template,
+    route, request, response,
     static_file,
     static_file,
+    FormsDict,
 )
 )
-from psycopg import connect
-from psycopg.sql import SQL, Literal
-from threading import Thread
-
-from ..data.filter import(
-    get_filter,
-    get_query_param,
-)
+from psycopg import Cursor, connect
+from psycopg.rows import TupleRow
+from urllib.parse import parse_qs
 
 
-from ..data.util import(
-    get_where_include_exclude
-)
-from . import trend as worker
-from . import PARAMS
+from .QueryCache import QueryCache
+from .route_decorators import cache, cursor
+from .query_to_xml import get_categories, get_groups, get_products, get_tags
 from .CachedLoadingPage import CachedLoadingPage
 from .CachedLoadingPage import CachedLoadingPage
-from .Cache import Cache
+from .PageCache import PageCache
+from . import trend as worker
 
 
 host = f"host={os.getenv('HOST')}"
 host = f"host={os.getenv('HOST')}"
 db = f"dbname={os.getenv('DB', 'grocery')}"
 db = f"dbname={os.getenv('DB', 'grocery')}"
@@ -40,194 +30,96 @@ if not password.split('=',1)[1]:
     password = ''
     password = ''
 conn = connect(f"{host} {db} {user} {password}")
 conn = connect(f"{host} {db} {user} {password}")
 
 
-CACHE = Cache(10)
-
-def get_product_rollup_statement(filters, having=None):
-    where = [ get_where_include_exclude(
-        k[0], "name", list(include), list(exclude)
-    ) for k, (include, exclude) in filters.items() ]
-    return SQL('\n').join([
-        SQL("""
-SELECT
-  count(DISTINCT p.id) AS "Products",
-  count(DISTINCT c.id) AS "Categories",
-  count(DISTINCT g.id) AS "Groups",
-  p.name AS "Product",
-  c.name AS "Category",
-  g.name AS "Group"
-FROM products p
-JOIN categories c ON p.category_id = c.id
-JOIN groups g ON c.group_id = g.id
-"""),
-        SQL("""
-WHERE {where}
-""").format(where=SQL("\nAND").join(where)) if where else SQL(''),
-        SQL("""
-GROUP BY ROLLUP (g.name, c.name, p.name)
-"""),
-        SQL("""
-HAVING {having}
-ORDER BY "Product", "Category", "Group"
-""").format(having=having) if having else SQL('')
-    ])
-
-
-def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> str:
-    param = get_filter(query, allow=allow)
-    return urlencode([
-        (k, get_query_param(*param[k])) for k in sorted(param) if param[k]
-    ])
-
-def _normalize_decorator(func, poison_on_reload=False):
-    def wrap(*args, **kwargs):
-        _, _, path, *_ = request.urlparts
-        normalized = normalize_query(request.query, allow=PARAMS)
-        if poison_on_reload and request.params.get('reload') == 'true':
-            CACHE.remove(normalized)
-        if request.query_string != normalized:
-            return redirect(f'{path}?{normalized}')
-        return func(*args, **kwargs)
-    return wrap
-
-def normalize(*args, **kwargs):
-    if not len(args):
-        return lambda f: _normalize_decorator(f, **kwargs)
-    
-    return _normalize_decorator(*args)
-
 @route('/grocery/static/<filename:path>')
 @route('/grocery/static/<filename:path>')
 def send_static(filename):
 def send_static(filename):
     return static_file(filename, root='app/rest/static')
     return static_file(filename, root='app/rest/static')
 
 
-@route('/grocery/trend')
-@normalize(poison_on_reload=True)
-def trend():
+def trend_thread(conn, path, forms):
+    def cb(queue):
+        return Thread(target=worker.trend, args=(
+            queue, conn, path, forms
+        )).start()
+    return cb
+
+PAGE_CACHE = PageCache(100)
+QUERY_CACHE = QueryCache(None)
+
+@route('/grocery/trend', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+def trend(key: Tuple[str, int], cache: PageCache):
     _, _, path, *_ = request.urlparts
     _, _, path, *_ = request.urlparts
-    normalized = normalize_query(request.query, allow=PARAMS)
-    if request.params.get('reload') == 'true':
-        CACHE.remove(normalized)
 
 
-    if request.query_string != normalized:
-        return redirect(f'{path}?{normalized}')
+    page = cache[key]
+    if page is None:
+        form = key_to_form(key)
+        page = cache.add(key, CachedLoadingPage([], trend_thread(conn, path, form)))
     
     
-    page = CACHE.get(normalized)
+    for i in iter_page(page):
+        yield i
 
 
-    return page if page else CACHE.add(normalized, CachedLoadingPage(
-        template("loading", progress=[]),
-        lambda queue: Thread(target=worker.trend, args=(
-            queue, conn, path, request.query
-        )).start()
-    ))
-
-@route('/grocery/groups')
-@normalize
-def groups():
-    filters = get_filter(request.query, allow=('group', 'category', 'product'))
-    form = template('form-nav', action='groups', method='get', params=[
-        {'name': k, 'value': request.params[k]} for k in request.params if k in PARAMS
-    ])
-    try:
-        with conn.cursor() as cur:
-            inner = get_product_rollup_statement(
-                filters,
-                having=SQL("c.name IS NULL")
-            )
-            xml = cur.execute(SQL("""
-SELECT query_to_xml_and_xmlschema({q}, false, false, ''::text)
-""").format(q=Literal(SQL("""SELECT
-    "Products",
-    "Categories",
-    COALESCE("Group", "Groups"||'') "Group"
-FROM (
-{inner}
-) q""").format(inner=inner).as_string(cur)))).fetchone()[0]
-    finally:
-        conn.commit()
-    response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return template("query-to-xml", title="Groups", xml=xml, form=form)
-
-@route('/grocery/categories')
-@normalize
-def categories():
-    filters = get_filter(request.query, allow=('group', 'category', 'product'))
-    form = template('form-nav', action='categories', method='get', params=[
-        {'name': k, 'value': request.params[k]} for k in request.params if k in PARAMS
-    ])
-    try:
-        with conn.cursor() as cur:
-            inner = get_product_rollup_statement(
-                filters,
-                having=SQL("p.name IS NULL AND (c.name IS NOT NULL OR g.name IS NULL)")
-            )
-            xml = cur.execute(SQL("""
-SELECT query_to_xml_and_xmlschema({q}, false, false, ''::text)
-""").format(q=Literal(SQL("""SELECT
-    "Products",
-    COALESCE("Category", "Categories"||'') "Category",
-    COALESCE("Group", "Groups"||'') "Group"
-FROM (
-{inner}
-) q""").format(inner=inner).as_string(cur)))).fetchone()[0]
-    finally:
-        conn.commit()
+
+def query_to_form(query):
+    form = FormsDict()
+    for k, v in parse_qs(query).items():
+        for item in v:
+            form.append(k, item)
+    return form
+
+
+def key_to_form(key):
+    query, _ = key
+    return query_to_form(query)
+
+
+def iter_page(page):
+    # copy first to avoid races
+    resp = list(page.value)
+    pos = len(resp)
+    yield ''.join(resp)
+    
+    while not page.loaded:
+        page.update()
+        # all changes since last yield
+        resp = list(page.value[pos:])
+        pos = pos + len(resp)
+        yield ''.join(resp)
+    
+    # possibly have not yielded the entire page
+    if pos < len(page.value):
+        yield ''.join(page.value[pos:])
+
+
+@route('/grocery/groups', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+@cursor(connection=conn)
+def groups(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return template("query-to-xml", title="Categories", xml=xml, form=form)
-
-@route('/grocery/products')
-@normalize
-def products():
-    filters = get_filter(request.query, allow=('group', 'category', 'product'))
-    form = template('form-nav', action='products', method='get', params=[
-        {'name': k, 'value': request.params[k]} for k in request.params if k in PARAMS
-    ])
-    try:
-        with conn.cursor() as cur:
-            inner = get_product_rollup_statement(
-                filters,
-                having=SQL("p.name IS NOT NULL OR g.name IS NULL")
-            )
-            xml = cur.execute(SQL("""
-SELECT query_to_xml_and_xmlschema({q}, false, false, ''::text)
-""").format(q=Literal(SQL("""SELECT
-    --"Transactions",
-    COALESCE("Product", "Products"||'') "Product",
-    COALESCE("Category", "Categories"||'') "Category",
-    COALESCE("Group", "Groups"||'') "Group"
-FROM (
-{inner}
-) q""").format(inner=inner).as_string(cur)))).fetchone()[0]
-    finally:
-        conn.commit()
+    return get_groups(cur, form)
+
+
+@route('/grocery/categories', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+@cursor(connection=conn)
+def categories(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return template("query-to-xml", title="Products", xml=xml, form=form)
-
-@route('/grocery/tags')
-@normalize
-def tags():
-    form = template('form-nav', action='tags', method='get', params=[
-        {'name': k, 'value': request.params[k]} for k in request.params if k in PARAMS
-    ])
-    try:
-        with conn.cursor() as cur:
-            inner = SQL('\n').join([SQL("""
-SELECT * FROM (SELECT count(DISTINCT txn.id) AS "Uses", tg.name AS "Name"
-FROM tags tg
-JOIN tags_map tm ON tg.id = tm.tag_id
-JOIN transactions txn ON txn.id = tm.transaction_id
-GROUP BY tg.name
-ORDER BY 1 DESC, 2) q
-UNION ALL
-SELECT count(DISTINCT txn.id) AS "Uses", count(DISTINCT tg.name)||'' AS "Name"
-FROM tags tg
-JOIN tags_map tm ON tg.id = tm.tag_id
-JOIN transactions txn ON txn.id = tm.transaction_id
-""")]).as_string(cur)
-            xml = cur.execute(SQL("""
-SELECT query_to_xml_and_xmlschema({inner}, false, false, ''::text)
-""").format(inner=Literal(inner))).fetchone()[0]
-    finally:
-        conn.commit()
+    return get_categories(cur, form)
+
+
+@route('/grocery/products', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+@cursor(connection=conn)
+def products(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
     response.content_type = 'application/xhtml+xml; charset=utf-8'
     response.content_type = 'application/xhtml+xml; charset=utf-8'
-    return template("query-to-xml", title="Tags", xml=xml, form=form)
+    return get_products(cur, form)
 
 
 
 
+@route('/grocery/tags', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+@cursor(connection=conn)
+def tags(cur: Cursor[TupleRow], key: Tuple[str, int], cache: PageCache):
+    form = key_to_form(key)
+    response.content_type = 'application/xhtml+xml; charset=utf-8'
+    return get_tags(cur, form)

+ 143 - 0
app/rest/query_to_xml.py

@@ -0,0 +1,143 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from bottle import request, template, FormsDict
+from pandas import DataFrame
+from psycopg import Cursor
+from psycopg.sql import SQL, Literal, Identifier
+from typing import List
+
+from ..data.filter import get_filter
+from ..data.util import get_where_include_exclude
+from ..data.QueryManager import get_data
+from .form import get_form
+from . import BOOLEAN, PARAMS
+
+
+def get_product_rollup_statement(filters, orderby: List[str]) -> SQL:
+    _map = { k: k[0] for k in ('product', 'category', 'group') }
+    where = [ get_where_include_exclude(
+        _map[k], "name", list(include), list(exclude)
+    ) for k, (include, exclude) in filters.items() ]
+    return SQL("""
+SELECT
+  count(DISTINCT p.id) AS "Products",
+  count(DISTINCT c.id) AS "Categories",
+  count(DISTINCT g.id) AS "Groups",
+  p.name AS "product",
+  c.name AS "category",
+  g.name AS "group"
+FROM products p
+JOIN categories c ON p.category_id = c.id
+JOIN groups g ON c.group_id = g.id
+WHERE {where}
+GROUP BY ROLLUP (g.name, c.name, p.name)
+ORDER BY {orderby}
+""").format(
+        where=SQL("\nAND").join(where),
+        orderby=SQL(", ").join(map(Identifier,orderby)),
+    )
+
+
+def get_inner_query(query: FormsDict, orderby: List[str]) -> SQL:
+    filters = get_filter(query, allow=('group', 'category', 'product'))
+    inner = get_product_rollup_statement(filters, orderby)
+    return inner
+
+
+def render_form(cur: Cursor, inner: str, query: FormsDict):
+    _filter = get_filter(query, allow=PARAMS)
+    data = DataFrame(get_data(cur, inner)).dropna()
+    action = request.path.split('/')[-1]
+    organic = BOOLEAN.get(query.organic, None)
+    return get_form(action, 'post', _filter, organic, data)
+
+
+def get_xml(cur: Cursor, sql: str):
+    return cur.execute(SQL(
+        "SELECT query_to_xml_and_xmlschema({q}, false, false, ''::text)"
+    ).format(q=Literal(sql))).fetchone()[0]
+
+
+def get_products(cur: Cursor, query: FormsDict):
+    inner = get_inner_query(query, ['product', 'category', 'group'])
+    form = render_form(cur, inner, query)
+    sql = SQL("""
+SELECT
+    --"Transactions",
+    COALESCE("product", "Products"||'') "Product",
+    COALESCE("category", "Categories"||'') "Category",
+    COALESCE("group", "Groups"||'') "Group"
+FROM ({inner}) q
+WHERE q.product IS NOT NULL OR q.group IS NULL
+""").format(inner=inner).as_string(cur)
+    xml = get_xml(cur, sql)
+    return template("query-to-xml", title="Products", xml=xml, form=form)
+
+
+def get_categories(cur: Cursor, query: FormsDict):
+    inner = get_inner_query(query, ['category', 'group'])
+    form = render_form(cur, inner, query)
+    sql = SQL("""
+SELECT
+    "Products",
+    COALESCE("category", "Categories"||'') "Category",
+    COALESCE("group", "Groups"||'') "Group"
+FROM ({inner}) q
+WHERE q.product IS NULL AND (q.category IS NOT NULL OR q.group IS NULL)
+""").format(inner=inner).as_string(cur)
+    xml = get_xml(cur, sql)
+    return template("query-to-xml", title="Categories", xml=xml, form=form)
+
+
+def get_groups(cur: Cursor, query: FormsDict):
+    inner = get_inner_query(query, ['group',])
+    form = render_form(cur, inner, query)
+    sql = SQL("""
+SELECT
+    "Products",
+    "Categories",
+    COALESCE("group", "Groups"||'') "Group"
+FROM ({inner}) q
+WHERE q.category IS NULL
+""").format(inner=inner).as_string(cur)
+    xml = get_xml(cur, sql)
+    return template("query-to-xml", title="Groups", xml=xml, form=form)
+
+def get_tags_statement(filters) -> SQL:
+    _map = {
+        k: k[0] for k in ('product', 'category', 'group')
+    }
+    _map.update({ 'tag': 'tg' })
+    where = [ get_where_include_exclude(
+        _map[k], "name", list(include), list(exclude)
+    ) for k, (include, exclude) in filters.items() ]
+    return SQL("""
+SELECT * FROM (SELECT count(DISTINCT txn.id) AS "Uses", tg.name AS "Name"
+FROM tags tg
+JOIN tags_map tm ON tg.id = tm.tag_id
+JOIN transactions txn ON txn.id = tm.transaction_id
+WHERE {where}
+GROUP BY tg.name
+ORDER BY 1 DESC, 2) q
+UNION ALL
+SELECT count(DISTINCT txn.id) AS "Uses", count(DISTINCT tg.name)||'' AS "Name"
+FROM tags tg
+JOIN tags_map tm ON tg.id = tm.tag_id
+JOIN transactions txn ON txn.id = tm.transaction_id
+WHERE {where}
+""").format(where=SQL("\nAND").join(where))
+
+def get_inner_tags_query(query: FormsDict) -> SQL:
+    filters = get_filter(query, allow=('tag',))
+    inner = get_tags_statement(filters)
+    return inner
+
+def get_tags(cur: Cursor, query: FormsDict):
+    inner = get_inner_tags_query(query)
+    form = render_form(cur, inner, query)
+    sql = inner.as_string(cur)
+    xml = get_xml(cur, sql)
+    return template("query-to-xml", title="Tags", xml=xml, form=form)

+ 1 - 1
app/rest/requirements.txt

@@ -1,5 +1,5 @@
 seaborn
 seaborn
 psycopg[binary]
 psycopg[binary]
 bottle
 bottle
-wsgigzip
 cherrypy
 cherrypy
+base32-lib

+ 90 - 0
app/rest/route_decorators.py

@@ -0,0 +1,90 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from typing import Callable, Iterable, Tuple
+from urllib.parse import urlencode
+from bottle import request, FormsDict, redirect, HTTPError
+from psycopg import Connection
+from psycopg.connection import TupleRow
+
+from .QueryCache import QueryCache, get_hash
+from ..data.filter import get_filter, get_query_param
+from . import BOOLEAN, PARAMS
+from .PageCache import PageCache
+from .hash_util import base32_to_hash, hash_to_base32, normalize_base32
+
+def normalize_query(query: FormsDict, allow: Iterable[str] = None) -> Tuple[str, str]:
+    if query.hash:
+        _hash = normalize_base32(query.hash)
+    else:
+        _hash = None
+
+    allow = allow or PARAMS
+    param = get_filter(query, allow=allow)
+    norm = urlencode([
+        (
+            k, get_query_param(*param[k])
+        ) if k != 'organic' else (
+            "organic", BOOLEAN[BOOLEAN.get(query.organic, None)]
+        ) for k in sorted(param) if param[k]
+    ])
+    return norm if not _hash or len(query.keys()) > 1 else None, _hash
+
+
+def _cache_decorator(func: Callable, query_cache: QueryCache = None, page_cache: PageCache = None):
+    def wrap(*args, **kwargs):
+        _, _, path, *_ = request.urlparts
+        
+        query, _hash = normalize_query(request.params)
+        if not _hash:
+            _hashInt = get_hash(query)
+            _hash = hash_to_base32(_hashInt)
+            key = (query, _hashInt)
+        else:
+            key = (query, base32_to_hash(_hash))
+        
+        if request.params.reload == "true":
+            page_cache.remove(key)
+
+        # key with tuple to avoid ambiguity
+        cached = query_cache.get(key)
+        if not cached:
+            if query:
+                cached = query_cache.add(key, None)
+            else:
+                return HTTPError(404, f"No query found for hash: {_hash}")
+
+        if not request.params.hash:
+            if cached and len(cached) > 2000:
+                return redirect(f"{path}?hash={_hash}")
+
+            if cached and request.query_string != cached:
+                return redirect(f"{path}?{cached}")
+
+        return func((cached, key[1]), page_cache, *args, **kwargs)
+    return wrap
+
+
+def cache(*args, **kwargs):
+    if not len(args):
+        return lambda f: _cache_decorator(f, **kwargs)
+    
+    raise Exception("decorator argument required")
+
+
+def _cursor_decorator(func: Callable, connection: Connection[TupleRow] = None):
+    def wrap(*args, **kwargs):
+        try:
+            with connection.cursor() as cur:
+                return func(cur, *args, **kwargs)
+        finally:
+            connection.commit()
+    return wrap
+
+
+def cursor(*args, **kwargs):
+    if not len(args):
+        return lambda f: _cursor_decorator(f, **kwargs)
+    raise Exception("decorator argument required")

BIN
app/rest/static/favicon.png


File diff suppressed because it is too large
+ 72 - 0
app/rest/static/favicon.svg


File diff suppressed because it is too large
+ 306 - 0
app/rest/static/favicon_square.svg


+ 22 - 0
app/rest/static/manifest.json

@@ -0,0 +1,22 @@
+{
+  "id": "/grocery",
+  "name": "Grocery Manager Web Application",
+  "short_name": "Grocery",
+  "description": "View trending price data and tracked product info",
+  "start_url": "/grocery/trend",
+  "theme_color": "firebrick",
+  "background_color": "black",
+  "display": "standalone",
+  "icons": [
+    {
+      "src": "/grocery/static/favicon.svg",
+      "sizes": "any",
+      "purpose": "any"
+    },
+    {
+      "src": "/grocery/static/favicon_square.svg",
+      "sizes": "any",
+      "purpose": "any"
+    }
+  ]
+}

+ 1 - 0
app/rest/templates/done.tpl

@@ -0,0 +1 @@
+<div class="done"></div>

+ 1 - 0
app/rest/templates/error-500.tpl

@@ -0,0 +1 @@
+<span style="font-size: 3em">{{ error }}</span>

+ 7 - 1
app/rest/templates/filter-set.tpl

@@ -6,6 +6,12 @@
 %>
 %>
 <%
 <%
   include('include-exclude', **tags)
   include('include-exclude', **tags)
-  include('select-one', **units)
+
 %>
 %>
+  <div class="pure-u-1-3 pure-u-lg-1-12">
+    <div class="pure-g">
+    <% include('select-one', **units) %>
+    <% include('range-organic') %>
+    </div>
+  </div>
 <div class="pure-u-lg-1-8"></div>
 <div class="pure-u-lg-1-8"></div>

+ 19 - 0
app/rest/templates/form-filter.tpl

@@ -1,5 +1,24 @@
 % from app.data.filter import get_query_param
 % from app.data.filter import get_query_param
 <form id="filter" method="{{ method }}" action="{{ action }}">
 <form id="filter" method="{{ method }}" action="{{ action }}">
+  <style>
+  select::-webkit-scrollbar {
+  width: 11px;
+}
+select {
+  color: #cccccc;
+  background-color: #080808;
+  scrollbar-width: thin;
+  scrollbar-color: var(--thumbBG) var(--scrollbarBG);
+}
+select::-webkit-scrollbar-track {
+  background: var(--scrollbarBG);
+}
+select::-webkit-scrollbar-thumb {
+  background-color: var(--thumbBG) ;
+  border-radius: 6px;
+  border: 3px solid var(--scrollbarBG);
+}
+  </style>
   % include('button-style')
   % include('button-style')
   % include('button-nav', action=action)
   % include('button-nav', action=action)
   <details style="padding: 1em 0">
   <details style="padding: 1em 0">

+ 0 - 27
app/rest/templates/loading.tpl

@@ -1,27 +0,0 @@
-<html>
-  <head>
-    <link rel="stylesheet" href="/grocery/static/cloud-gears.css"/>
-    <style>
-body {
-  background-color: #080808;
-  color: #cccccc;
-}
-.loader-container {
-  position: absolute;
-  left: 45vw;
-  top: 45vh;
-}
-    </style>
-  </head>
-  <body>
-    <div class="loader-container">
-      <span class="loader"></span>
-      <%
-      for indicator in progress:
-          include('progress', **indicator)
-      end
-      %>
-    </div>
-    <meta http-equiv="Refresh" content="0;" />
-  </body>
-</html>

+ 5 - 3
app/rest/templates/progress.tpl

@@ -1,3 +1,5 @@
-<p>
-{{name}}... {{status}}
-</p>
+<div class="progress">
+  <progress id="loading-{{stage}}" value="{{percent}}" max="100"></progress>
+  <br/>
+  <label for="loading-{{stage}}">{{stage}}</label>
+</div>

+ 2 - 0
app/rest/templates/query-to-xml.tpl

@@ -8,6 +8,8 @@
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous"/>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous"/>
     <link rel="stylesheet" href="https://shandan.one/css/grids-responsive-min.css"/>
     <link rel="stylesheet" href="https://shandan.one/css/grids-responsive-min.css"/>
     <link rel="stylesheet" href="https://shandan.one/css/responsive-visibility-collapse.css"/>
     <link rel="stylesheet" href="https://shandan.one/css/responsive-visibility-collapse.css"/>
+    <link rel="manifest" href="/grocery/static/manifest.json"/>
+    <link rel="icon" type="image/png" href="/grocery/static/favicon.png"/>
     <style>
     <style>
 body {
 body {
   background-color: #080808;
   background-color: #080808;

+ 14 - 0
app/rest/templates/range-organic.tpl

@@ -0,0 +1,14 @@
+<div class="pure-u-1">
+  <div class="l-box">
+    <h3>Organic</h3>
+  </div>
+  </div>
+  <label for="organic-state" hidden="true">Organic</label>
+  <div class="pure-g">
+  <div class="pure-u-1-3">No</div>
+  <div class="pure-u-1-3">Any</div>
+  <div class="pure-u-1-3">Yes</div>
+  <div class="pure-u-1">
+    <input type="range" id="organic-state" name="organic" min="0" max="1" step="0.5" value="{{organic}}" />
+  </div>
+</div>

+ 8 - 11
app/rest/templates/select-one.tpl

@@ -1,13 +1,10 @@
-<div class="pure-u-1-3 pure-u-lg-1-12">
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="l-box">
-      <h3>{{name.title()}}</h3>
-    </div>
+<div class="pure-u-1">
+  <div class="l-box">
+    <h3>{{name.title()}}</h3>
   </div>
   </div>
-  <% include('select', id=f"{name}-select-one", name=name,
-     children=[{
-        "options": options
-     }]) %>
 </div>
 </div>
-</div>
+<% include('select', id=f"{name}-select-one", name=name,
+   children=[{
+      "options": options
+   }])
+%>

+ 2 - 2
app/rest/templates/select.tpl

@@ -1,11 +1,11 @@
 % from bottle import template
 % from bottle import template
-% multiple = (get("multiple", False) and "multiple") or ""
+% multiple = (get("multiple", False) and 'multiple="true"') or ""
 <div class="pure-u-1">
 <div class="pure-u-1">
 %  if defined("label"):
 %  if defined("label"):
 %    include('label', id=id, label=label)
 %    include('label', id=id, label=label)
 %  end
 %  end
 
 
-<select id="{{id}}" name="{{name}}" size=10 {{multiple}} style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="{{id}}" name="{{name}}" size="10" {{!multiple}} style="width: calc(100% - 1em); margin: 0 1em 1em">
 %  if defined("hint"):
 %  if defined("hint"):
 %    include('option', value=hint, disabled=True)
 %    include('option', value=hint, disabled=True)
 %  end
 %  end

+ 59 - 28
app/rest/templates/trend.tpl

@@ -1,46 +1,77 @@
-<!DOCTYPE html>
+% setdefault("start", False)
+% setdefault("end", False)
+% setdefault("error", '')
+% if start:
 <html>
 <html>
-    <head>
-        <style>
+  <head>
+    <title>Trend</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1"/>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous"/>
+    <link rel="stylesheet" href="https://shandan.one/css/grids-responsive-min.css"/>
+    <link rel="stylesheet" href="https://shandan.one/css/responsive-visibility-collapse.css"/>
+    <link rel="stylesheet" href="/grocery/static/cloud-gears.css"/>
+    <link rel="manifest" href="/grocery/static/manifest.json"/>
+    <link rel="icon" type="image/png" href="/grocery/static/favicon.png"/>
+    <style>
 html {
 html {
   --scrollbarBG: #333333;
   --scrollbarBG: #333333;
   --thumbBG: #080808;
   --thumbBG: #080808;
 }
 }
+svg {
+  max-height: min(100vh, calc(100vw * 9 / 16));
+  max-width: calc(100vw - 2em);
+}
 body {
 body {
   background-color: #080808;
   background-color: #080808;
   color: #cccccc;
   color: #cccccc;
+  text-align: center;
 }
 }
-select::-webkit-scrollbar {
-  width: 11px;
+div.loader-container {
+  position: absolute;
+  left: 50vw;
+  top: 50vh;
+  margin-top: -5.5em;
+  margin-left: -87.5px;
+  padding-bottom: 2em;
+  height: 9em;
+  width: 175px;
 }
 }
-select {
-  color: #cccccc;
-  background-color: #080808;
-  scrollbar-width: thin;
-  scrollbar-color: var(--thumbBG) var(--scrollbarBG);
+div.loader-container:not(:has(+ .done)) {
+  display: block;
 }
 }
-select::-webkit-scrollbar-track {
-  background: var(--scrollbarBG);
+.loader-container:not(:last-child) {
+  display: none;
 }
 }
-select::-webkit-scrollbar-thumb {
-  background-color: var(--thumbBG) ;
-  border-radius: 6px;
-  border: 3px solid var(--scrollbarBG);
+div.progress {
+  margin: 1em 0 1em;
 }
 }
-
-svg {
-  max-height: min(100vh, calc(100vw * 9 / 16));
-  max-width: calc(100vw - 2em);
+div.progress:not(:has(+ .done)) {
+  display: block;
+}
+.progress label {
+  text-align:left;
+}
+.progress label:after {
+  content: "...";
+}
+.progress:not(:last-child) {
+  display: none;
 }
 }
-        </style>
-        <title>Trend</title>
-        <meta name="viewport" content="width=device-width, initial-scale=1"/>
-        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@2.1.0/build/pure-min.css" integrity="sha384-yHIFVG6ClnONEA5yB5DJXfW2/KC173DIQrYoZMEtBvGzmf0PKiGyNEqe9N6BNDBH" crossorigin="anonymous"/>
-        <link rel="stylesheet" href="https://shandan.one/css/grids-responsive-min.css"/>
-        <link rel="stylesheet" href="https://shandan.one/css/responsive-visibility-collapse.css"/>
-    </head>
-    <body align="center" style="text-align: center">
+    </style>
+  </head>
+  <body>
+    <div class="loader-container">
+    <span class="loader"></span>
+% end
+% if end:
+    </div>
+    <div class="done"></div>
 {{!form}}
 {{!form}}
+    % if error:
+    % include('error-500', error=error)
+    % else:
 {{!svg}}
 {{!svg}}
+    % end
     </body>
     </body>
 </html>
 </html>
+% end

+ 47 - 33
app/rest/trend.py

@@ -4,25 +4,28 @@
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from io import StringIO
+from queue import Queue
 from bottle import (
 from bottle import (
-    DictProperty,
+    FormsDict,
     HTTPError,
     HTTPError,
     template,
     template,
 )
 )
-from io import StringIO
 import matplotlib.pyplot as plt
 import matplotlib.pyplot as plt
 import matplotlib
 import matplotlib
+from pandas import DataFrame
 import seaborn as sns
 import seaborn as sns
 from psycopg import Connection
 from psycopg import Connection
 from psycopg.connection import TupleRow
 from psycopg.connection import TupleRow
-from queue import Queue
-from . import ALL_UNITS
+
+from . import ALL_UNITS, BOOLEAN, PARAMS
 from ..data.QueryManager import (
 from ..data.QueryManager import (
     display_mapper,
     display_mapper,
     QueryManager,
     QueryManager,
 )
 )
 from ..data.filter import (
 from ..data.filter import (
     get_filter,
     get_filter,
+    get_query_param,
 )
 )
 from ..activities.Plot import (
 from ..activities.Plot import (
     get_data,
     get_data,
@@ -30,42 +33,50 @@ from ..activities.Plot import (
 from .form import(
 from .form import(
     get_form,
     get_form,
 )
 )
-from . import PARAMS
 
 
 matplotlib.use('agg')
 matplotlib.use('agg')
 
 
 def abort(code, text):
 def abort(code, text):
-    return HTTPError(code, text)
+    raise HTTPError(code, text)
 
 
-def trend(queue: Queue, conn: Connection[TupleRow], path: str, query: DictProperty):
+def trend(queue: Queue, conn: Connection[TupleRow], path: str, query: FormsDict):
     for item in trend_internal(conn, path, query):
     for item in trend_internal(conn, path, query):
         queue.put(item, block=True)
         queue.put(item, block=True)
     queue.put(None)
     queue.put(None)
 
 
-def trend_internal(conn: Connection[TupleRow], path: str, query: DictProperty):
-    progress = []
+def trend_internal(conn: Connection[TupleRow], path: str, query: FormsDict):
+    progress = {
+        'stage': None,
+        'percent': None,
+    }
+    action = path.split('/')[-1]
+    organic = BOOLEAN.get(query.organic, None)
+    _filter = get_filter(query, allow=PARAMS)
+    yield template("trend", start=True)
     try:
     try:
         with conn.cursor() as cur:
         with conn.cursor() as cur:
             query_manager = QueryManager(cur, display_mapper)
             query_manager = QueryManager(cur, display_mapper)
-            fields = { k: query[k] or None for k in query.keys() if k in PARAMS }
-            unit = fields['unit'] = fields['unit'] or 'kg' if 'unit' in fields else 'kg'
+            fields = {
+                k: get_query_param(*_filter[k])
+                for k in sorted(_filter) if k not in ('organic', 'unit') and _filter[k]
+            }
+            unit = fields['unit'] = query.unit or 'kg'
+            fields['organic'] = BOOLEAN.get(query.organic, None)
             if unit and unit not in ALL_UNITS:
             if unit and unit not in ALL_UNITS:
-                yield abort(400, f"Unsupported unit {unit}")
-                return
+                abort(400, f"Unsupported unit: {unit}")
 
 
-            progress.append({ "name": "Loading data", "status": ""})
-            yield template("loading", progress=progress)
+            progress.update({ "stage": "Querying database", "percent": "10"})
+            yield template("done") + template("progress", **progress)
             data = get_data(query_manager, **fields)
             data = get_data(query_manager, **fields)
-
-            progress[-1]["status"] = "done"
-            yield template("loading", progress=progress)
-
+            
             if data.empty:
             if data.empty:
-                yield abort(404, f"No data for {fields}")
-                return
+                abort(404, f"No data.")
+            
+            progress.update({ "stage": "Preparing data", "percent": "30"})
+            yield template("done") + template("progress", **progress)
             
             
-            progress.append({ "name": "Loading chart", "status": ""})
-            yield template("loading", progress=progress)
+            in_chart = data['$/unit'].apply(lambda x: (x or False) and True)
+            data = data[in_chart]
             
             
             pivot = data.pivot_table(index=['ts_raw',], columns=['product',], values=['$/unit'], aggfunc='mean')
             pivot = data.pivot_table(index=['ts_raw',], columns=['product',], values=['$/unit'], aggfunc='mean')
             pivot.columns = pivot.columns.droplevel()
             pivot.columns = pivot.columns.droplevel()
@@ -107,21 +118,24 @@ def trend_internal(conn: Connection[TupleRow], path: str, query: DictProperty):
             for _, spine in ax.spines.items():
             for _, spine in ax.spines.items():
                 spine.set_color('#ffffff')
                 spine.set_color('#ffffff')
             
             
-            progress[-1]["status"] = "done"
-            yield template("loading", progress=progress)
+            progress.update({ "stage": "Rendering chart", "percent": "50"})
+            yield template("done") + template("progress", **progress)
             
             
-            progress.append({ "name": "Rendering chart", "status": ""})
-            yield template("loading", progress=progress)
-
             f = StringIO()
             f = StringIO()
             plt.savefig(f, format='svg')
             plt.savefig(f, format='svg')
-            _filter = get_filter(query, allow=PARAMS)
-            form = get_form(path.split('/')[-1], 'get', _filter, data)
+            progress.update({ "stage": "Done", "percent": "100" })
+            yield template("done") + template("progress", **progress)
             
             
-            progress[-1]["status"] = "done"
-            yield template("loading", progress=progress)
+            form = get_form(action, 'post', _filter, organic, data)
             
             
-            yield template("trend", form=form, svg=f.getvalue())
+            yield template("trend", end=True, form=form, svg=f.getvalue())
+
+    except HTTPError as e:
+        if 'data' not in locals():
+            data = DataFrame()
+        if 'form' not in locals():
+            form = get_form(action, 'post', _filter, organic, data)
+        yield template("done") + template("trend", end=True, form=form, error=e.body)
 
 
     finally:
     finally:
         conn.commit()
         conn.commit()

+ 3 - 9
grocery_transactions.py

@@ -11,7 +11,7 @@ from urwid import raw_display, WidgetPlaceholder, SolidFill, MainLoop
 from app.activities.ActivityManager import ActivityManager, show_or_exit
 from app.activities.ActivityManager import ActivityManager, show_or_exit
 from app.activities.TransactionEditor import TransactionEditor
 from app.activities.TransactionEditor import TransactionEditor
 from app.data.QueryManager import QueryManager, display_mapper
 from app.data.QueryManager import QueryManager, display_mapper
-from app.palette import solarized
+from app.palette import iter_palettes, solarized
 
 
 try:
 try:
     from db_credentials import HOST, PASSWORD
     from db_credentials import HOST, PASSWORD
@@ -114,14 +114,8 @@ activity_manager.create(TransactionEditor, 'transaction',
 )
 )
 
 
 app = None
 app = None
-def iter_palettes():
-    palettes = [ v for k,v in solarized.theme.items() ]
-    while True:
-        p = palettes.pop(0)
-        palettes.append(p)
-        yield p
-
-palettes = iter_palettes()
+
+palettes = iter_palettes(solarized.theme)
 
 
 try:
 try:
     app = GroceryTransactionEditor(activity_manager, cur, log)
     app = GroceryTransactionEditor(activity_manager, cur, log)

+ 0 - 1
requirements.txt

@@ -9,4 +9,3 @@ pandas
 pyyaml
 pyyaml
 numpy
 numpy
 seaborn
 seaborn
-urllib

+ 1 - 2
test/activities/test_Rating.py

@@ -1,10 +1,9 @@
 #
 #
-# Copyright (c) Daniel Sheffield 2021 - 2023
+# Copyright (c) Daniel Sheffield 2023
 #
 #
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-import numpy as np
 from app.activities.Rating import Rating
 from app.activities.Rating import Rating
 from pytest import mark, fixture
 from pytest import mark, fixture
 from urwid import Text
 from urwid import Text

+ 83 - 0
test/activities/test_activity_manager.py

@@ -0,0 +1,83 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from itertools import chain
+from pytest import mark, fixture, raises
+from urwid.raw_display import Screen
+from urwid import ExitMainLoop
+from urwid.widget import Widget, Text
+from app.activities import ActivityManager
+from app.activities.ActivityManager import show_or_exit, ActivityManager
+
+from app.palette import high_contrast, iter_palettes, solarized
+
+@fixture
+def palettes():
+    return chain(iter_palettes(solarized.theme), iter_palettes(high_contrast.theme))
+
+@fixture
+def activity_manager():
+    return ActivityManager()
+
+@fixture
+def widget():
+    return Widget()
+
+@mark.parametrize('screen', [
+    None,
+    Screen(),
+])
+@mark.parametrize('key, expected', [
+  ((1,), None),
+  ('ctrl home', None),
+  ('esc', (ExitMainLoop, ""))
+])
+def test_show_or_exit(key, screen, palettes, expected):
+    if not isinstance(expected, tuple):
+        assert expected == show_or_exit(key, screen=screen, palettes=palettes)
+        return
+    
+    with raises(expected[0]):
+        show_or_exit(key, screen=screen, palettes=palettes)
+
+def test_add(activity_manager, widget):
+    assert widget == activity_manager.add(widget, 'myActivity')
+    assert 'myActivity' in activity_manager.widgets
+    assert widget == activity_manager.add(widget, 'myOtherActivity')
+    assert 'myOtherActivity' in activity_manager.widgets
+    assert 'myActivity' in activity_manager.widgets
+
+def test_get(activity_manager, widget):
+    assert activity_manager.get('myActivity') is None
+    activity_manager.add(widget, 'myActivity')
+    assert widget == activity_manager.get('myActivity')
+    activity_manager.add(widget, 'myOtherActivity')
+    assert widget == activity_manager.get('myOtherActivity')
+    assert widget == activity_manager.get('myActivity')
+
+def test_create(activity_manager):
+    widget = activity_manager.create(Text, 'widget_name', 'abc', align='right')
+    assert isinstance(widget, Text)
+    assert activity_manager.get('widget_name') is widget
+    activity_manager.show(widget)
+    assert activity_manager.app is widget
+
+def test_show(activity_manager, widget):
+    assert activity_manager.app is None
+    activity_manager.add(widget, 'myActivity')
+    assert activity_manager.show(widget) is None
+    assert activity_manager.app is widget
+    assert getattr(activity_manager.app, 'original_widget', None) is None
+    assert activity_manager.show(widget) is None
+    assert activity_manager.app.original_widget is widget
+
+def test_current(activity_manager, widget):
+    assert activity_manager.current() is None
+    activity_manager.add(widget, 'myActivity')
+    activity_manager.show(widget)
+    # fix requiring call twice if not a container widget
+    activity_manager.show(widget)
+    assert activity_manager.current() is widget

+ 24 - 0
test/activities/test_banner.py

@@ -0,0 +1,24 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from app import COPYRIGHT
+from app.activities.Banner import banner
+from urwid import Text, Pile, AttrMap, Padding
+
+def test_banner():
+    myBanner = banner('test-banner')
+    assert isinstance(myBanner.original_widget, Pile)
+    assert isinstance(myBanner, AttrMap)
+    contents = myBanner.original_widget.contents
+    assert len(contents) == 2
+    for (w,_), expected in zip(contents, [
+        'test-banner',
+        COPYRIGHT
+    ]):
+        assert isinstance(w, Padding)
+        original = w.original_widget
+        assert isinstance(original, Text)
+        assert original.get_text()[0] == expected

+ 77 - 0
test/activities/test_grouped_widget_util.py

@@ -0,0 +1,77 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from pytest import mark, fixture
+from itertools import chain
+from app.activities.grouped_widget_util import (
+    to_named_value,
+    to_numbered_field,
+    to_unnumbered_field,
+    in_same_row,
+)
+
+@mark.parametrize("idx", chain(range(2), [None, 'any']))
+@mark.parametrize("key, value", [
+    ('label', 'value'),
+    ('label2', 3),
+    ('checkbox', True),
+    ('checkbox', 'mixed'),
+])
+def test_to_unnumbered_field(key, idx, value):
+    assert (key, value) == to_unnumbered_field((key, idx, value))
+
+@mark.parametrize("key, value, expected", [
+    ('label#1', 'value', ('label', 1, 'value')),
+    ('label2#0', 3, ('label2', 0, 3)),
+    ('checkbox', True, ('checkbox', 0, True)),
+    ('checkbox', 'mixed', ('checkbox', 0, 'mixed')),
+])
+def test_to_numbered_field(key, value, expected):
+    assert expected == to_numbered_field((key, value))
+
+@mark.parametrize("key, values, expected", [
+    ('label#1', [
+        ('label', 1, 'value'),
+        ('label2', 0, 3),
+        ('checkbox', 0, True),
+        ('checkbox', 0, 'mixed'),
+        ('label2', 1, 0),
+    ], [
+        ('label', 1, 'value'),
+        ('label2', 1, 0),
+    ]),
+    ('label', [
+        ('label', 1, 'value'),
+        ('label2', 0, 3),
+        ('checkbox', 0, True),
+        ('checkbox', 0, 'mixed'),
+        ('label2', 1, 0),
+    ], [
+        ('label2', 0, 3),
+        ('checkbox', 0, True),
+        ('checkbox', 0, 'mixed'),
+    ]),
+])
+def test_in_same_row(key, values, expected):
+    assert expected == list(filter(in_same_row(key), values))
+
+@mark.parametrize("key, values, expected", [
+    ('label2', [
+        (1, 'value'),
+        (0, 3),
+        (0, True),
+        (0, 'mixed'),
+        (1, 0),
+    ], [
+        ('label2#1', 'value'),
+        ('label2#0', 3),
+        ('label2#0', True),
+        ('label2#0', 'mixed'),
+        ('label2#1', 0)
+    ]),
+])
+def test_to_named_value(key, values, expected):
+    assert expected == list(map(to_named_value(key), values))

+ 67 - 0
test/data/test_dataframe_util.py

@@ -0,0 +1,67 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from datetime import datetime
+from pytest import mark, raises
+from pandas import DataFrame
+
+from app.data.dataframe_util import(
+    get_caption,
+    get_divider,
+    get_time_range,
+    stats,
+)
+
+@mark.parametrize('data', [
+    DataFrame([
+        {'ts': datetime(1970, 1, 1)}, {'ts': datetime(2000, 2, 28)}
+    ])
+])
+def test_get_timerange(data):
+    assert ("01/01/1970", "28/02/2000") == get_time_range(data, 'ts')
+
+@mark.parametrize('width, marker, expected', [
+    #(0, '─', (AssertionError, "")),
+    #(1, '─', (AssertionError, "")),
+    #(2, '─', (AssertionError, "")),
+    (0, '─', ' ─ '),
+    (1, '─', ' ─ '),
+    (2, '─', ' ─ '),
+    (3, '─', ' ─ '),
+    (4, '─', ' ── '),
+    (5, '─', ' ─── '),
+    (6, '─', '  ──  '),
+    (7, '─', '  ───  '),
+    (8, '─', '   ──   '),
+])
+def test_get_divider(width, marker, expected):
+    if not isinstance(expected, tuple):
+        assert expected == get_divider(width, marker=marker)
+        return
+
+    with raises(expected[0]):
+        get_divider(width, marker=marker)
+
+@mark.parametrize('data', [
+    DataFrame([
+        {'ts': 0, 'price': 3.0, 'amount': 10, '$/unit': 0.3 },
+        {'ts': datetime(2000, 2, 28), 'price': 11, 'amount': 2, '$/unit': 5.5 },
+    ])
+])
+def test_stats(data):
+    assert [0.3, 2.9, 14/12, 5.5] == stats(data, 'price', 'amount', '$/unit')
+
+@mark.parametrize('data', [
+    DataFrame([
+        {'ts': datetime(1970, 1, 1)}, {'ts': datetime(2000, 2, 28)}
+    ])
+])
+@mark.parametrize('width, div', [
+    (26, ' ── '),
+    (27, ' ─── '),
+])
+def test_get_caption(data, width, div):
+    assert f"01/01/1970 {div} 28/02/2000" == get_caption(get_time_range(data, 'ts'), width)

+ 20 - 0
test/data/test_decimal.py

@@ -0,0 +1,20 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from decimal import Decimal
+from pytest import mark
+
+from app.data.decimal_util import(
+    decimal_or_none
+)
+
+@mark.parametrize('maybe_decimal, expected', [
+    ('1', Decimal('1')),
+    ('1.0', Decimal('1.0')),
+    ('', None),
+])
+def test_decimal_or_none(maybe_decimal, expected):
+    assert decimal_or_none(maybe_decimal) == expected

+ 1 - 1
test/data/test_util.py

@@ -4,7 +4,7 @@
 # All rights reserved
 # All rights reserved
 #
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from psycopg.sql import SQL, Identifier, Literal, Composable
+from psycopg.sql import SQL, Literal, Composable
 from typing import Callable, Iterable, Union
 from typing import Callable, Iterable, Union
 from pytest import mark, raises
 from pytest import mark, raises
 from app.data.util import (
 from app.data.util import (

+ 2 - 2
test/rest/templates/test_include-exclude.py

@@ -12,7 +12,7 @@ from bottle import template
     </div>
     </div>
 <div class="pure-u-1">
 <div class="pure-u-1">
 
 
-<select id="item-include" name="item" size=10 multiple style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="item-include" name="item" size="10" multiple="true" style="width: calc(100% - 1em); margin: 0 1em 1em">
   <option value="Include" disabled="true" >Include</option>
   <option value="Include" disabled="true" >Include</option>
 
 
   <option value="val1-to-backend"  >val1</option>
   <option value="val1-to-backend"  >val1</option>
@@ -21,7 +21,7 @@ from bottle import template
 </div>
 </div>
 <div class="pure-u-1">
 <div class="pure-u-1">
 
 
-<select id="item-exclude" name="item" size=10 multiple style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="item-exclude" name="item" size="10" multiple="true" style="width: calc(100% - 1em); margin: 0 1em 1em">
   <option value="Exclude" disabled="true" >Exclude</option>
   <option value="Exclude" disabled="true" >Exclude</option>
 
 
   <option value="val1-to-backend"  >val1</option>
   <option value="val1-to-backend"  >val1</option>

+ 5 - 8
test/rest/templates/test_select-one.py

@@ -3,16 +3,14 @@ from pytest import mark, raises
 from bottle import template
 from bottle import template
 
 
 @mark.parametrize('expected, params', [
 @mark.parametrize('expected, params', [
-    ("""<div class="pure-u-1-3 pure-u-lg-1-12">
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="l-box">
-      <h3>Unit</h3>
-    </div>
+    ("""<div class="pure-u-1">
+  <div class="l-box">
+    <h3>Unit</h3>
   </div>
   </div>
+</div>
 <div class="pure-u-1">
 <div class="pure-u-1">
 
 
-<select id="unit-select-one" name="unit" size=10  style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="unit-select-one" name="unit" size="10"  style="width: calc(100% - 1em); margin: 0 1em 1em">
 
 
 
 
   <option value="Bags"  >Bags</option>
   <option value="Bags"  >Bags</option>
@@ -23,7 +21,6 @@ from bottle import template
   <option value="kg"  selected="true">kg</option>
   <option value="kg"  selected="true">kg</option>
   <option value="mL"  >mL</option>
   <option value="mL"  >mL</option>
 </select>
 </select>
-</div></div>
 </div>""", {
 </div>""", {
     "name": "unit", "options": [{
     "name": "unit", "options": [{
         "value": "Bags",
         "value": "Bags",

+ 3 - 3
test/rest/templates/test_select.py

@@ -5,7 +5,7 @@ from bottle import template
 @mark.parametrize('expected, params', [
 @mark.parametrize('expected, params', [
     ("""<div class="pure-u-1">
     ("""<div class="pure-u-1">
 <label for="select-id">Choose: </label>
 <label for="select-id">Choose: </label>
-<select id="select-id" name="select-name" size=10 multiple style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="select-id" name="select-name" size="10" multiple="true" style="width: calc(100% - 1em); margin: 0 1em 1em">
   <option value="hint" disabled="true" >hint</option>
   <option value="hint" disabled="true" >hint</option>
 <optgroup label="Group">
 <optgroup label="Group">
   <option value="val1-to-backend"  >val1</option>
   <option value="val1-to-backend"  >val1</option>
@@ -25,7 +25,7 @@ from bottle import template
     }, ]}, ]}),
     }, ]}, ]}),
     ("""<div class="pure-u-1">
     ("""<div class="pure-u-1">
 
 
-<select id="select-unit-id" name="unit" size=10  style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="select-unit-id" name="unit" size="10"  style="width: calc(100% - 1em); margin: 0 1em 1em">
 
 
 
 
   <option value="val1"  >val1</option>
   <option value="val1"  >val1</option>
@@ -42,7 +42,7 @@ from bottle import template
     }, ]}, ]}),
     }, ]}, ]}),
     ("""<div class="pure-u-1">
     ("""<div class="pure-u-1">
 <label for="select-id">Choose: </label>
 <label for="select-id">Choose: </label>
-<select id="select-id" name="select-name" size=10 multiple style="width: calc(100% - 1em); margin: 0 1em 1em">
+<select id="select-id" name="select-name" size="10" multiple="true" style="width: calc(100% - 1em); margin: 0 1em 1em">
   <option value="hint" disabled="true" >hint</option>
   <option value="hint" disabled="true" >hint</option>
 <optgroup label="Group">
 <optgroup label="Group">
   <option value="val1-to-backend"  >val1</option>
   <option value="val1-to-backend"  >val1</option>

+ 27 - 26
test/rest/test_Cache.py

@@ -6,67 +6,68 @@
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 from pytest import fixture
 from pytest import fixture
 from time import time
 from time import time
-from app.rest.Cache import Cache
+from app.rest.PageCache import PageCache
 from app.rest.CachedLoadingPage import (
 from app.rest.CachedLoadingPage import (
     CachedLoadingPage,
     CachedLoadingPage,
+    STALE,
 )
 )
 
 
 @fixture
 @fixture
 def cache():
 def cache():
-    return Cache(0)
+    return PageCache(0)
 
 
-def test_add(cache: Cache):
+def test_add(cache: PageCache):
     val = 'test-cached-value'
     val = 'test-cached-value'
     key = 'test-key'
     key = 'test-key'
-    assert cache.add(key, CachedLoadingPage(val, lambda _: None)) == val
+    assert cache.add(key, CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
 
 
-def test_get(cache: Cache):
+def test_get(cache: PageCache):
     val = 'test-cached-value'
     val = 'test-cached-value'
     key = 'test-key'
     key = 'test-key'
 
 
     assert cache.get(key) is None
     assert cache.get(key) is None
 
 
-    assert cache.add(key, CachedLoadingPage(val, lambda q: q.put('next-val'))) == val
-    assert cache.get(key) == 'next-val'
+    assert cache.add(key, CachedLoadingPage(val, lambda q: q.put('next-val'), incremental=False)).value == val
+    assert cache.get(key).value == 'next-val'
 
 
-def test_remove(cache: Cache):
+def test_remove(cache: PageCache):
     val = 'test-cached-value'
     val = 'test-cached-value'
     key = 'test-key'
     key = 'test-key'
 
 
     assert cache.get(key) is None
     assert cache.get(key) is None
-    assert cache.add(key, CachedLoadingPage(val, lambda q: q.put('next-val'))) == val
+    assert cache.add(key, CachedLoadingPage(val, lambda q: q.put('next-val'), incremental=False)).value == val
     cache.remove(key)
     cache.remove(key)
     assert cache.get(key) is None
     assert cache.get(key) is None
 
 
-def test_enforce_limit(cache: Cache):
+def test_enforce_limit(cache: PageCache):
     val = 'test-cached-value'
     val = 'test-cached-value'
     key = 'test-key'
     key = 'test-key'
-    assert cache.add(key, CachedLoadingPage(val, lambda _: None)) == val
+    assert cache.add(key, CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
     # adding more exceeds limit
     # adding more exceeds limit
-    assert cache.add('other', CachedLoadingPage(val, lambda _: None)) == val
+    assert cache.add('other', CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
     assert cache.get(key) is None
     assert cache.get(key) is None
-    assert cache.get('other') == val
+    assert cache.get('other').value == val
 
 
-def test_clean_stale(cache: Cache):
+def test_clean_stale(cache: PageCache):
     val = 'test-cached-value'
     val = 'test-cached-value'
     key = 'test-key'
     key = 'test-key'
-    page = CachedLoadingPage(val, lambda _: None)
-    page._created = time() - 10*60
+    page = CachedLoadingPage(val, lambda _: None, incremental=False)
+    page._created = time() - STALE
     # add stale page
     # add stale page
-    assert cache.add(key, page) == val
+    assert cache.add(key, page).value == val
     assert cache.get(key) is None
     assert cache.get(key) is None
-    
-    page = CachedLoadingPage(val, lambda _: None)
-    assert cache.add(key, page) == val
+
+    page = CachedLoadingPage(val, lambda _: None, incremental=False)
+    assert cache.add(key, page).value == val
     # make page stale
     # make page stale
-    page._created = time() - 10*60
+    page._created = time() - STALE
     assert cache.get(key) is None
     assert cache.get(key) is None
 
 
-    page = CachedLoadingPage(val, lambda _: None)
-    assert cache.add(key, page) == val
+    page = CachedLoadingPage(val, lambda _: None, incremental=False)
+    assert cache.add(key, page).value == val
     # make page stale
     # make page stale
-    page._created = time() - 10*60
+    page._created = time() - STALE
     # stale page is rotated out on addition
     # stale page is rotated out on addition
-    assert cache.add('other', CachedLoadingPage(val, lambda _: None)) == val
+    assert cache.add('other', CachedLoadingPage(val, lambda _: None, incremental=False)).value == val
     assert cache.get(key) is None
     assert cache.get(key) is None
-    assert cache.get('other') == val
+    assert cache.get('other').value == val

+ 3 - 2
test/rest/test_CachedLoadingPage.py

@@ -10,12 +10,13 @@ from pytest import mark, fixture
 from time import sleep, time
 from time import sleep, time
 from app.rest.CachedLoadingPage import (
 from app.rest.CachedLoadingPage import (
     CachedLoadingPage,
     CachedLoadingPage,
+    STALE,
 )
 )
 
 
 
 
 @fixture
 @fixture
 def cache():
 def cache():
-    return CachedLoadingPage("start", lambda _: None)
+    return CachedLoadingPage("start", lambda _: None, incremental=False)
 
 
 
 
 def test_get_age(cache: CachedLoadingPage):
 def test_get_age(cache: CachedLoadingPage):
@@ -52,7 +53,7 @@ def test_update(cache: CachedLoadingPage):
     assert cache.update() == "final value"
     assert cache.update() == "final value"
 
 
 def test_stale(cache: CachedLoadingPage):
 def test_stale(cache: CachedLoadingPage):
-    cache._created = time() - 10*60
+    cache._created = time() - STALE
     assert cache.stale
     assert cache.stale
 
 
 def test_lock(cache: CachedLoadingPage):
 def test_lock(cache: CachedLoadingPage):

File diff suppressed because it is too large
+ 308 - 0
test/rest/test_hash_util.py


+ 76 - 0
test/rest/test_route_decorators.py

@@ -0,0 +1,76 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from bottle import FormsDict
+from pytest import fixture, mark, raises
+from app.rest import PARAMS
+from app.rest.route_decorators import (
+    cache,
+    cursor,
+    normalize_query,
+)
+
+def incr(_dict, key):
+    _dict[key] = _dict[key] + 1
+
+@fixture
+def counter():
+    return dict()
+
+@fixture
+def tracker():
+    return dict()
+
+@fixture
+def method(counter, tracker):
+    counter['method'] = 0
+    tracker['method'] = []
+    return lambda *args, **kwargs: incr(counter, 'method') or \
+        tracker['method'].push({'args': args, 'kwargs': kwargs})
+
+@mark.parametrize('form_data, expected', [
+    ([
+        ('product', 'kiwi'),
+        ('product', 'apple'),
+        ('organic', ''),
+    ], ('category=&group=&organic=0.5&product=apple%7Ckiwi&tag=&unit=', None)),
+    ([
+        ('product', 'kiwi'),
+        ('category', '!baking'),
+        ('organic', '1'),
+    ], ('category=%21baking&group=&organic=1&product=kiwi&tag=&unit=', None)),
+])
+def test_normalise_query(form_data, expected):
+    form = FormsDict()
+    for k,v in form_data:
+        form.append(k, v)
+    
+    assert normalize_query(form, allow=PARAMS) == expected
+
+
+# need to mock bottle request.params
+def test_cache(counter, tracker, method):
+    decorated = cache(query_cache=None, page_cache=None)
+    assert callable(decorated)
+    #decorated()
+    #assert counter['method'] == 1
+    #assert tracker['method'][0]['args'] == []
+    #assert tracker['method'][0]['kwargs'] == {'allow': None}
+    with raises(Exception) as ex:
+        cursor(method)
+    assert ex.value.args[0] == "decorator argument required"
+
+
+def test_cursor(counter, tracker, method):
+    decorated = cursor(cache=None)
+    assert callable(decorated)
+    #decorated()
+    #assert counter['method'] == 1
+    #assert tracker['method'][0]['args'] == []
+    #assert tracker['method'][0]['kwargs'] == {'allow': None}
+    with raises(Exception) as ex:
+        cursor(method)
+    assert ex.value.args[0] == "decorator argument required"

Some files were not shown because too many files changed in this diff