Переглянути джерело

Merge branch 'vertical-button-groups' of gogsadmin/grocery-manager into master

gogsadmin 7 місяців тому
батько
коміт
6ec6a3ab42

+ 0 - 92
app/activities/Plot.py

@@ -1,92 +0,0 @@
-#
-# Copyright (c) Daniel Sheffield 2023
-#
-# All rights reserved
-#
-# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from datetime import date
-import pandas as pd
-import seaborn as sns
-import matplotlib.pyplot as plt
-from app.data.QueryManager import QueryManager
-
-def get_data(query_manager: QueryManager, unit=None, **kwargs) -> pd.DataFrame:
-    d = pd.DataFrame(query_manager.get_historic_prices_data(unit, **kwargs))
-    if d.empty:
-        return d
-    d['ts_month'] = d['ts_raw'].apply(lambda x: date(x.date().year, x.date().month,1))
-    d[['price','quantity']] = d[['price','quantity']].apply(
-        lambda y: y.apply(lambda x: x and float(x)),
-    )
-    return d
-
-def pivot_data(data: QueryManager):
-    pivot = data.groupby(['ts_month','group',])['price', 'quantity'].sum()
-    pivot = pivot.reset_index().set_index('group')
-    return pivot
-
-def pie(p: pd.DataFrame, col=None, title=None):
-    ax = p.plot.pie(y=col, figsize=(5, 5))
-    ax.get_legend().remove()
-    ax.set_xlabel('')
-    ax.set_ylabel('')
-    ax.set_title(title)
-    plt.show()
-
-def line(pivot, ylabel=None, xlabel=None):
-    ax = sns.lineplot(data=pivot, markers=True)
-    ax.set_xlabel(xlabel)
-    ax.set_ylabel(ylabel)
-    plt.show()
-
-def get_selection(
-    query_manager: QueryManager,
-    fields: dict[str, str],
-    units: set[str],
-    name: str
-):
-    options = query_manager.unique_suggestions(name, **fields)
-    matches = '\t'.join(list(options))
-    print(f'{name.title()} names: {matches}')
-    while (len(options) >=1):
-        v = fields[name] if name in fields else ''
-        fields[name] = v + input(f"{name.title()}: {v}")
-        options = query_manager.unique_suggestions(name, **fields)
-        if name == 'unit':
-            options = sorted(set(options) | units)
-
-        if fields[name] == '':
-            choice = ''
-            break
-        elif len(options) == 1:
-            choice = options[0]
-            break
-        elif fields[name].lower() in map(lambda x: x.lower(), options):
-            choice = next(
-                filter(lambda x: x.lower() == fields[name].lower(), options)
-            )
-            break
-        elif len(options) == 0 and name == 'unit':
-            choice = fields[name]
-            break
-        matches = '\t'.join(options)
-        print(f'Matches ({name}): {matches}')
-
-    return choice
-
-def get_input(query_manager: QueryManager, units: set[str]) -> dict[str, str]:
-    fields = dict()
-    for k in ('group', 'category', 'product', 'unit'):
-        choice = get_selection(query_manager, fields, units, k)
-
-        if k != 'unit':
-            fields[k] = (fields[k] and choice) or ''
-        else:
-            fields[k] = choice
-        if fields[k] == '':
-            print(f'Ignoring {k} {choice} as it does not exist')
-        else:
-            print(f'Selected {k}: {fields[k]}')
-        print()
-    return fields
-

+ 18 - 4
app/rest/pyapi.py

@@ -34,9 +34,9 @@ conn = connect(f"{host} {db} {user} {password}")
 def send_static(filename):
     return static_file(filename, root='app/rest/static')
 
-def trend_thread(conn, path, forms):
+def new_thread(target, conn, path, forms):
     def cb(queue):
