]>
apps.delaporte.us Git - matrix-shop-bot.git/blob - matrix_shop_bot/db.py
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
))
108 def _rename(cls
, session
, old
, new
) -> bool:
110 record
= session
.exec(_get_with_name(cls
, old
)).first()
116 def __init__(self
, db_file
=None):
118 db_file
= os
.path
.join(os
.environ
.get('MATRIX_DATA_DIR', ''), f
'{MODULE}.db')
121 logger
.debug(f
'Shop bot is using db file {self.file}')
122 self
.engine
= self
.get_engine()
123 self
.last_store_id
= None
125 def rename_store(self
, old
, new
):
126 with Session(self
.engine
) as session
:
127 return _rename(Store
, session
, old
, new
)
129 def rename_item(self
, old
, new
):
130 with Session(self
.engine
) as session
:
131 return _rename(Item
, session
, old
, new
)
133 def _set_pref_store_id(self
, store_id
):
134 with Session(self
.engine
) as session
:
135 prefs
= session
.exec(select(PreferStore
)).all()
139 pref
.store_id
= store_id
143 def _get_pref_store_id(self
):
144 with Session(self
.engine
) as session
:
145 pref
= session
.exec(select(PreferStore
)).one()
148 def _add_with_name(self
, cls
, name
):
151 with Session(self
.engine
) as session
:
158 # recover by returning the item_id
159 item
= session
.exec(select(Item
).where(Item
.name
== name
)).first()
164 def add_store(self
, name
):
165 store_id
= self
._add
_with
_name
(Store
, name
)
166 self
._set
_pref
_store
_id
(store_id
)
169 def add_item(self
, name
):
170 names
= name
.split(',')
172 item_id
= self
._add
_with
_name
(Item
, name
.strip())
173 store_id
= self
._get
_pref
_store
_id
()
174 self
.assign_store(item_id
, store_id
)
177 def assign_last_store(self
, item_id
):
178 store_id
= self
._get
_pref
_store
_id
()
179 self
.assign_store(item_id
, store_id
)
182 def assign_zone(self
, item_name
, zone_name
):
184 with Session(self
.engine
) as session
:
186 new
= ItemZone(name
=item_name
)
192 pass # Already assigned
195 def assign_store(self
, item_id
, store_id
):
196 self
._set
_pref
_store
_id
(store_id
)
198 with Session(self
.engine
) as session
:
199 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
200 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
202 item
.stores
.append(store
)
205 pass # Already assigned
208 def list_purchases(self
, store_name
):
209 '''List unique purchases at store in the past 90 days'''
210 with Session(self
.engine
) as session
:
211 recent
= dt
.now() - timedelta(days
=90)
212 cmd
= select(PurchaseRecord
).where(func
.lower(PurchaseRecord
.store
) == func
.lower(store_name
)).where(PurchaseRecord
.purchase_date
> recent
)
213 records
= session
.exec(cmd
).all()
216 bought
.add(item
.name
)
219 def purchase_items(self
, item_ids
):
221 with Session(self
.engine
) as session
:
222 # Mark items as purchased
223 for item_id
in item_ids
.split(' '):
224 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
226 item
.purchase_date
= dt
.today()
228 # Track purchase history (locally only!)
229 # to generate recommended shopping lists
230 record
= PurchaseRecord()
231 record
.name
= item
.name
232 record
.price
= item
.price
233 record
.purchase_date
= item
.purchase_date
235 shopping_at
= self
._get
_pref
_store
_id
() # We bought it here
236 cmd
= select(Store
).where(Store
.id == shopping_at
)
237 store
= session
.exec(cmd
).one()
238 record
.store
= store
.name
241 results
.append(str(record
))
242 # results.append(str(item))
248 def clear_list(self
, store_name
, clear_all
=False):
250 with Session(self
.engine
) as session
:
251 store
= session
.exec(_get_with_name(Store
, store_name
)).first()
253 return [f
'Store {store_name} not found.']
255 for item
in store
.shopping_list
:
256 if clear_all
or item
.purchase_date
:
260 results
= _get_pretty_shopping_list(store
.id, session
)
263 def get_stores(self
):
264 with Session(self
.engine
) as session
:
266 return session
.exec(cmd
).all()
268 def get_unsorted_list(self
):
271 with Session(self
.engine
) as session
:
272 cmd
= select(Item
).where(Item
.stores
== None)
273 items
= session
.exec(cmd
).all()
276 return [f
'No unsorted items!']
279 def get_last_shopping_list(self
) -> List
[str]:
280 store_id
= self
._get
_pref
_store
_id
()
281 with Session(self
.engine
) as session
:
282 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
284 return _get_pretty_shopping_list(store
.id, session
)
285 return [f
'Last shopping list store not found.']
287 def get_shopping_list(self
, store_name
) -> List
[str]:
289 with Session(self
.engine
) as session
:
290 store
= session
.exec(_get_with_name(Store
, store_name
)).first()
292 return [f
'Store {store_name} not found.']
293 results
= _get_pretty_shopping_list(store
.id, session
)
294 self
._set
_pref
_store
_id
(store
.id)
297 def remove_item(self
, item_id
):
298 with Session(self
.engine
) as session
:
299 item
= session
.exec(_get_with_id(Item
, item_id
)).first()
303 def remove_store(self
, store_id
):
304 with Session(self
.engine
) as session
:
305 store
= session
.exec(_get_with_id(Store
, store_id
)).first()
306 session
.delete(store
)
309 def get_engine(self
):
310 return create_engine(f
"sqlite:///{self.file}")
313 SQLModel
.metadata
.create_all(self
.engine
)
314 with Session(self
.engine
) as session
:
315 # Add some obvious store zones by default
317 'Bakery': ['bread', 'cake', 'cookie', 'muffin', 'pie'],
318 'Baking': ['flour', 'sugar'],
320 'Clothes': ['bra', 'coat', 'jacket', 'panties', 'pants', 'shirt', 'shorts', 'socks', 'sweater', 'underwear'],
321 'Cooler': ['butter', 'cream', 'eggs', 'juice', 'milk', 'tea'],
322 'Deli': ['beef', 'chicken', 'salmon', 'steak', 'turkey'],
323 'Movies': ['movie', 'DVD'],
324 'Music': ['cd', 'album'],
325 'Produce': ['carrot', 'cucumber', 'greens', 'lettuce'],
327 'Television': ['tv', 'season'],
329 for zone
in DEFAULT_ZONES
:
330 logging
.debug(f
'Adding default items for {zone}')
331 for item
in DEFAULT_ZONES
[zone
]:
333 logging
.debug(f
'Attempt adding {item} to {zone}...')
334 new
= ItemZone(name
=item
, zone
=zone
)
337 logging
.debug(f
'Success adding {item} to {zone}...')
338 except Exception as ex
:
340 logging
.debug(f
'Failed adding {item} to {zone}: {ex}')