TransactionEditor.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. #
  2. # Copyright (c) Daniel Sheffield 2021 - 2023
  3. #
  4. # All rights reserved
  5. #
  6. # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
  7. import itertools
  8. from decimal import Decimal, InvalidOperation
  9. from itertools import chain
  10. from typing import Callable, Union
  11. from urwid import (
  12. connect_signal,
  13. AttrMap,
  14. Button,
  15. Columns,
  16. Divider,
  17. Filler,
  18. LineBox,
  19. Padding,
  20. Pile,
  21. Text,
  22. )
  23. from .. import COPYRIGHT
  24. from ..db_utils import QueryManager
  25. from ..widgets import (
  26. AutoCompleteEdit,
  27. AutoCompleteFloatEdit,
  28. FocusWidget,
  29. AutoCompletePopUp,
  30. NoTabCheckBox,
  31. FlowBarGraphWithVScale,
  32. )
  33. from . import ActivityManager
  34. from .Rating import Rating
  35. from .NewProduct import NewProduct
  36. class TransactionEditor(FocusWidget):
  37. def keypress(self, size, key):
  38. if isinstance(key, tuple):
  39. return
  40. if getattr(self._w.original_widget, 'original_widget', None) is None:
  41. return super().keypress(size, key)
  42. if key == 'tab':
  43. self.advance_focus()
  44. elif key == 'shift tab':
  45. self.advance_focus(reverse=True)
  46. elif key == 'ctrl delete':
  47. self.clear()
  48. self.focus_on(self.edit_fields['product'])
  49. elif key == 'insert':
  50. self.save_and_clear_cb()
  51. elif key == 'ctrl p':
  52. self.focus_on(self.edit_fields['product'])
  53. else:
  54. return super().keypress(size, key)
  55. def apply_choice(self, name, value):
  56. self.apply_changes(name, value)
  57. for k,v in self.data.items():
  58. if k == name or v:
  59. continue
  60. options = self.query_manager.unique_suggestions(k, **self.data)
  61. if len(options) == 1 and k != 'ts':
  62. self.apply_changes(k, list(options)[0])
  63. def apply_changes(self, name, value):
  64. self.data = {
  65. name: value if name != 'organic' else {
  66. 'yes': True, 'no': False,
  67. True: True, False: False,
  68. 'mixed': '',
  69. }[value],
  70. }
  71. @property
  72. def data(self):
  73. ret = dict(itertools.chain(
  74. [(k, v.get_edit_text()) for k,v in self.edit_fields.items()],
  75. [(k, v.state) for k,v in self.checkboxes.items()]
  76. ))
  77. return ret
  78. @data.setter
  79. def data(self, _data: dict):
  80. for k,v in _data.items():
  81. if k in self.edit_fields and v != self.edit_fields[k].get_edit_text():
  82. self.edit_fields[k].set_edit_text(v)
  83. if k in self.checkboxes and v != self.checkboxes[k].state:
  84. self.checkboxes[k].set_state(v)
  85. def clear(self):
  86. for (k, ef) in self.edit_fields.items():
  87. if k in ('ts', 'store',):
  88. continue
  89. ef.set_edit_text('')
  90. for (_, cb) in self.checkboxes.items():
  91. cb.set_state('mixed')
  92. for (k, tf) in self.text_fields.items():
  93. if k != 'dbview':
  94. tf.set_text('')
  95. self.graph.set_data([],0)
  96. return self.update()
  97. def update(self):
  98. data = self.data
  99. date, store = data['ts'], data['store']
  100. self.text_fields['dbview'].set_text(
  101. self.query_manager.get_session_transactions(date, store) if None not in (
  102. date or None, store or None
  103. ) else ''
  104. )
  105. self.update_historic_prices(data)
  106. return self
  107. def update_graph(self, df):
  108. # after truncating, need to recalculate avg(median), min, max
  109. df = df.sort_values(
  110. 'ts_raw', ascending=True, ignore_index=True
  111. ).truncate(
  112. before=max(0, len(df.index)-self.graph._canvas_width)
  113. )
  114. data = df[['$/unit','quantity']].apply(
  115. lambda x: (float(x['$/unit']), float(x['quantity'])),
  116. axis=1, result_type='broadcast'
  117. )
  118. data['avg'] = (data['$/unit']*data['quantity']).sum()/data['quantity'].sum()
  119. data_max = data.max()['$/unit'] #.max()
  120. assert len(data['avg'].unique()) == 1
  121. norm = [ (x,) for x in data['$/unit'] ] #.to_records(index=False)
  122. self.graph.set_data(norm, data_max,
  123. vscale=[x for x in map(float, [
  124. data['$/unit'].min(),
  125. data['$/unit'].median(),
  126. data['avg'].iloc[0],
  127. data_max
  128. ])]
  129. )
  130. #self.graph.set_bar_width(1)
  131. # canvas_width = 10 + pad + pad + 10
  132. date_strlen = (self.graph.canvas_width - 20)
  133. ex = "─" if date_strlen % 2 else ""
  134. plen = date_strlen//2
  135. caption = f"{df['ts_raw'].min():%d/%m/%Y}"
  136. caption += f"{{p:>{plen}}}{ex}{{p:<{plen}}}".format(
  137. p="─")
  138. caption += f"{df['ts_raw'].max():%d/%m/%Y}"
  139. self.graph.set_caption(caption)
  140. def update_historic_prices(self, data):
  141. organic = None if data['organic'] == 'mixed' else data['organic']
  142. #sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
  143. product, unit = data['product'] or None, data['unit'] or None
  144. try:
  145. price = Decimal(data['price'])
  146. except InvalidOperation:
  147. price = None
  148. try:
  149. quantity = Decimal(data['quantity'])
  150. except InvalidOperation:
  151. quantity = None
  152. if None in (product, unit):
  153. self.rating.update_rating(None, None, None, unit)
  154. return
  155. df = self.query_manager.get_historic_prices_data(unit, product=product, organic=organic, sort='ts')
  156. if df.empty:
  157. self.rating.update_rating(None, None, None, unit)
  158. return
  159. assert len(df['avg'].unique()) == 1, f"There should be only one average price: {df['avg'].unique()}"
  160. # all time (or all data) avg(mean), min, max
  161. _avg, _min, _max = [
  162. float(x) for x in df[['avg','min','max']].iloc[0]
  163. ]
  164. self.rating.update_rating(_avg, _min, _max, unit, price=price, quantity=quantity)
  165. self.update_graph(df)
  166. def autocomplete_callback(self, widget, name, options):
  167. if len(options):
  168. widget._emit('open', options)
  169. return
  170. new_product_activity = self.activity_manager.get('new_product')
  171. current_activity = self.activity_manager.current()
  172. if current_activity is not new_product_activity and name in (
  173. 'product', 'category', 'group'
  174. ):
  175. self.new_product_callback(name)
  176. return
  177. def new_product_callback(self, name):
  178. cur = self.activity_manager.current()
  179. txn = self.activity_manager.get('transaction')
  180. new_product = self.activity_manager.create(
  181. NewProduct, 'new_product',
  182. self.activity_manager, self.query_manager,
  183. cur, name, txn.data,
  184. txn.apply_changes,
  185. )
  186. self.activity_manager.show(new_product)
  187. def save_and_clear_callback(self):
  188. txn = self.activity_manager.get('transaction')
  189. self.activity_manager.app.save(txn.data)
  190. txn.clear()
  191. txn.focus_on(txn.edit_fields['product'])
  192. self.activity_manager.show(txn.update())
  193. def __init__(self,
  194. activity_manager: ActivityManager,
  195. query_manager: QueryManager,
  196. ):
  197. self.activity_manager = activity_manager
  198. self.query_manager = query_manager
  199. self.buttons = {
  200. 'done': Button(('streak', u'Done')),
  201. 'clear': Button(('streak', u'Clear')),
  202. }
  203. self.edit_fields = {
  204. 'ts': AutoCompleteEdit(('bg', 'ts')),
  205. 'store': AutoCompleteEdit(('bg', 'store')),
  206. 'product': AutoCompleteEdit(('bg', 'product')),
  207. 'category': AutoCompleteEdit(('bg', 'category')),
  208. 'group': AutoCompleteEdit(('bg', 'group')),
  209. 'description': AutoCompleteEdit(('bg', 'description')),
  210. 'unit': AutoCompleteEdit(('bg', 'unit')),
  211. 'quantity': AutoCompleteFloatEdit(('bg', 'quantity')),
  212. 'price': AutoCompleteFloatEdit(('bg', 'price')),
  213. }
  214. self.checkboxes = {
  215. 'organic': NoTabCheckBox(('bg', "Organic"), state='mixed'),
  216. }
  217. self.text_fields = {
  218. 'dbview': Text(''),
  219. 'rating': Text(''),
  220. 'spread': Text(''),
  221. 'marker': Text(''),
  222. }
  223. self.graph = FlowBarGraphWithVScale(
  224. 50, 14,
  225. ['bg','popup_focus', 'badge_neutral' ],
  226. hatt=['dark red', 'dark red', 'dark red']
  227. )
  228. self.rating = Rating(dict(filter(
  229. lambda x: x[0] in ('spread','rating','marker'),
  230. self.text_fields.items()
  231. )))
  232. self.organic_checkbox = self.checkboxes['organic']
  233. connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
  234. layout = [
  235. [ 'ts', 'store', ],
  236. [ 'organic', 'product', ],
  237. [ 'category', 'group', ],
  238. ]
  239. side_pane = [
  240. 'unit',
  241. 'quantity',
  242. 'price',
  243. ]
  244. bottom_pane = [
  245. 'description',
  246. 'dbview',
  247. ]
  248. badge = [
  249. 'rating',
  250. 'spread',
  251. 'marker',
  252. ]
  253. self.clear()
  254. _widgets = dict(itertools.chain(*[
  255. [(k, v) for k,v in x] for x in map(lambda x: x.items(), [
  256. self.edit_fields, self.text_fields, self.checkboxes
  257. ])
  258. ]))
  259. _widgets.update({
  260. 'dbview': LineBox(
  261. AttrMap(self.text_fields['dbview'], 'streak'),
  262. title="Session Data",
  263. title_align='left',
  264. )
  265. })
  266. for (k, ef) in self.edit_fields.items():
  267. connect_signal(ef, 'postchange', lambda *_: self.update())
  268. connect_signal(ef, 'apply', lambda w, name: self.autocomplete_callback(
  269. w, name, query_manager.unique_suggestions(name, **self.data)
  270. ))
  271. _widgets.update(dict([
  272. (k, LineBox(
  273. AttrMap(AutoCompletePopUp(
  274. self.edit_fields[k],
  275. self.apply_choice,
  276. lambda: activity_manager.show(self.update())
  277. ), 'streak'), title=k.title(), title_align='left')
  278. ) for k in self.edit_fields if k != 'product'
  279. ]))
  280. header = Text(u'Fill Transaction', 'center')
  281. _copyright = Text(COPYRIGHT, 'center')
  282. components = {
  283. 'bottom_button_bar': Columns(
  284. [(8, self.buttons['done']), Divider(), (9, self.buttons['clear'])]
  285. ),
  286. 'badge': Pile(map(
  287. lambda x: _widgets[x] if x is not None else Divider,
  288. badge
  289. )),
  290. }
  291. components.update({
  292. 'bottom_pane': Columns([
  293. Pile(map(
  294. lambda x: _widgets[x] if x is not None else Divider(),
  295. bottom_pane
  296. )),
  297. (self.graph.total_width+2, Pile([
  298. LineBox(
  299. AttrMap(components['badge'], 'badge'),
  300. title="Current Price", title_align='left',
  301. ),
  302. LineBox(
  303. self.graph,
  304. title="Historic Price", title_align='left'
  305. ),
  306. ])),
  307. ])
  308. })
  309. connect_signal(self.buttons['done'], 'click', lambda _: self.save_and_clear_callback())
  310. connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
  311. banner = Pile([
  312. Padding(header, 'center', width=('relative', 100)),
  313. Padding(_copyright, 'center', width=('relative', 100)),
  314. ])
  315. banner = AttrMap(banner, 'banner')
  316. _widgets.update({
  317. 'product': LineBox(Columns([
  318. AttrMap(AutoCompletePopUp(
  319. self.edit_fields['product'],
  320. self.apply_choice,
  321. lambda: activity_manager.show(self.update())
  322. ), 'streak'),
  323. self.organic_checkbox
  324. ], dividechars=2), title='Product', title_align='left')
  325. })
  326. components['side_pane'] = (12, Pile([
  327. _widgets[r] if r is not None else Divider() for r in side_pane
  328. ]))
  329. components['main_pane'] = []
  330. for _, r in enumerate(layout):
  331. col = []
  332. for c in r:
  333. if c is not None:
  334. if c == 'organic':
  335. continue
  336. col.append(_widgets[c])
  337. else:
  338. col.append(Divider())
  339. components['main_pane'].append(Columns(col))
  340. components['main_pane'] = Pile(components['main_pane'])
  341. widget = Pile([
  342. banner,
  343. Divider(),
  344. Columns([
  345. components['main_pane'], components['side_pane']
  346. ], dividechars=2),
  347. components['bottom_pane'],
  348. Divider(),
  349. components['bottom_button_bar']
  350. ])
  351. widget = Filler(widget, 'top')
  352. widget = AttrMap(widget, 'bg')
  353. super().__init__(widget, map(
  354. lambda x: next(w[1] for w in chain(
  355. self.buttons.items(),
  356. self.edit_fields.items(),
  357. self.checkboxes.items()
  358. ) if x == w[0]),
  359. [
  360. 'product', 'organic',
  361. 'unit', 'quantity', 'price',
  362. 'description',
  363. 'done', 'clear',
  364. 'category', 'group',
  365. 'ts', 'store'
  366. ]))