TransactionEditor.py 12 KB

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