|
@@ -3,34 +3,24 @@
|
|
|
# All rights reserved
|
|
|
#
|
|
|
# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
|
|
|
-from typing import Iterable, Dict
|
|
|
import os
|
|
|
-from urllib.parse import urlencode
|
|
|
+from threading import Thread
|
|
|
+from typing import Tuple
|
|
|
from bottle import (
|
|
|
- route,
|
|
|
- request,
|
|
|
- response,
|
|
|
- FormsDict,
|
|
|
- redirect,
|
|
|
- template,
|
|
|
+ route, request, response,
|
|
|
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 .Cache import Cache
|
|
|
+from .PageCache import PageCache
|
|
|
+from . import trend as worker
|
|
|
|
|
|
host = f"host={os.getenv('HOST')}"
|
|
|
db = f"dbname={os.getenv('DB', 'grocery')}"
|
|
@@ -40,194 +30,96 @@ if not password.split('=',1)[1]:
|
|
|
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>')
|
|
|
def send_static(filename):
|
|
|
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
|
|
|
- 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'
|
|
|
- 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'
|
|
|
- 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'
|
|
|
- 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)
|