diff --git a/migrations/versions/c28b3a6cdc3a_.py b/migrations/versions/c28b3a6cdc3a_.py new file mode 100644 index 0000000..a4e5e80 --- /dev/null +++ b/migrations/versions/c28b3a6cdc3a_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: c28b3a6cdc3a +Revises: 22dc32e475dd +Create Date: 2022-12-24 08:56:13.784788 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'c28b3a6cdc3a' +down_revision = '22dc32e475dd' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add the 'remember' column and set to true (original default) + op.add_column('products', sa.Column('remember', sa.Boolean(), nullable=True, default=True)) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('products', 'remember') + # ### end Alembic commands ### diff --git a/sigl/database/tables.py b/sigl/database/tables.py index 5d955e0..e8fa61c 100644 --- a/sigl/database/tables.py +++ b/sigl/database/tables.py @@ -64,6 +64,7 @@ products = db.Table( db.Column('name', db.String(128), nullable=False), db.Column('category', db.String(128), nullable=False, index=True), db.Column('defaultQty', db.String(128), default=None), + db.Column('remember', db.Boolean, nullable=False, default=True), # Mixin Columns db.Column('notes', db.String(), default=None), diff --git a/sigl/domain/models/product.py b/sigl/domain/models/product.py index 869abc5..9ba93d6 100644 --- a/sigl/domain/models/product.py +++ b/sigl/domain/models/product.py @@ -27,6 +27,7 @@ class Product(NotesMixin, TimestampMixin): name: str = None category: str = None defaultQty: str = None + remember: bool = True # Relationship Fields entries: List['ListEntry'] = field(default_factory=list) diff --git a/sigl/domain/service.py b/sigl/domain/service.py index 13fd83c..c7914eb 100644 --- a/sigl/domain/service.py +++ b/sigl/domain/service.py @@ -17,11 +17,12 @@ def list_addItem( session: Session, id: int, *, - productId: Optional[int], - productName: Optional[str], - productCategory: Optional[str], - quantity: Optional[str], - notes: Optional[str], + productId: Optional[int] = None, + productName: Optional[str] = None, + productCategory: Optional[str] = None, + quantity: Optional[str] = None, + remember: Optional[bool] = None, + notes: Optional[str] = None, ) -> ListEntry: """Add a Product to a Shopping List. @@ -29,6 +30,10 @@ def list_addItem( product by ID and add it to the list. If the `product` parameter is not provided, a new `Product` will be created with the provided `productName` and `productCategory` values. + + If the `remember` parameter is provided and is `False`, the product will + be removed along with the list item, and it will not be offered as a + suggestion when adding items. """ sList = list_by_id(session, id) if not sList: @@ -40,6 +45,8 @@ def list_addItem( raise DomainError('Product Name cannot be empty') product = Product(name=productName, category=productCategory) + if remember is not None: + product.remember = remember session.add(product) @@ -97,6 +104,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList: raise NotFoundError(f'List {id} does not exist') for entry in sList.entries: + if not entry.product.remember: + session.delete(entry.product) if entry.crossedOff: session.delete(entry) @@ -108,6 +117,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList: def list_deleteItem(session: Session, listId: int, entryId: int): """Delete an Entry from a Shopping List.""" entry = list_entry_by_id(session, listId, entryId) + if not entry.product.remember: + session.delete(entry.product) session.delete(entry) session.commit() @@ -118,8 +129,8 @@ def list_editItem( listId: int, entryId: int, *, - quantity: Optional[str], - notes: Optional[str], + quantity: Optional[str] = None, + notes: Optional[str] = None, ) -> ListEntry: """Edit an Entry on a Shopping List.""" entry = list_entry_by_id(session, listId, entryId) @@ -133,7 +144,7 @@ def list_editItem( return entry -def list_stores(session: Session, id: Optional[int]) -> List[str]: +def list_stores(session: Session, id: Optional[int] = None) -> List[str]: """Get a list of all Stores for the List. This helper returns a list of all Stores for which the Products in the @@ -163,8 +174,8 @@ def list_stores(session: Session, id: Optional[int]) -> List[str]: def list_update( session: Session, id: int, - name: Union[str, None], - notes: Union[str, None], + name: Union[str, None] = None, + notes: Union[str, None] = None, ) -> ShoppingList: """Update the Name and/or Notes of a Shopping List.""" sList = list_by_id(session, id) @@ -215,7 +226,7 @@ def list_entry_set_crossedOff(session: Session, listId: int, entryId: int, cross def products_all(session: Session) -> List[Product]: """Return all Products.""" - return session.query(Product).all() + return session.query(Product).filter(Product.remember is True).all() def product_by_id(session: Session, id: int) -> Optional[Product]: @@ -227,11 +238,15 @@ def product_create( session: Session, name: str, *, - category: Optional[str], - notes: Optional[str], + category: Optional[str] = None, + remember: Optional[bool] = None, + notes: Optional[str] = None, ) -> Product: """Create a new Product.""" product = Product(name=name, category=category, notes=notes) + if remember is not None: + product.remember = remember + session.add(product) session.commit() @@ -252,8 +267,8 @@ def product_update( session: Session, id: int, name: str, - category: Optional[str], - notes: Optional[str], + category: Optional[str] = None, + notes: Optional[str] = None, ) -> Product: """Update a Product.""" product = product_by_id(session, id) @@ -276,8 +291,8 @@ def product_addLocation( id: int, store: str, *, - aisle: Optional[str], - bin: Optional[str] + aisle: Optional[str] = None, + bin: Optional[str] = None, ) -> ProductLocation: """Add a Store Location to a Product.""" product = product_by_id(session, id) diff --git a/tests/conftest.py b/tests/conftest.py index 81fe4d3..6b7366e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,7 +128,8 @@ def session(request, monkeypatch, app): monkeypatch.setattr(_db, 'session', session) def teardown(): - transaction.rollback() + if transaction.is_active: + transaction.rollback() connection.close() session.remove() diff --git a/tests/test_11_product_model.py b/tests/test_11_product_model.py index a21f09d..ab9f2ee 100644 --- a/tests/test_11_product_model.py +++ b/tests/test_11_product_model.py @@ -76,3 +76,13 @@ def test_product_model_same_store_fails(session): with pytest.raises(IntegrityError): session.commit() + + +@pytest.mark.unit +def test_product_model_remembers_by_default(session): + """Test that the Product defaults to remembering.""" + p = Product(name='Eggs', category='Dairy') + session.add(p) + session.commit() + + assert p.remember is True diff --git a/tests/test_21_product_service.py b/tests/test_21_product_service.py new file mode 100644 index 0000000..b54e4f6 --- /dev/null +++ b/tests/test_21_product_service.py @@ -0,0 +1,36 @@ +"""Test the Product Service Entry Points. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import pytest + +from sigl.domain.service import product_by_id, product_create + +# Always use 'app' fixture so ORM gets initialized +pytestmark = pytest.mark.usefixtures('app') + + +@pytest.mark.unit +def test_product_create_defaults(session): + """Test newly created Products have no Locations.""" + pc = product_create(session, 'Eggs', category='Dairy') + p = product_by_id(session, pc.id) + + assert p.name == 'Eggs' + assert p.category == 'Dairy' + assert p.remember is True + assert not p.locations + + +@pytest.mark.unit +def test_product_create_forget(session): + """Test newly created Products can have remember as false.""" + pc = product_create(session, 'Eggs', category='Dairy', remember=False) + p = product_by_id(session, pc.id) + + assert p.name == 'Eggs' + assert p.category == 'Dairy' + assert p.remember is False + assert not p.locations diff --git a/tests/test_22_list_service.py b/tests/test_22_list_service.py new file mode 100644 index 0000000..554ab1e --- /dev/null +++ b/tests/test_22_list_service.py @@ -0,0 +1,107 @@ +"""Test the Product Service Entry Points. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +import pytest + +from sigl.domain.service import ( + list_addItem, + list_by_id, + list_create, + list_deleteCrossedOff, + list_entry_set_crossedOff, + product_by_id, +) + +# Always use 'app' fixture so ORM gets initialized +pytestmark = pytest.mark.usefixtures('app') + + +@pytest.mark.unit +def test_list_create_defaults(session): + """Test newly created Lists are empty.""" + lc = list_create(session, 'Test') + list = list_by_id(session, lc.id) + + assert list.name == 'Test' + assert not list.entries + + +@pytest.mark.unit +def test_list_add_product_defaults(session): + """Test adding a Product to a List.""" + list = list_create(session, 'Test') + entry = list_addItem(session, list.id, productName='Eggs', productCategory='Dairy') + + assert entry.id is not None + assert entry.product is not None + assert entry.product.name == 'Eggs' + assert entry.product.category == 'Dairy' + assert entry.product.remember is True + + assert len(list.entries) == 1 + assert list.entries[0] == entry + + +@pytest.mark.unit +def test_list_add_product_no_remember(session): + """Test adding a Product to a List without remembering it.""" + list = list_create(session, 'Test') + entry = list_addItem( + session, + list.id, + productName='Eggs', + productCategory='Dairy', + remember=False, + ) + + assert entry.id is not None + assert entry.product is not None + assert entry.product.name == 'Eggs' + assert entry.product.category == 'Dairy' + assert entry.product.remember is False + + assert len(list.entries) == 1 + assert list.entries[0] == entry + + +@pytest.mark.unit +def test_list_removes_product_with_remember(session): + """Test that checking off and deleting a remembered Product does not delete the Product Entry.""" + list = list_create(session, 'Test') + entry = list_addItem( + session, + list.id, + productName='Eggs', + productCategory='Dairy', + remember=True, + ) + + pid = entry.product.id + + list_entry_set_crossedOff(session, list.id, entry.id, True) + list_deleteCrossedOff(session, list.id) + + assert product_by_id(session, pid) is not None + + +@pytest.mark.unit +def test_list_removes_product_no_remember(session): + """Test that checking off and deleting a non-remembered Product deletes the Product Entry also.""" + list = list_create(session, 'Test') + entry = list_addItem( + session, + list.id, + productName='Eggs', + productCategory='Dairy', + remember=False, + ) + + pid = entry.product.id + + list_entry_set_crossedOff(session, list.id, entry.id, True) + list_deleteCrossedOff(session, list.id) + + assert product_by_id(session, pid) is None