-        return Thread(target=worker.trend, args=(
+        return Thread(target=target, args=(
             queue, conn, path, forms
         )).start()
     return cb
@@ -44,6 +44,20 @@ def trend_thread(conn, path, forms):
 PAGE_CACHE = PageCache(100)
 QUERY_CACHE = QueryCache(None)
 
+@route('/grocery/volume', method=['GET', 'POST'])
+@cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
+def volume(key: Tuple[str, int], cache: PageCache):
+    _, _, path, *_ = request.urlparts
+
+    page = cache[key]
+    if page is None:
+        form = key_to_form(key)
+        page = cache.add(key, CachedLoadingPage([], new_thread(worker.volume, conn, path, form)))
+    
+    for i in iter_page(page):
+        yield i
+
+
 @route('/grocery/trend', method=['GET', 'POST'])
 @cache(query_cache=QUERY_CACHE, page_cache=PAGE_CACHE)
 def trend(key: Tuple[str, int], cache: PageCache):
@@ -52,7 +66,7 @@ def trend(key: Tuple[str, int], cache: PageCache):
     page = cache[key]
     if page is None:
         form = key_to_form(key)
-        page = cache.add(key, CachedLoadingPage([], trend_thread(conn, path, form)))
+        page = cache.add(key, CachedLoadingPage([], new_thread(worker.trend, conn, path, form)))
     
     for i in iter_page(page):
         yield i
@@ -68,7 +82,7 @@ def query_to_form(query):
 
 def key_to_form(key):
     query, _ = key
-    return query_to_form(query)
+    return query_to_form(query.split('?', 1)[-1])
 
 
 def iter_page(page):

+ 5 - 2
app/rest/route_decorators.py

@@ -37,7 +37,10 @@ def _cache_decorator(func: Callable, query_cache: QueryCache = None, page_cache:
     def wrap(*args, **kwargs):
         _, _, path, *_ = request.urlparts
         
+        endpoint = path.split('/', 2)[-1]
+
         query, _hash = normalize_query(request.params)
+        query = f"{endpoint}?{query}"
         if not _hash:
             _hashInt = get_hash(query)
             _hash = hash_to_base32(_hashInt)
@@ -60,8 +63,8 @@ def _cache_decorator(func: Callable, query_cache: QueryCache = None, page_cache:
             if cached and len(cached) > 2000:
                 return redirect(f"{path}?hash={_hash}")
 
-            if cached and request.query_string != cached:
-                return redirect(f"{path}?{cached}")
+            if cached and f"{endpoint}?{request.query_string}" != cached:
+                return redirect(cached)
 
         return func((cached, key[1]), page_cache, *args, **kwargs)
     return wrap

+ 1 - 1
app/rest/static/manifest.json

@@ -1,6 +1,6 @@
 {
   "id": "/grocery",
-  "name": "Grocery Manager Web Application",
+  "name": "Grocery Manager",
   "short_name": "Grocery",
   "description": "View trending price data and tracked product info",
   "start_url": "/grocery/trend",

+ 20 - 2
app/rest/static/query-to-xml-xslt.xml

@@ -25,13 +25,15 @@
                   select="$schema/xsd:element[@name=name(current())]/@type"/>
     <xsl:variable name="rowtypename"
                   select="$schema/xsd:complexType[@name=$tabletypename]/xsd:sequence/xsd:element[@name='row']/@type"/>
-    <xsl:variable name="tablename" select="$schema/xsd:complexType[@name=$rowtypename]/xsd:sequence/xsd:element[1]/@name"/>
-     
+    <xsl:variable name="fieldname"
+                  select="$schema/xsd:complexType[@name=$rowtypename]/xsd:sequence/xsd:element[@name='Product' or @name='Category' or @name='Group' or @name='Name'][1]/@name"/>
+    
     <table xmlns="http://www.w3.org/1999/xhtml"
       class="pure-table pure-table-bordered pure-table-striped" style="text-align: left; width: 100%;">
     
       <thead style="text-align: center">
       <tr>
+        <th></th>
         <xsl:for-each select="$schema/xsd:complexType[@name=$rowtypename]/xsd:sequence/xsd:element/@name">
           <th><xsl:value-of select="."/></th>
         </xsl:for-each>
@@ -43,6 +45,22 @@
         <xsl:choose>
           <xsl:when test="position() != last()">
             <tr>
+              <td>
+                <input type="checkbox">
+                  <xsl:attribute name="name">
+                    <xsl:choose>
+                      <xsl:when test="$fieldname = 'Name'">tag</xsl:when>
+                      <xsl:otherwise>
+                        <xsl:value-of select="translate($fieldname,'PCG', 'pcg')"/>
+                      </xsl:otherwise>
+                    </xsl:choose>
+                  </xsl:attribute>
+                  <xsl:attribute name="value">
+                    <xsl:value-of select="*[name() = $fieldname][1]"/>
+                  </xsl:attribute>
+                  <xsl:attribute name="form">filter</xsl:attribute>
+                </input>
+              </td>
               <xsl:for-each select="*">
                 <td><xsl:value-of select="."/></td>
               </xsl:for-each>

+ 4 - 8
app/rest/templates/button-action.tpl

@@ -1,9 +1,5 @@
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="pure-button-group" role="action" style="padding: 1em 0 0;">
-    <button class="button-resize pure-button" type="submit"> Apply </button>
-    <button form="clear" class="button-resize pure-button" type="submit"> Clear </button>
-    <button form="reload" class="button-resize pure-button" type="submit"> Reload </button>
-    </div>
-  </div>
+<div class="pure-button-group vertical-button-group" role="action" style="padding: 1em 0.25em 0;">
+  <button class="pure-button" type="submit"> Apply </button>
+  <button form="clear" class="pure-button" type="submit"> Clear </button>
+  <button form="reload" class="pure-button" type="submit"> Reload </button>
 </div>

+ 0 - 11
app/rest/templates/button-nav.tpl

@@ -1,11 +0,0 @@
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="pure-button-group" role="nav" style="padding: 1em 0 0;">
-    % for target in ("trend", "products", "categories", "groups", "tags"):
-    %   disabled = 'disabled="true"' if target == action else ''
-    %   label = target.title()
-      <button class="button-resize pure-button" type="submit" formaction="{{target}}" {{!disabled}}> {{label}} </button>
-    % end
-    </div>
-  </div>
-</div>

+ 10 - 9
app/rest/templates/button-style.tpl

@@ -1,15 +1,16 @@
 <style>
-.button-resize { font-size: 70%; }
-@media screen and (min-width:35.5em){
-    .button-resize { font-size: 75%; }
+.vertical-button-group .pure-button:first-child {
+    border-top-right-radius: 2px;
+    border-bottom-left-radius: 0px;
 }
-@media screen and (min-width:40em){
-    .button-resize { font-size: 85%; }
+.vertical-button-group .pure-button:last-child {
+    border-top-right-radius: 0px;
+    border-bottom-left-radius: 2px;
 }
-@media screen and (min-width:64em){
-    .button-resize { font-size: 100%; }
+.vertical-button-group .pure-button {
+    width: 100%;
 }
-@media screen and (min-width:80em){
-    .button-resize { font-size: 110%; }
+.vertical-button-group .pure-button-hover, .pure-button:focus, .pure-button:hover {
+    background-image: linear-gradient(.25turn, rgba(0, 0, 0, .1), rgba(0, 0, 0, .05) 20%, rgba(0, 0, 0, .05) 80%, rgba(0, 0, 0, 0.1));
 }
 </style>

+ 13 - 0
app/rest/templates/buttongroup-nav.tpl

@@ -0,0 +1,13 @@
+% setdefault('style', '')
+<div class="pure-button-group vertical-button-group" role="{{role}}" style="padding: 1em 0.25em 0;">
+% for target in targets:
+%   active = 'pure-button-active' if target == action else ''
+%   label = target.title()
+  <button
+    class="pure-button {{active}}"
+    type="submit"
+    formaction="{{target}}"
+    style="{{style}}"
+  > {{label}} </button>
+% end
+</div>

+ 10 - 4
app/rest/templates/form-filter.tpl

@@ -19,11 +19,17 @@ select::-webkit-scrollbar-thumb {
   border: 3px solid var(--scrollbarBG);
 }
   </style>
-  % include('button-style')
-  % include('button-nav', action=action)
+  <div class="pure-g">
+    <div class="pure-u-1-24 pure-u-lg-1-5"></div>  
+    <div class="pure-u-11-12 pure-u-lg-3-5">
+    % include('button-style')
+    % include('menu', action=action)
+
+    </div>
+    <div class="pure-u-1-24 pure-u-lg-1-5"></div>    
+  </div>
   <details style="padding: 1em 0">
     <summary>Click to expand filter...</summary>
-    % include('button-action')
     <div class="pure-g">
       <%
       include('filter-set',
@@ -38,4 +44,4 @@ select::-webkit-scrollbar-thumb {
 %   'value': get_query_param(inc, ex),
 % } for k, (inc, ex) in params.items()]
 % include('form-clear', params=params)
-% include('form-reload', params=params)
+% include('form-reload', params=params)

+ 0 - 17
app/rest/templates/form-nav.tpl

@@ -1,17 +0,0 @@
-<form id="filter" method="{{ method }}" action="{{ action }}">
-  % include('button-style')
-
-  % include('button-nav', action=action)
-
-  % include('button-action')
-
-  <div style="width: 0; height: 1em">
-  % for param in params:
-  %   include('hidden-input', **param)
-
-  % end
-  </div>
-</form>
-% include('form-clear', params=params)
-
-% include('form-reload', params=params)

+ 19 - 0
app/rest/templates/menu.tpl

@@ -0,0 +1,19 @@
+<div class="pure-g">
+  <div class="pure-u-1-3">
+  % include('buttongroup-nav', role='chart', style='background-color: firebrick', targets=[
+  %   "trend",
+  %   "volume",
+  % ])
+
+  </div>
+  <div class="pure-u-1-3">
+  % include('buttongroup-nav', role='data', targets=[
+  %   "products", "categories", "groups", "tags"
+  % ])
+
+  </div>
+  <div class="pure-u-1-3">
+    % include('button-action')
+
+  </div>
+</div>

+ 79 - 0
app/rest/templates/volume.tpl

@@ -0,0 +1,79 @@
+% setdefault("start", False)
+% setdefault("end", False)
+% setdefault("error", '')
+% if start:
+<html>
+  <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 {
+  --scrollbarBG: #333333;
+  --thumbBG: #080808;
+}
+svg {
+  max-height: min(100vh, calc(100vw * 9 / 16));
+  max-width: calc(100vw - 2em);
+}
+body {
+  background-color: #080808;
+  color: #cccccc;
+  text-align: center;
+}
+div.loader-container {
+  position: absolute;
+  left: 50vw;
+  top: 50vh;
+  margin-top: -5.5em;
+  margin-left: -87.5px;
+  padding-bottom: 2em;
+  height: 9em;
+  width: 175px;
+}
+div.loader-container:not(:has(+ .done)) {
+  display: block;
+}
+.loader-container:not(:last-child) {
+  display: none;
+}
+div.progress {
+  margin: 1em 0 1em;
+}
+div.progress:not(:has(+ .done)) {
+  display: block;
+}
+.progress label {
+  text-align:left;
+}
+.progress label:after {
+  content: "...";
+}
+.progress:not(:last-child) {
+  display: none;
+}
+    </style>
+  </head>
+  <body>
+    <div class="loader-container">
+    <span class="loader"></span>
+% end
+% if end:
+    </div>
+    <div class="done"></div>
+{{!form}}
+    % if error:
+    % include('error-500', error=error)
+    % else:
+    %   for plt in svg:
+{{!plt}}
+    %    end
+    % end
+    </body>
+</html>
+% end

+ 149 - 17
app/rest/trend.py

@@ -5,6 +5,7 @@
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
 from io import StringIO
+from datetime import date, datetime
 from queue import Queue
 from bottle import (
     FormsDict,
@@ -13,6 +14,7 @@ from bottle import (
 )
 import matplotlib.pyplot as plt
 import matplotlib
+import numpy as np
 from pandas import DataFrame
 import seaborn as sns
 from psycopg import Connection
@@ -27,15 +29,38 @@ from ..data.filter import (
     get_filter,
     get_query_param,
 )
-from ..activities.Plot import (
-    get_data,
-)
 from .form import(
     get_form,
 )
 
 matplotlib.use('agg')
 
+plot_style = {
+    "lines.color": "#ffffff",
+    "patch.edgecolor": "#ffffff",
+    "text.color": "#ffffff",
+    "axes.facecolor": "#7f7f7f",
+    "axes.edgecolor": "#ffffff",
+    "axes.labelcolor": "#ffffff",
+    "xtick.color": "#ffffff",
+    "ytick.color": "#ffffff",
+    "grid.color": "#ffffff",
+    "figure.facecolor": "#7f7f7f",
+    "figure.edgecolor": "#7f7f7f",
+    "savefig.facecolor": "#7f7f7f",
+    "savefig.edgecolor": "#7f7f7f",
+}
+
+def get_data(query_manager: QueryManager, unit=None, **kwargs) -> DataFrame:
+    d = DataFrame(query_manager.get_historic_prices_data(unit, **kwargs))
+    if d.empty:
+        return d
+    d['ts_month'] = d['ts_raw'].apply(lambda x: date(x.date().year, x.date().month,1))
+    d[['price','quantity']] = d[['price','quantity']].apply(
+        lambda y: y.apply(lambda x: x and float(x)),
+    )
+    return d
+
 def abort(code, text):
     raise HTTPError(code, text)
 
@@ -82,20 +107,7 @@ def trend_internal(conn: Connection[TupleRow], path: str, query: FormsDict):
             pivot.columns = pivot.columns.droplevel()
             sns.set_theme(style='darkgrid', palette='pastel', context="talk")
             plt.style.use("dark_background")
-            plt.rcParams.update({
-    "lines.color": "#ffffff",
-    "patch.edgecolor": "#ffffff",
-    "text.color": "#ffffff",
-    "axes.facecolor": "#7f7f7f",
-    "axes.edgecolor": "#ffffff",
-    "axes.labelcolor": "#ffffff",
-    "xtick.color": "#ffffff",
-    "ytick.color": "#ffffff",
-    "grid.color": "#ffffff",
-    "figure.facecolor": "#7f7f7f",
-    "figure.edgecolor": "#7f7f7f",
-    "savefig.facecolor": "#7f7f7f",
-    "savefig.edgecolor": "#7f7f7f"})
+            plt.rcParams.update(plot_style)
             plt.rcParams.update({"grid.linewidth":0.2, "grid.alpha":0.5})
             plt.figure(figsize=[16, 9], layout="tight")
             xlabel='Time'
@@ -139,3 +151,123 @@ def trend_internal(conn: Connection[TupleRow], path: str, query: FormsDict):
 
     finally:
         conn.commit()
+
+def volume(queue: Queue, conn: Connection[TupleRow], path: str, query: FormsDict):
+    for item in volume_internal(conn, path, query):
+        queue.put(item, block=True)
+    queue.put(None)
+
+def volume_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:
+        with conn.cursor() as cur:
+            query_manager = QueryManager(cur, display_mapper)
+            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:
+                abort(400, f"Unsupported unit: {unit}")
+
+            progress.update({ "stage": "Querying database", "percent": "10"})
+            yield template("done") + template("progress", **progress)
+            data = get_data(query_manager, **fields)
+
+            if data.empty:
+                abort(404, f"No data.")
+            
+            progress.update({ "stage": "Preparing data", "percent": "30"})
+            yield template("done") + template("progress", **progress)
+            
+            now = datetime.now().date()
+            prev_month = date(now.year,now.month-1,1) if now.month > 1 else date(now.year-1, 12, 1)
+            data = data[data['ts_month'] == prev_month ]
+            group = 'group'
+            for g, _g in zip(
+                ('category', 'group'),
+                ('product', 'category')
+            ):
+                if g and len(data[g].unique()) != 1:
+                    continue
+                group = _g
+                break
+
+            pivot = data[~(data['quantity'].isnull())].groupby([group,])[['price', 'quantity']].sum()
+            
+            if pivot.empty:
+                abort(404, f"No data.")
+
+            sns.set_theme(style='darkgrid', palette='pastel', context="talk")
+            plt.style.use("dark_background")
+            plt.rcParams.update(plot_style)
+            plt.rcParams.update({"grid.linewidth":0.2, "grid.alpha":0.5})
+            svg = []
+            for title, col, (pre, fmt, suf) in zip((
+                f"Expenditure ${pivot['price'].sum():0.2f}",
+                f"Quantity {pivot['quantity'].sum():0.1f} {unit}",
+            ), (
+                'price',
+                'quantity'
+            ), [
+                ('$', "{0:0.2f}", ''),
+                ('', "{0:0.1f}", f' {unit}')
+            ]):
+                plt.figure(figsize=[16, 9], layout="tight")
+                ax = plt.axes()
+                wedges, *_ = ax.pie(pivot[col].values, startangle=90)
+                bbox_props = dict(boxstyle="square,pad=0.3", lw=0.72, fc='#5f5f5f', ec=plot_style["axes.edgecolor"])
+                kw = dict(arrowprops=dict(arrowstyle="-"),
+                    bbox=bbox_props, zorder=0, va="center")
+                for i, p in enumerate(wedges):
+                    ang = (p.theta2 - p.theta1)/2. + p.theta1
+                    y = np.sin(np.deg2rad(ang))
+                    x = np.cos(np.deg2rad(ang))
+                    horizontalalignment = {-1: "right", 1: "left"}[int(np.sign(x))]
+                    connectionstyle = f"angle,angleA=0,angleB={ang}"
+                    kw["arrowprops"].update({"connectionstyle": connectionstyle})
+                    label = pivot.index[i]
+                    val = pivot.loc[label, col]
+                    label = f"{label} {pre}{fmt.format(val)}{suf} ({val*100/pivot[col].sum():0.1f}%)"
+                    ax.annotate(label, xy=(x, y), xytext=(1.35*np.sign(x), 1.4*y),
+                                horizontalalignment=horizontalalignment, **kw)
+                ax.set_title(title)
+                xlabel=''
+                ylabel=''
+                ax.legend().set_visible(False)
+
+                ax.set_xlabel(xlabel, fontsize="14")
+                ax.set_ylabel(ylabel, fontsize="14")
+                ax.axes.tick_params(labelsize="12", which='both')
+            
+                progress.update({ "stage": "Rendering chart", "percent": "50"})
+                yield template("done") + template("progress", **progress)
+                
+                f = StringIO()
+                plt.savefig(f, format='svg')
+                svg.append(f.getvalue())
+
+            progress.update({ "stage": "Done", "percent": "100" })
+            yield template("done") + template("progress", **progress)
+            
+            form = get_form(action, 'post', _filter, organic, data)
+            
+            yield template("volume", end=True, form=form, svg=svg)
+
+    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:
+        conn.commit()

+ 3 - 0
grocery_transactions.py

@@ -42,6 +42,9 @@ class GroceryTransactionEditor(WidgetPlaceholder):
         self.cur = cur
         txn: TransactionEditor = self.activity_manager.get('transaction')
 
+        with open(log, 'a') as f:
+            pass
+
         with open(log, 'r') as f:
             date = None
             store = None

+ 0 - 63
price_plot.py

@@ -1,63 +0,0 @@
-#
-# Copyright (c) Daniel Sheffield 2023
-#
-# All rights reserved
-#
-# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from datetime import date, datetime
-import os
-import sys
-from psycopg import connect, Cursor
-import seaborn as sns
-from db_credentials import HOST, PASSWORD
-from app.activities.Plot import (
-    get_data,
-    get_input,
-    pivot_data,
-    pie,
-    line,
-)
-from app.data.QueryManager import QueryManager, display_mapper
-
-ALL_UNITS = {'g','kg','mL','L','Pieces','Bunches','Bags'}
-host = f'host={HOST}'
-password = f'password={PASSWORD}'
-user = os.getenv('USER')
-conn = connect(f"{host} dbname=grocery user={user} {password}")
-cur: Cursor = conn.cursor()
-cur.execute("BEGIN")
-
-query_manager = QueryManager(cur, display_mapper)
-
-fields = get_input(query_manager, ALL_UNITS)
-
-unit = fields['unit'] = fields['unit'] or 'kg'
-fields = { k: v or None for k,v in fields.items() }
-if unit not in ALL_UNITS:
-    print(f'Invalid unit: {unit}')
-    exit(2)
-
-print('Getting data for selection:\n  ')
-print('\n  '.join([
-    f'{k.title()}: {v}' for k,v in fields.items()
-]))
-
-data = get_data(query_manager, **fields)
-if data.empty:
-    sys.exit(1)
-
-now = datetime.now().date()
-pivot = pivot_data(data[data['ts_month'] == date(now.year,now.month,1)])
-
-sns.set_theme()
-
-if not pivot.empty:
-    pie(pivot, col='quantity', title=f'Quantity ({unit})')
-    pie(pivot, col='price', title='Price ($)')
-
-pivot = data.pivot_table(index=['ts_raw',], columns=['product',], values=['$/unit'], aggfunc='mean')
-pivot.columns = pivot.columns.droplevel()
-print(pivot.info())
-print(pivot)
-
-line(pivot, xlabel='Time', ylabel=f'$ / {unit}')

+ 42 - 0
test/rest/templates/test_buttongroup-nav.py

@@ -0,0 +1,42 @@
+from bottle import template
+from pytest import mark
+
+@mark.parametrize('expected', [
+    """<div class="vertical-button-group">
+  <div class="pure-button-group vertical-button-group" role="nav" style="padding: 1em 0.25em 0;">
+    <button
+      class="pure-button "
+      type="submit"
+      formaction="trend"
+      style=""
+    > Trend </button>
+    <button
+      class="pure-button pure-button-active"
+      type="submit"
+      formaction="products"
+      style=""
+    > Products </button>
+    <button
+      class="pure-button "
+      type="submit"
+      formaction="categories"
+      style=""
+    > Categories </button>
+    <button
+      class="pure-button "
+      type="submit"
+      formaction="groups"
+      style=""
+    > Groups </button>
+    <button
+      class="pure-button "
+      type="submit"
+      formaction="tags"
+      style=""
+    > Tags </button>
+  </div>
+</div>"""])
+def test_form_nav_render_exact(expected):
+    assert template('buttongroup-nav', role='nav', action='products', targets=[
+        "trend", "products", "categories", "groups", "tags"
+    ]) == expected

+ 0 - 77
test/rest/templates/test_form-nav.py

@@ -1,77 +0,0 @@
-from bottle import template
-from pytest import mark
-
-@mark.parametrize('expected, params', [
-    ("""<form id="filter" method="get" action="products">
-<style>
-.button-resize { font-size: 70%; }
-@media screen and (min-width:35.5em){
-    .button-resize { font-size: 75%; }
-}
-@media screen and (min-width:40em){
-    .button-resize { font-size: 85%; }
-}
-@media screen and (min-width:64em){
-    .button-resize { font-size: 100%; }
-}
-@media screen and (min-width:80em){
-    .button-resize { font-size: 110%; }
-}
-</style>
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="pure-button-group" role="nav" style="padding: 1em 0 0;">
-      <button class="button-resize pure-button" type="submit" formaction="trend" > Trend </button>
-      <button class="button-resize pure-button" type="submit" formaction="products" disabled="true"> Products </button>
-      <button class="button-resize pure-button" type="submit" formaction="categories" > Categories </button>
-      <button class="button-resize pure-button" type="submit" formaction="groups" > Groups </button>
-      <button class="button-resize pure-button" type="submit" formaction="tags" > Tags </button>
-    </div>
-  </div>
-</div>
-<div class="pure-g">
-  <div class="pure-u-1">
-    <div class="pure-button-group" role="action" style="padding: 1em 0 0;">
-    <button class="button-resize pure-button" type="submit"> Apply </button>
-    <button form="clear" class="button-resize pure-button" type="submit"> Clear </button>
-    <button form="reload" class="button-resize pure-button" type="submit"> Reload </button>
-    </div>
-  </div>
-</div>
-  <div style="width: 0; height: 1em">
-<input type="text" name="product" value="!Chicken Stir Fry" hidden="true"/>
-<input type="text" name="category" value="!Beef" hidden="true"/>
-<input type="text" name="group" value="Fish, Meat, Eggs" hidden="true"/>
-<input type="text" name="tag" value="" hidden="true"/>
-<input type="text" name="unit" value="kg" hidden="true"/>
-  </div>
-</form>
-<form id="clear" method="get" action="products">
-  <div style="width: 0; height: 0">
-<input type="text" name="product" value="" hidden="true"/>
-<input type="text" name="category" value="" hidden="true"/>
-<input type="text" name="group" value="" hidden="true"/>
-<input type="text" name="tag" value="" hidden="true"/>
-<input type="text" name="unit" value="" hidden="true"/>
-  </div>
-</form>
-<form id="reload" method="get" action="products">
-  <div style="width: 0; height: 0">
-<input type="text" name="product" value="!Chicken Stir Fry" hidden="true"/>
-<input type="text" name="category" value="!Beef" hidden="true"/>
-<input type="text" name="group" value="Fish, Meat, Eggs" hidden="true"/>
-<input type="text" name="tag" value="" hidden="true"/>
-<input type="text" name="unit" value="kg" hidden="true"/>
-<input type="text" name="reload" value="true" hidden="true"/>
-  </div>
-</form>""", {
-    "method": "get", "action": "products", "params": [
-        {'name': 'product', 'value': '!Chicken Stir Fry'},
-        {'name': 'category', 'value': '!Beef'},
-        {'name': 'group', 'value': 'Fish, Meat, Eggs'},
-        {'name': 'tag'},
-        {'name': 'unit', 'value': 'kg'},
-    ]})
-])
-def test_form_nav_render_exact(expected, params):
-    assert template('form-nav', **params) == expected