X-Git-Url: https://apps.delaporte.us/gitweb/matrix-shop-bot.git/blobdiff_plain/d0f8d69f2d4aa2a1af67baae391663d9bb72c729..050ee98fbbfea0a1cde53a5853e1da905f8cbb7b:/matrix_shop_bot/db.py?ds=inline diff --git a/matrix_shop_bot/db.py b/matrix_shop_bot/db.py index 0d1f590..a225f3c 100644 --- a/matrix_shop_bot/db.py +++ b/matrix_shop_bot/db.py @@ -1,10 +1,13 @@ import logging import os from datetime import datetime as dt +from datetime import timedelta from decimal import Decimal from typing import List -from sqlmodel import Field, Session, SQLModel, create_engine, select, text, Relationship +from sqlmodel import Field, Session, SQLModel, create_engine, select, text, Relationship, func + +from sqlite3 import IntegrityError MODULE='shop' @@ -15,7 +18,6 @@ def today(): logger = logging.getLogger(__name__) -logging.basicConfig(filename=f'{MODULE}.log', encoding='utf-8', level=logging.DEBUG) class ItemStoreLink(SQLModel, table=True): item_id: int | None = Field(default=None, foreign_key="item.id", primary_key=True) @@ -23,15 +25,60 @@ class ItemStoreLink(SQLModel, table=True): class Item(SQLModel, table=True): + '''An item to shop for.''' id: int | None = Field(default=None, primary_key=True) name: str = Field(default=None, unique=True) price: Decimal | None = Field(decimal_places=2) stores: list["Store"] = Relationship(back_populates="shopping_list", link_model=ItemStoreLink) purchase_date: dt | None = Field(default=None) def __str__(self): - return f'{self.name} (#{self.id})' + if(self.purchase_date): + return f'- [x] {self.name} (#{self.id})' + else: + return f'- [ ] {self.name} (#{self.id})' + +class ItemZone(SQLModel, table=True): + '''Where an item is usually found. + + Matched only by loose text matching, since items come and go, over time. + ''' + id: int | None = Field(default=None, primary_key=True) + name: str = Field(default=None, unique=True) # milk + zone: str = Field(default=None, unique=False) # dairy section + +class PreferStore(SQLModel, table=True): + '''Tracks which store we are shopping at, at the moment.''' + store_id: int | None = Field(default=None, foreign_key="store.id", primary_key=True) + +def _get_item_zone(item_name, session) -> str: + zone = session.exec(select(ItemZone).where(func.lower(item_name).contains(func.lower(ItemZone.name)))).first() + if zone: + return zone.zone + return 'No Zone Assigned' + +def _get_pretty_shopping_list(store_id, session) -> List[str]: + store = session.exec(_get_with_id(Store, store_id)).first() + results = [] + results.append(f' ## {store.name}') + items = store.shopping_list + if not items: + results.append(f'-- Nothing to shop for at {store.name} --') + + zones = {} + + for item in items: + zone = str(_get_item_zone(item.name, session)).title() # Title Case + if not zone in zones: + zones[zone] = [] + zones[zone].append(str(item)) + for zone in zones: + results.append(f' ### {zone}') + for item in zones[zone]: + results.append(item) + return results class Store(SQLModel, table=True): + '''A place to shop at.''' id: int | None = Field(default=None, primary_key=True) name: str = Field(default=None, unique=True) shopping_list: list["Item"] = Relationship(back_populates="stores", link_model=ItemStoreLink) @@ -40,20 +87,23 @@ class Store(SQLModel, table=True): return f'{self.name} (#{self.id})' - - - -# May get used later... -#class PurchaseRecord(SQLModel, table=True): -# id: int | None = Field(default=None, primary_key=True) -# name: str = Field(default=None, unique=True) -# price: Decimal | None = Field(decimal_places=2) -# store: Store = Relationship(back_populates="purchase_history") -# purchase_date: dt | None = Field(default=None) +class PurchaseRecord(SQLModel, table=True): + '''Tracks when and where items were purchased.''' + id: int | None = Field(default=None, primary_key=True) + name: str = Field(default=None) + price: Decimal | None = Field(decimal_places=2) + store: str | None = Field(default=None) + purchase_date: dt | None = Field(default=None) + def __str__(self): + date = self.purchase_date.strftime(DATE_FORMAT) + return f'Bought {self.name} at {self.store} on {date}' def _get_with_id(cls, item_id): return select(cls).where(cls.id == item_id) +def _get_with_name(cls, name): + return select(cls).where(func.lower(cls.name) == func.lower(name)) + class Data(): def __init__(self, db_file=None): @@ -61,43 +111,139 @@ class Data(): db_file = os.path.join(os.environ.get('MATRIX_DATA_DIR', ''), f'{MODULE}.db') self.file = db_file + logger.debug(f'Shop bot is using db file {self.file}') self.engine = self.get_engine() + self.last_store_id = None - def _add_with_name(self, cls, name): - id = -1 + def _set_pref_store_id(self, store_id): with Session(self.engine) as session: - new = cls(name=name) - session.add(new) + prefs = session.exec(select(PreferStore)).all() + for pref in prefs: + session.delete(pref) + pref = PreferStore() + pref.store_id = store_id + session.add(pref) session.commit() - id = new.id - return id + + def _get_pref_store_id(self): + with Session(self.engine) as session: + pref = session.exec(select(PreferStore)).one() + return pref.store_id + + def _add_with_name(self, cls, name): + item_id = -1 + try: + with Session(self.engine) as session: + new = cls(name=name) + session.add(new) + session.commit() + item_id = new.id + except: + # duplicate add + # recover by returning the item_id + item = session.exec(select(Item).where(Item.name == name)).first() + item_id = item.id + + return item_id def add_store(self, name): - return self._add_with_name(Store, name) + store_id = self._add_with_name(Store, name) + self._set_pref_store_id(store_id) + return store_id def add_item(self, name): - return self._add_with_name(Item, name) + names = name.split(',') + for name in names: + item_id = self._add_with_name(Item, name.strip()) + store_id = self._get_pref_store_id() + self.assign_store(item_id, store_id) + return item_id + + def assign_last_store(self, item_id): + store_id = self._get_pref_store_id() + self.assign_store(item_id, store_id) + return store_id + + def assign_zone(self, item_name, zone_name): + item_id = -1 + with Session(self.engine) as session: + try: + new = ItemZone(name=item_name) + new.zone = zone_name + session.add(new) + session.commit() + item_id = new.id + except: + pass # Already assigned + return item_id + + def assign_store(self, item_id, store_id): + self._set_pref_store_id(store_id) - def assign_store(self, store_id, item_id): with Session(self.engine) as session: store = session.exec(_get_with_id(Store, store_id)).first() item = session.exec(_get_with_id(Item, item_id)).first() - item.stores.append(store) - session.commit() - - def purchase_item(self, item_id): + try: + item.stores.append(store) + session.commit() + except: + pass # Already assigned + return ['Success'] + + def list_purchases(self, store_name): + '''List unique purchases at store in the past 90 days''' with Session(self.engine) as session: - item = session.exec(_get_with_id(Item, item_id)).first() - item.purchase_date = dt.today() + recent = dt.now() - timedelta(days=90) + cmd = select(PurchaseRecord).where(func.lower(PurchaseRecord.store) == func.lower(store_name)).where(PurchaseRecord.purchase_date > recent) + records = session.exec(cmd).all() + bought = set() + for item in records: + bought.add(item.name) + return list(bought) + + def purchase_items(self, item_ids): + results = [] + with Session(self.engine) as session: + # Mark items as purchased + for item_id in item_ids.split(' '): + item = session.exec(_get_with_id(Item, item_id)).first() + if item: + item.purchase_date = dt.today() + + # Track purchase history (locally only!) + # to generate recommended shopping lists + record = PurchaseRecord() + record.name = item.name + record.price = item.price + record.purchase_date = item.purchase_date + + shopping_at = self._get_pref_store_id() # We bought it here + cmd = select(Store).where(Store.id == shopping_at) + store = session.exec(cmd).one() + record.store = store.name + session.add(record) + + results.append(str(record)) + # results.append(str(item)) + session.commit() + return results + - def clear_list(self, store_id, clear_all=False): + def clear_list(self, store_name, clear_all=False): + results = [] with Session(self.engine) as session: - store = session.exec(_get_with_id(Store, store_id)).first() + store = session.exec(_get_with_name(Store, store_name)).first() + if not store: + return [f'Store {store_name} not found.'] + for item in store.shopping_list: if clear_all or item.purchase_date: session.delete(item) + session.commit() + results = _get_pretty_shopping_list(store.id, session) + return results def get_stores(self): with Session(self.engine) as session: @@ -115,26 +261,35 @@ class Data(): return [f'No unsorted items!'] return items - def get_shopping_list(self, store_id) -> List[str]: - results = [] + def get_last_shopping_list(self) -> List[str]: + store_id = self._get_pref_store_id() with Session(self.engine) as session: - cmd = select(Store).where(Store.id == store_id) - store = session.exec(cmd).first() + store = session.exec(_get_with_id(Store, store_id)).first() + if store: + return _get_pretty_shopping_list(store.id, session) + return [f'Last shopping list store not found.'] + + def get_shopping_list(self, store_name) -> List[str]: + retsults = [] + with Session(self.engine) as session: + store = session.exec(_get_with_name(Store, store_name)).first() if not store: - return [f'Store {store_id} not found.'] + return [f'Store {store_name} not found.'] + results = _get_pretty_shopping_list(store.id, session) + self._set_pref_store_id(store.id) + return results - results.append(f' ## {store.name}') + def remove_item(self, item_id): + with Session(self.engine) as session: + item = session.exec(_get_with_id(Item, item_id)).first() + session.delete(item) + session.commit() - items = store.shopping_list - if not items: - results.append(f'-- Nothing to shop for at {store.name} --') - else: - for item in items: - if(item.purchase_date): - results.append(f' - [x] {item}') - else: - results.append(f' - [ ] {item}') - return results + def remove_store(self, store_id): + with Session(self.engine) as session: + store = session.exec(_get_with_id(Store, store_id)).first() + session.delete(store) + session.commit() def get_engine(self): return create_engine(f"sqlite:///{self.file}")