]> apps.delaporte.us Git - matrix-shop-bot.git/blob - matrix_shop_bot/db.py
Add some default zones
[matrix-shop-bot.git] / matrix_shop_bot / db.py
1 import logging
2 import os
3 from datetime import datetime as dt
4 from datetime import timedelta
5 from decimal import Decimal
6 from typing import List
7
8 from sqlmodel import Field, Session, SQLModel, create_engine, select, text, Relationship, func
9
10 from sqlite3 import IntegrityError
11
12 MODULE='shop'
13
14 DATE_FORMAT = '%Y-%m-%d'
15
16 def today():
17 return dt.now().strftime(DATE_FORMAT)
18
19
20 logger = logging.getLogger(__name__)
21
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)
25
26
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)
34 def __str__(self):
35 if(self.purchase_date):
36 return f'- [x] {self.name} (#{self.id})'
37 else:
38 return f'- [ ] {self.name} (#{self.id})'
39
40 class ItemZone(SQLModel, table=True):
41 '''Where an item is usually found.
42
43 Matched only by loose text matching, since items come and go, over time.
44 '''
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
48
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)
52
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()
55 if zone:
56 return zone.zone
57 return 'No Zone Assigned'
58
59 def _get_pretty_shopping_list(store_id, session) -> List[str]:
60 store = session.exec(_get_with_id(Store, store_id)).first()
61 results = []
62 results.append(f' ## {store.name}')
63 items = store.shopping_list
64 if not items:
65 results.append(f'-- Nothing to shop for at {store.name} --')
66
67 zones = {}
68
69 for item in items:
70 zone = str(_get_item_zone(item.name, session)).title() # Title Case
71 if not zone in zones:
72 zones[zone] = []
73 zones[zone].append(str(item).title())
74 for zone in zones:
75 zones[zone].sort()
76 results.append(f' ### {zone}')
77 for item in zones[zone]:
78 results.append(item)
79 return results
80
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")
87 def __str__(self):
88 return f'{self.name} (#{self.id})'
89
90
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)
98 def __str__(self):
99 date = self.purchase_date.strftime(DATE_FORMAT)
100 return f'Bought {self.name} at {self.store} on {date}'
101
102 def _get_with_id(cls, item_id):
103 return select(cls).where(cls.id == item_id)
104
105 def _get_with_name(cls, name):
106 return select(cls).where(func.lower(cls.name) == func.lower(name))
107
108
109 class Data():
110 def __init__(self, db_file=None):
111 if not db_file:
112 db_file = os.path.join(os.environ.get('MATRIX_DATA_DIR', ''), f'{MODULE}.db')
113
114 self.file = db_file
115 logger.debug(f'Shop bot is using db file {self.file}')
116 self.engine = self.get_engine()
117 self.last_store_id = None
118
119 def _set_pref_store_id(self, store_id):
120 with Session(self.engine) as session:
121 prefs = session.exec(select(PreferStore)).all()
122 for pref in prefs:
123 session.delete(pref)
124 pref = PreferStore()
125 pref.store_id = store_id
126 session.add(pref)
127 session.commit()
128
129 def _get_pref_store_id(self):
130 with Session(self.engine) as session:
131 pref = session.exec(select(PreferStore)).one()
132 return pref.store_id
133
134 def _add_with_name(self, cls, name):
135 item_id = -1
136 try:
137 with Session(self.engine) as session:
138 new = cls(name=name)
139 session.add(new)
140 session.commit()
141 item_id = new.id
142 except:
143 # duplicate add
144 # recover by returning the item_id
145 item = session.exec(select(Item).where(Item.name == name)).first()
146 item_id = item.id
147
148 return item_id
149
150 def add_store(self, name):
151 store_id = self._add_with_name(Store, name)
152 self._set_pref_store_id(store_id)
153 return store_id
154
155 def add_item(self, name):
156 names = name.split(',')
157 for name in names:
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)
161 return item_id
162
163 def assign_last_store(self, item_id):
164 store_id = self._get_pref_store_id()
165 self.assign_store(item_id, store_id)
166 return store_id
167
168 def assign_zone(self, item_name, zone_name):
169 item_id = -1
170 with Session(self.engine) as session:
171 try:
172 new = ItemZone(name=item_name)
173 new.zone = zone_name
174 session.add(new)
175 session.commit()
176 item_id = new.id
177 except:
178 pass # Already assigned
179 return item_id
180
181 def assign_store(self, item_id, store_id):
182 self._set_pref_store_id(store_id)
183
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()
187 try:
188 item.stores.append(store)
189 session.commit()
190 except:
191 pass # Already assigned
192 return ['Success']
193
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()
200 bought = set()
201 for item in records:
202 bought.add(item.name)
203 return list(bought)
204
205 def purchase_items(self, item_ids):
206 results = []
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()
211 if item:
212 item.purchase_date = dt.today()
213
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
220
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
225 session.add(record)
226
227 results.append(str(record))
228 # results.append(str(item))
229
230 session.commit()
231 return results
232
233
234 def clear_list(self, store_name, clear_all=False):
235 results = []
236 with Session(self.engine) as session:
237 store = session.exec(_get_with_name(Store, store_name)).first()
238 if not store:
239 return [f'Store {store_name} not found.']
240
241 for item in store.shopping_list:
242 if clear_all or item.purchase_date:
243 session.delete(item)
244
245 session.commit()
246 results = _get_pretty_shopping_list(store.id, session)
247 return results
248
249 def get_stores(self):
250 with Session(self.engine) as session:
251 cmd = select(Store)
252 return session.exec(cmd).all()
253
254 def get_unsorted_list(self):
255
256 results = []
257 with Session(self.engine) as session:
258 cmd = select(Item).where(Item.stores == None)
259 items = session.exec(cmd).all()
260
261 if not items:
262 return [f'No unsorted items!']
263 return items
264
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()
269 if store:
270 return _get_pretty_shopping_list(store.id, session)
271 return [f'Last shopping list store not found.']
272
273 def get_shopping_list(self, store_name) -> List[str]:
274 retsults = []
275 with Session(self.engine) as session:
276 store = session.exec(_get_with_name(Store, store_name)).first()
277 if not store:
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)
281 return results
282
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()
286 session.delete(item)
287 session.commit()
288
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)
293 session.commit()
294
295 def get_engine(self):
296 return create_engine(f"sqlite:///{self.file}")
297
298 def setup(self):
299 SQLModel.metadata.create_all(self.engine)
300 with Session(self.engine) as session:
301 # Add some obvious store zones by default
302 DEFAULT_ZONES = {
303 'Bakery': ['bread', 'cake', 'cookie', 'muffin', 'pie'],
304 'Baking': ['flour', 'sugar'],
305 'Books': ['book'],
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'],
312 'Shoes': ['shoe'],
313 'Television': ['tv', 'season'],
314 }
315 for zone in DEFAULT_ZONES:
316 logging.debug(f'Adding default items for {zone}')
317 for item in DEFAULT_ZONES[zone]:
318 try:
319 logging.debug(f'Attempt adding {item} to {zone}...')
320 new = ItemZone(name=item, zone=zone)
321 session.add(new)
322 session.commit()
323 logging.debug(f'Success adding {item} to {zone}...')
324 except Exception as ex:
325 session.rollback()
326 logging.debug(f'Failed adding {item} to {zone}: {ex}')
327
328 return 'Success'
329
330
331
332