]>
apps.delaporte.us Git - matrix-shop-bot.git/blob - matrix_shop_bot/db.py
cd8d494cacfe16832b5cceee08c82d0f7c979f2c
3 from datetime
import datetime
as dt
4 from datetime
import timedelta
5 from decimal
import Decimal
6 from typing
import List
8 from sqlmodel
import Field
, Session
, SQLModel
, create_engine
, select
, text
, Relationship
, func
10 from sqlite3
import IntegrityError
14 DATE_FORMAT
= '%Y-%m-%d'
17 return dt
.now().strftime(DATE_FORMAT
)
20 logger
= logging
.getLogger(__name__
)
22 class ItemStoreLink(SQLModel
, table
=True):
23 item_id
: int |
None = Field(default
=None, foreign_key
="item.id", primary_key
=True)
24 store_id
: int |
None = Field(default
=None, foreign_key
="store.id", primary_key
=True)
27 class Item(SQLModel
, table
=True):
28 '''An item to shop for.'''
29 id: int |
None = Field(default
=None, primary_key
=True)
30 name
: str = Field(default
=None, unique
=True)
31 price
: Decimal |
None = Field(decimal_places
=2)
32 stores
: list["Store"] = Relationship(back_populates
="shopping_list", link_model
=ItemStoreLink
)
33 purchase_date
: dt |
None = Field(default
=None)
35 if(self
.purchase_date
):
36 return f
'- [x] {self.name} (#{self.id})'
38 return f
'- [ ] {self.name} (#{self.id})'
40 class ItemZone(SQLModel
, table
=True):
41 '''Where an item is usually found.
43 Matched only by loose text matching, since items come and go, over time.
45 id: int |
None = Field(default
=None, primary_key
=True)
46 name
: str = Field(default
=None, unique
=True) # milk
47 zone
: str = Field(default
=None, unique
=False) # dairy section
49 class PreferStore(SQLModel
, table
=True):
50 '''Tracks which store we are shopping at, at the moment.'''
51 store_id
: int |
None = Field(default
=None, foreign_key
="store.id", primary_key
=True)
53 def _get_item_zone(item_name
, session
) -> str:
54 zone
= session
.exec(select(ItemZone
).where(func
.lower(item_name
).contains(func
.lower(ItemZone
.name
)))).first()
57 return 'No Zone Assigned'
59 def _get_pretty_shopping_list(store_id
, session
) -> List
[str]:
60 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
62 results
.append(f
' ## {store.name}')
63 items
= store
.shopping_list
65 results
.append(f
'-- Nothing to shop for at {store.name} --')
70 zone
= str(_get_item_zone(item
.name
, session
)).title() # Title Case
73 zones
[zone
].append(str(item
).title())
76 results
.append(f
' ### {zone}')
77 for item
in zones
[zone
]:
81 class Store(SQLModel
, table
=True):
82 '''A place to shop at.'''
83 id: int |
None = Field(default
=None, primary_key
=True)
84 name
: str = Field(default
=None, unique
=True)
85 shopping_list
: list["Item"] = Relationship(back_populates
="stores", link_model
=ItemStoreLink
)
86 # purchase_history: list["PurchaseRecord"] = Relationship(back_populates="store")
88 return f
'{self.name} (#{self.id})'
91 class PurchaseRecord(SQLModel
, table
=True):
92 '''Tracks when and where items were purchased.'''
93 id: int |
None = Field(default
=None, primary_key
=True)
94 name
: str = Field(default
=None)
95 price
: Decimal |
None = Field(decimal_places
=2)
96 store
: str |
None = Field(default
=None)
97 purchase_date
: dt |
None = Field(default
=None)
99 date
= self
.purchase_date
.strftime(DATE_FORMAT
)
100 return f
'Bought {self.name} at {self.store} on {date}'
102 def _get_with_id(cls
, item_id
):
103 return select(cls
).where(cls
.id == item_id
)
105 def _get_with_name(cls
, name
):
106 return select(cls
).where(func
.lower(cls
.name
) == func
.lower(name
))
110 def __init__(self
, db_file
=None):
112 db_file
= os
.path
.join(os
.environ
.get('MATRIX_DATA_DIR', ''), f
'{MODULE}.db')
115 logger
.debug(f
'Shop bot is using db file {self.file}')
116 self
.engine
= self
.get_engine()
117 self
.last_store_id
= None
119 def _set_pref_store_id(self
, store_id
):
120 with Session(self
.engine
) as session
:
121 prefs
= session
.exec(select(PreferStore
)).all()
125 pref
.store_id
= store_id
129 def _get_pref_store_id(self
):
130 with Session(self
.engine
) as session
:
131 pref
= session
.exec(select(PreferStore
)).one()
134 def _add_with_name(self
, cls
, name
):
137 with Session(self
.engine
) as session
:
144 # recover by returning the item_id
145 item
= session
.exec(select(Item
).where(Item
.name
== name
)).first()
150 def add_store(self
, name
):
151 store_id
= self
._add
_with
_name
(Store
, name
)
152 self
._set
_pref
_store
_id
(store_id
)
155 def add_item(self
, name
):
156 names
= name
.split(',')
158 item_id
= self
._add
_with
_name
(Item
, name
.strip())
159 store_id
= self
._get
_pref
_store
_id
()
160 self
.assign_store(item_id
, store_id
)
163 def assign_last_store(self
, item_id
):
164 store_id
= self
._get
_pref
_store
_id
()
165 self
.assign_store(item_id
, store_id
)
168 def assign_zone(self
, item_name
, zone_name
):
170 with Session(self
.engine
) as session
:
172 new
= ItemZone(name
=item_name
)
178 pass # Already assigned
181 def assign_store(self
, item_id
, store_id
):
182 self
._set
_pref
_store
_id
(store_id
)
184 with Session(self
.engine
) as session
:
185 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
186 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
188 item
.stores
.append(store
)
191 pass # Already assigned
194 def list_purchases(self
, store_name
):
195 '''List unique purchases at store in the past 90 days'''
196 with Session(self
.engine
) as session
:
197 recent
= dt
.now() - timedelta(days
=90)
198 cmd
= select(PurchaseRecord
).where(func
.lower(PurchaseRecord
.store
) == func
.lower(store_name
)).where(PurchaseRecord
.purchase_date
> recent
)
199 records
= session
.exec(cmd
).all()
202 bought
.add(item
.name
)
205 def purchase_items(self
, item_ids
):
207 with Session(self
.engine
) as session
:
208 # Mark items as purchased
209 for item_id
in item_ids
.split(' '):
210 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
212 item
.purchase_date
= dt
.today()
214 # Track purchase history (locally only!)
215 # to generate recommended shopping lists
216 record
= PurchaseRecord()
217 record
.name
= item
.name
218 record
.price
= item
.price
219 record
.purchase_date
= item
.purchase_date
221 shopping_at
= self
._get
_pref
_store
_id
() # We bought it here
222 cmd
= select(Store
).where(Store
.id == shopping_at
)
223 store
= session
.exec(cmd
).one()
224 record
.store
= store
.name
227 results
.append(str(record
))
228 # results.append(str(item))
234 def clear_list(self
, store_name
, clear_all
=False):
236 with Session(self
.engine
) as session
:
237 store
= session
.exec(_get_with_name(Store
, store_name
)).first()
239 return [f
'Store {store_name} not found.']
241 for item
in store
.shopping_list
:
242 if clear_all
or item
.purchase_date
:
246 results
= _get_pretty_shopping_list(store
.id, session
)
249 def get_stores(self
):
250 with Session(self
.engine
) as session
:
252 return session
.exec(cmd
).all()
254 def get_unsorted_list(self
):
257 with Session(self
.engine
) as session
:
258 cmd
= select(Item
).where(Item
.stores
== None)
259 items
= session
.exec(cmd
).all()
262 return [f
'No unsorted items!']
265 def get_last_shopping_list(self
) -> List
[str]:
266 store_id
= self
._get
_pref
_store
_id
()
267 with Session(self
.engine
) as session
:
268 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
270 return _get_pretty_shopping_list(store
.id, session
)
271 return [f
'Last shopping list store not found.']
273 def get_shopping_list(self
, store_name
) -> List
[str]:
275 with Session(self
.engine
) as session
:
276 store
= session
.exec(_get_with_name(Store
, store_name
)).first()
278 return [f
'Store {store_name} not found.']
279 results
= _get_pretty_shopping_list(store
.id, session
)
280 self
._set
_pref
_store
_id
(store
.id)
283 def remove_item(self
, item_id
):
284 with Session(self
.engine
) as session
:
285 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
289 def remove_store(self
, store_id
):
290 with Session(self
.engine
) as session
:
291 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
292 session
.delete(store
)
295 def get_engine(self
):
296 return create_engine(f
"sqlite:///{self.file}")
299 SQLModel
.metadata
.create_all(self
.engine
)
300 with Session(self
.engine
) as session
:
301 # Add some obvious store zones by default
303 'Bakery': ['bread', 'cake', 'cookie', 'muffin', 'pie'],
304 'Baking': ['flour', 'sugar'],
306 'Clothes': ['bra', 'coat', 'jacket', 'panties', 'pants', 'shirt', 'shorts', 'socks', 'sweater', 'underwear'],
307 'Cooler': ['butter', 'cream', 'eggs', 'juice', 'milk', 'tea'],
308 'Deli': ['beef', 'chicken', 'salmon', 'steak', 'turkey'],
309 'Movies': ['movie', 'DVD'],
310 'Music': ['cd', 'album'],
311 'Produce': ['carrot', 'cucumber', 'greens', 'lettuce'],
313 'Television': ['tv', 'season'],
315 for zone
in DEFAULT_ZONES
:
316 logging
.debug(f
'Adding default items for {zone}')
317 for item
in DEFAULT_ZONES
[zone
]:
319 logging
.debug(f
'Attempt adding {item} to {zone}...')
320 new
= ItemZone(name
=item
, zone
=zone
)
323 logging
.debug(f
'Success adding {item} to {zone}...')
324 except Exception as ex
:
326 logging
.debug(f
'Failed adding {item} to {zone}: {ex}')