PriceCheck.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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 typing import Callable, Union
  10. from urwid import (
  11. connect_signal,
  12. AttrMap,
  13. Button,
  14. Columns,
  15. Divider,
  16. Filler,
  17. LineBox,
  18. Padding,
  19. Pile,
  20. RadioButton,
  21. Text,
  22. )
  23. from .. import COPYRIGHT
  24. from ..widgets import (
  25. AutoCompleteEdit,
  26. AutoCompleteFloatEdit,
  27. FocusWidget,
  28. AutoCompletePopUp,
  29. NoTabCheckBox
  30. )
  31. from ..db_utils import QueryManager
  32. from . import ActivityManager, show_or_exit
  33. from .Rating import Rating
  34. class PriceCheck(FocusWidget):
  35. def keypress(self, size, key):
  36. if isinstance(key, tuple):
  37. return
  38. if getattr(self.original_widget.original_widget, 'original_widget', None) is None:
  39. return super().keypress(size, key)
  40. if key == 'tab':
  41. self.advance_focus()
  42. elif key == 'shift tab':
  43. self.advance_focus(reverse=True)
  44. else:
  45. return super().keypress(size, key)
  46. def apply_choice(self, name, value):
  47. self.apply_changes(name, value)
  48. for k,v in self.data.items():
  49. if k == name or v:
  50. continue
  51. options = self.query_manager.unique_suggestions(k, **self.data)
  52. if len(options) == 1:
  53. self.apply_changes(k, list(options)[0])
  54. def apply_changes(self, name, value):
  55. self.data = {
  56. name: value if name != 'organic' else {
  57. 'yes': True, 'no': False,
  58. True: True, False: False,
  59. 'mixed': '',
  60. }[value],
  61. }
  62. @property
  63. def data(self):
  64. ret = dict(itertools.chain(
  65. [(k, v.get_edit_text()) for k,v in self.edit_fields.items()],
  66. [(k, v.state) for k,v in self.checkboxes.items()]
  67. ))
  68. return ret
  69. @data.setter
  70. def data(self, _data: dict):
  71. for k,v in _data.items():
  72. if k in self.edit_fields and v != self.edit_fields[k].get_edit_text():
  73. self.edit_fields[k].set_edit_text(v)
  74. if k in self.checkboxes and v != self.checkboxes[k].state:
  75. self.checkboxes[k].set_state(v)
  76. def clear(self):
  77. for (_, ef) in self.edit_fields.items():
  78. ef.set_edit_text('')
  79. for (_, cb) in self.checkboxes.items():
  80. cb.set_state('mixed')
  81. for (_, tf) in self.text_fields.items():
  82. tf.set_text('')
  83. self.update()
  84. return self
  85. def update(self):
  86. self.update_historic_prices(self.data)
  87. return self
  88. def update_historic_prices(self, data):
  89. organic = None if data['organic'] == 'mixed' else data['organic']
  90. sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
  91. product, unit = data['product'] or None, data['unit'] or None
  92. try:
  93. price = Decimal(data['price'])
  94. except InvalidOperation:
  95. price = None
  96. try:
  97. quantity = Decimal(data['quantity'])
  98. except InvalidOperation:
  99. quantity = None
  100. if None in (sort, product, unit):
  101. self.text_fields['dbview'].set_text('')
  102. self.rating.update_rating(None, None, None, unit)
  103. return
  104. df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=organic)
  105. if df.empty:
  106. self.text_fields['dbview'].set_text('')
  107. self.rating.update_rating(None, None, None, unit)
  108. return
  109. assert len(df['avg'].unique()) == 1, f"There should be only one average price: {df['avg'].unique()}"
  110. _avg, _min, _max = [
  111. float(x) for x in df[['avg','min','max']].iloc[0]
  112. ]
  113. self.rating.update_rating(_avg, _min, _max, unit, price=price, quantity=quantity),
  114. self.text_fields['dbview'].set_text(
  115. self.rating.get_historic_prices(df)
  116. )
  117. def __init__(self,
  118. activity_manager: ActivityManager,
  119. query_manager: QueryManager,
  120. autocomplete_cb: Callable[[
  121. Union[AutoCompleteEdit, AutoCompleteFloatEdit], str, dict
  122. ], None],
  123. ):
  124. button_group = []
  125. self.buttons = {
  126. 'clear': Button(('streak', 'Clear')),
  127. 'exit': Button(('streak', 'Exit')),
  128. 'sort_price': RadioButton(button_group, ('streak', 'Best'), state="first True"),
  129. 'sort_date': RadioButton(button_group, ('streak', 'Last'), state="first True"),
  130. }
  131. self.edit_fields = {
  132. 'product': AutoCompleteEdit(('bg', 'product')),
  133. 'unit': AutoCompleteEdit(('bg', 'unit')),
  134. 'quantity': AutoCompleteFloatEdit(('bg', 'quantity')),
  135. 'price': AutoCompleteFloatEdit(('bg', 'price')),
  136. }
  137. self.checkboxes = {
  138. 'organic': NoTabCheckBox(('bg', "Organic"), state='mixed'),
  139. }
  140. self.text_fields = dict((
  141. (k, Text('')) for k in ('dbview', 'spread', 'rating', 'marker')
  142. ))
  143. self.rating = Rating(dict(filter(
  144. lambda x: x[0] in ('spread','rating','marker'),
  145. self.text_fields.items()
  146. )))
  147. top_pane = [ 'clear', 'exit', ['sort_price', 'sort_date'], ]
  148. left_pane = [
  149. 'product',
  150. 'organic',
  151. ]
  152. badge = [
  153. 'rating',
  154. 'spread',
  155. 'marker',
  156. ]
  157. right_pane = [
  158. 'unit',
  159. 'quantity',
  160. 'price',
  161. ]
  162. bottom_pane = [ 'dbview', ]
  163. self.query_manager = query_manager
  164. self.organic_checkbox = self.checkboxes['organic']
  165. connect_signal(self.organic_checkbox, 'postchange', lambda _,v: self.update())
  166. for (k, ef) in self.edit_fields.items():
  167. connect_signal(ef, 'postchange', lambda _,v: self.update())
  168. connect_signal(ef, 'apply', lambda w, name: autocomplete_cb(w, name, self.data))
  169. for b in button_group:
  170. connect_signal(b, 'postchange',lambda *_: self.update())
  171. connect_signal(self.buttons['clear'], 'click', lambda x: self.clear().update())
  172. connect_signal(self.buttons['exit'], 'click', lambda x: show_or_exit('esc'))
  173. self.clear()
  174. header = Text(u'Price Check', 'center')
  175. _copyright = Text(COPYRIGHT, 'center')
  176. banner = Pile([
  177. Padding(header, 'center', width=('relative', 100)),
  178. Padding(_copyright, 'center', width=('relative', 100)),
  179. ])
  180. banner = AttrMap(banner, 'banner')
  181. _widgets = dict(itertools.chain(*[
  182. [(k, v) for k,v in x] for x in map(lambda x: x.items(), [
  183. self.edit_fields, self.text_fields, self.checkboxes
  184. ])
  185. ]))
  186. _widgets.update([
  187. (k, LineBox(AttrMap(
  188. AutoCompletePopUp(
  189. self.edit_fields[k],
  190. self.apply_choice,
  191. lambda: activity_manager.show(self.update())
  192. ), 'streak'), title=k.title(), title_align='left')
  193. ) for k in self.edit_fields
  194. ])
  195. _widgets.update({
  196. 'dbview': LineBox(
  197. AttrMap(self.text_fields['dbview'], 'streak'),
  198. title="Historic Prices",
  199. title_align='center',
  200. ),
  201. })
  202. components = {
  203. 'top_pane': Columns([
  204. (9, Pile([
  205. Divider(),
  206. AttrMap(self.buttons['clear'], 'streak'),
  207. Divider(),
  208. ])),
  209. LineBox(
  210. Columns([ v for k,v in self.buttons.items() if 'sort' in k]),
  211. title="Sort price by",
  212. title_align='left',
  213. ),
  214. (9, Pile([
  215. Divider(),
  216. AttrMap(self.buttons['exit'], 'streak'),
  217. Divider(),
  218. ]))
  219. ], dividechars=1),
  220. 'right_pane': (16, Pile(map(
  221. lambda x: _widgets[x] if x is not None else Divider,
  222. right_pane
  223. ))),
  224. 'left_pane': Pile(map(
  225. lambda x: _widgets[x] if x is not None else Divider,
  226. left_pane
  227. )),
  228. 'badge': Pile(map(
  229. lambda x: _widgets[x] if x is not None else Divider,
  230. badge
  231. )),
  232. 'bottom_pane': _widgets['dbview'],
  233. }
  234. components.update({
  235. 'left_pane': Pile([
  236. components['left_pane'],
  237. LineBox(
  238. AttrMap(components['badge'], 'badge'),
  239. title="Current Price", title_align='left',
  240. )
  241. ])})
  242. widget = Pile([
  243. banner,
  244. Divider(),
  245. components['top_pane'],
  246. Columns((components['left_pane'], components['right_pane']),
  247. dividechars=0,
  248. ),
  249. components['bottom_pane'],
  250. ])
  251. widget = Filler(widget, 'top')
  252. widget = AttrMap(widget, 'bg')
  253. widget.original_widget.original_widget.set_focus_path([3,0,0])
  254. super().__init__(widget, 4, [3,0,0,0], [
  255. [0, 0,], [0,1,],
  256. [1,],
  257. [2,],
  258. [2,0,],
  259. [2,0,0,], [2,0,2], [2,2,0], [2,2,2],
  260. [3,0,], [3,0,0,],
  261. [3,0,1,0,], [3,0,1,1,], [3,0,1,2,],
  262. [3,1,3,],
  263. [4,],
  264. ]
  265. )