From 9bd38bdc5662f53e5ac802bef428bcbb95b042e9 Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Sat, 25 Feb 2023 16:06:54 -0800 Subject: [PATCH] Add Recipes --- config/test.py | 2 +- migrations/versions/3d0cab7d7747_.py | 47 ++++ sigl/database/orm.py | 36 ++- sigl/database/tables.py | 36 +++ sigl/domain/models/__init__.py | 3 + sigl/domain/models/product.py | 2 + sigl/domain/models/recipe.py | 42 ++++ sigl/domain/service.py | 225 +++++++++++++++++- tests/test_22_recipe_service.py | 101 ++++++++ ...ist_service.py => test_23_list_service.py} | 0 tests/test_31_list_recipes.py | 90 +++++++ 11 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/3d0cab7d7747_.py create mode 100644 sigl/domain/models/recipe.py create mode 100644 tests/test_22_recipe_service.py rename tests/{test_22_list_service.py => test_23_list_service.py} (100%) create mode 100644 tests/test_31_list_recipes.py diff --git a/config/test.py b/config/test.py index 13492d8..ef36d71 100644 --- a/config/test.py +++ b/config/test.py @@ -17,7 +17,7 @@ APP_TOKEN_VALIDITY = 7200 # Development Database Settings (overridden by PyTest app_config Fixture) DB_DRIVER = 'sqlite' -DB_FILE = 'sigl-test.db' +DB_FILE = 'sigl-test-next.db' # Mail Configuration MAIL_ENABLED = True diff --git a/migrations/versions/3d0cab7d7747_.py b/migrations/versions/3d0cab7d7747_.py new file mode 100644 index 0000000..0ec1906 --- /dev/null +++ b/migrations/versions/3d0cab7d7747_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: 3d0cab7d7747 +Revises: c28b3a6cdc3a +Create Date: 2023-02-25 15:37:19.626908 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '3d0cab7d7747' +down_revision = 'c28b3a6cdc3a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('recipes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('recipe_entries', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('recipe_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.String(length=128), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('createdAt', sa.DateTime(), nullable=True), + sa.Column('modifiedAt', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['recipe_id'], ['recipes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('recipe_entries') + op.drop_table('recipes') + # ### end Alembic commands ### diff --git a/sigl/database/orm.py b/sigl/database/orm.py index 59306e0..c89cc97 100644 --- a/sigl/database/orm.py +++ b/sigl/database/orm.py @@ -6,9 +6,17 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. from sigl.domain.models import Product, ProductLocation from sigl.domain.models.list import ListEntry, ShoppingList +from sigl.domain.models.recipe import Recipe, RecipeEntry from .globals import db -from .tables import list_entries, lists, product_locations, products +from .tables import ( + list_entries, + lists, + product_locations, + products, + recipe_entries, + recipes, +) __all__ = ('init_orm', ) @@ -27,6 +35,18 @@ def init_orm(): ) }) + # Recipe Entries + db.mapper(RecipeEntry, recipe_entries, properties={ + 'product': db.relationship( + Product, + back_populates='recipes' + ), + 'recipe': db.relationship( + Recipe, + back_populates='entries', + ) + }) + # Products db.mapper(Product, products, properties={ 'entries': db.relationship( @@ -34,6 +54,11 @@ def init_orm(): back_populates='product', cascade='all, delete-orphan', ), + 'recipes': db.relationship( + RecipeEntry, + back_populates='product', + cascade='all, delete-orphan', + ), 'locations': db.relationship( ProductLocation, back_populates='product', @@ -57,3 +82,12 @@ def init_orm(): cascade='all, delete-orphan', ) }) + + # Recipes + db.mapper(Recipe, recipes, properties={ + 'entries': db.relationship( + RecipeEntry, + back_populates='recipe', + cascade='all, delete-orphan', + ) + }) diff --git a/sigl/database/tables.py b/sigl/database/tables.py index e8fa61c..80512a5 100644 --- a/sigl/database/tables.py +++ b/sigl/database/tables.py @@ -94,3 +94,39 @@ product_locations = db.Table( db.Column('createdAt', db.DateTime(), default=None), db.Column('modifiedAt', db.DateTime(), default=None), ) + +#: Recipe Table +recipes = db.Table( + 'recipes', + + # Primary Key + db.Column('id', db.Integer, primary_key=True), + + # List Attributes + db.Column('name', db.String(128), nullable=False), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), +) + +#: Recipe Entry Table +recipe_entries = db.Table( + 'recipe_entries', + + # Primary Key + db.Column('id', db.Integer, primary_key=True), + + # Shopping List and Product Link + db.Column('recipe_id', db.ForeignKey('recipes.id'), nullable=False), + db.Column('product_id', db.ForeignKey('products.id'), nullable=False), + + # Entry Attributes + db.Column('quantity', db.String(128), default=None), + + # Mixin Columns + db.Column('notes', db.String(), default=None), + db.Column('createdAt', db.DateTime(), default=None), + db.Column('modifiedAt', db.DateTime(), default=None), +) diff --git a/sigl/domain/models/__init__.py b/sigl/domain/models/__init__.py index 1092c51..c95aa9e 100644 --- a/sigl/domain/models/__init__.py +++ b/sigl/domain/models/__init__.py @@ -6,10 +6,13 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. from .list import ListEntry, ShoppingList from .product import Product, ProductLocation +from .recipe import Recipe, RecipeEntry __all__ = ( 'ListEntry', 'Product', 'ProductLocation', + 'Recipe', + 'RecipeEntry', 'ShoppingList', ) diff --git a/sigl/domain/models/product.py b/sigl/domain/models/product.py index 9ba93d6..e8ebef9 100644 --- a/sigl/domain/models/product.py +++ b/sigl/domain/models/product.py @@ -11,6 +11,7 @@ from .mixins import NotesMixin, TimestampMixin if TYPE_CHECKING: from .list import ListEntry + from .recipe import RecipeEntry __all__ = ('Product', 'ProductLocation') @@ -31,6 +32,7 @@ class Product(NotesMixin, TimestampMixin): # Relationship Fields entries: List['ListEntry'] = field(default_factory=list) + recipes: List['RecipeEntry'] = field(default_factory=list) locations: List['ProductLocation'] = field(default_factory=list) diff --git a/sigl/domain/models/recipe.py b/sigl/domain/models/recipe.py new file mode 100644 index 0000000..bd85e27 --- /dev/null +++ b/sigl/domain/models/recipe.py @@ -0,0 +1,42 @@ +"""Sigl Recipe Domain Model. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2023 Asymworks, LLC. All Rights Reserved. +""" + +from dataclasses import dataclass, field +from typing import List + +from .mixins import NotesMixin, TimestampMixin +from .product import Product + +__all__ = ('Recipe', 'RecipeEntry') + + +@dataclass +class RecipeEntry(NotesMixin, TimestampMixin): + """Information about a Product in a Recipe. + + This class contains information about a Product that is in a recipe + list, including the quantity to be purchased and notes about the entry. + """ + id: int = None + quantity: str = None + + # Relationship Fields + product: Product = None + recipe: 'Recipe' = None + + +@dataclass +class Recipe(NotesMixin, TimestampMixin): + """Top-Level Recipe. + + Contains a collection of `RecipeEntry` items which are intended to be + added to shopping lists as a group. + """ + id: int = None + name: str = None + + # Relationship Fields + entries: List[RecipeEntry] = field(default_factory=list) diff --git a/sigl/domain/service.py b/sigl/domain/service.py index b7ef12f..393af90 100644 --- a/sigl/domain/service.py +++ b/sigl/domain/service.py @@ -11,7 +11,14 @@ from sqlalchemy.orm import Session from sigl.exc import DomainError, NotFoundError -from .models import ListEntry, Product, ProductLocation, ShoppingList +from .models import ( + ListEntry, + Product, + ProductLocation, + Recipe, + RecipeEntry, + ShoppingList, +) def list_addItem( @@ -20,7 +27,7 @@ def list_addItem( *, productId: Optional[int] = None, productName: Optional[str] = None, - productCategory: Optional[str] = None, + productCategory: Optional[str] = '', quantity: Optional[str] = None, remember: Optional[bool] = None, notes: Optional[str] = None, @@ -71,6 +78,51 @@ def list_addItem( return entry +def list_addRecipe(session: Session, listId: int, recipeId: int) -> List[ListEntry]: + """Add a Recipe to a Shopping List. + + This creates new `ListEntry` items for each `RecipeEntry` within the + `Recipe` object. Note that any Products that are referenced by the Recipe + and that are already in the Shopping List will have the quantity updated + to include the Recipe quantity. + """ + sList = list_by_id(session, listId) + if not sList: + raise NotFoundError(f'List {listId} does not exist') + + recipe = recipe_by_id(session, recipeId) + if not recipe: + raise NotFoundError(f'Recipe {recipeId} does not exist') + + lEntries = list() + for rEntry in recipe.entries: + lEntry = list_entry_by_productId(session, listId, rEntry.product.id) + if lEntry: + if lEntry.quantity and rEntry.quantity: + lEntry.quantity = f'{lEntry.quantity}, {rEntry.quantity} ({recipe.name})' + elif rEntry.quantity: + lEntry.quantity = rEntry.quantity + + if lEntry.notes and rEntry.notes: + lEntry.notes = f'{lEntry.notes}\n{rEntry.notes}' + elif rEntry.notes: + lEntry.notes = rEntry.notes + + else: + lEntry = ListEntry( + shoppingList=sList, + product=rEntry.product, + quantity=rEntry.quantity, + notes=rEntry.notes, + ) + + session.add(lEntry) + lEntries.append(lEntry) + + session.commit() + return lEntries + + def lists_all(session: Session) -> List[ShoppingList]: """Return all Shopping Lists.""" return session.query(ShoppingList).all() @@ -195,6 +247,19 @@ def list_update( return sList +def list_entry_by_productId(session: Session, listId: int, productId: int) -> Optional[ListEntry]: + """Load a Shopping List Entry by Product Id.""" + sList = list_by_id(session, listId) + if not sList: + raise NotFoundError(f'List {listId} not found') + + product = product_by_id(session, productId) + if not product: + raise NotFoundError(f'Product {productId} not found') + + return session.query(ListEntry).filter(ListEntry.product == product).one_or_none() + + def list_entry_by_id(session: Session, listId: int, entryId: int) -> Optional[ListEntry]: """Load a specific Shopping List Entry.""" sList = list_by_id(session, listId) @@ -333,3 +398,159 @@ def product_removeLocation( session.delete(loc) session.commit() + + +def recipe_addItem( + session: Session, + id: int, + *, + productId: Optional[int] = None, + productName: Optional[str] = None, + productCategory: Optional[str] = '', + quantity: Optional[str] = None, + remember: Optional[bool] = None, + notes: Optional[str] = None, +) -> ListEntry: + """Add a Product to a Recipe. + + If the `productId` parameter is provided, the method will look up the + 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. + """ + recipe = recipe_by_id(session, id) + if not recipe: + raise NotFoundError(f'Recipe {id} does not exist') + + product = None + if not productId: + if not productName: + raise DomainError('Product Name cannot be empty') + + product = product_by_name(session, productName) + if not product: + product = Product(name=productName, category=productCategory) + if remember is not None: + product.remember = remember + + session.add(product) + + else: + product = product_by_id(session, productId) + if not product: + raise NotFoundError(f'Product {productId} does not exist') + + entry = RecipeEntry( + recipe=recipe, + product=product, + quantity=quantity, + notes=notes, + ) + + session.add(entry) + session.commit() + + return entry + + +def recipes_all(session: Session) -> List[Recipe]: + """Return all Recipes.""" + return session.query(Recipe).all() + + +def recipe_by_id(session: Session, id: int) -> Optional[Recipe]: + """Load a specific Recipe.""" + return session.query(Recipe).filter(Recipe.id == id).one_or_none() + + +def recipe_create(session: Session, name: str, *, notes=None) -> Recipe: + """Create a new Recipe.""" + recipe = Recipe(name=name, notes=notes) + session.add(recipe) + session.commit() + + return recipe + + +def recipe_delete(session: Session, id: int): + """Delete a Recipe.""" + recipe = recipe_by_id(session, id) + if not recipe: + raise NotFoundError(f'Recipe {id} does not exist') + + session.delete(recipe) + session.commit() + + +def recipe_deleteItem(session: Session, recipeId: int, entryId: int): + """Delete an Entry from a Recipe.""" + entry = recipe_entry_by_id(session, recipeId, entryId) + if not entry.product.remember: + session.delete(entry.product) + + session.delete(entry) + session.commit() + + +def recipe_editItem( + session: Session, + recipeId: int, + entryId: int, + *, + quantity: Optional[str] = None, + notes: Optional[str] = None, +) -> ListEntry: + """Edit an Entry in a Recipe.""" + entry = recipe_entry_by_id(session, recipeId, entryId) + entry.quantity = quantity + entry.notes = notes + entry.set_modified_at() + + session.add(entry) + session.commit() + + return entry + + +def recipe_update( + session: Session, + id: int, + name: Union[str, None] = None, + notes: Union[str, None] = None, +) -> ShoppingList: + """Update the Name and/or Notes of a Recipe.""" + recipe = recipe_by_id(session, id) + if not recipe: + raise NotFoundError(f'Recipe {id} does not exist') + + recipe.name = name + recipe.notes = notes + recipe.set_modified_at() + + session.add(recipe) + session.commit() + + return recipe + + +def recipe_entry_by_id(session: Session, recipeId: int, entryId: int) -> Optional[RecipeEntry]: + """Load a specific Recipe Entry.""" + recipe = recipe_by_id(session, recipeId) + if not recipe: + raise NotFoundError(f'Recipe {recipeId} not found') + + entry = session.query(RecipeEntry).filter(RecipeEntry.id == entryId).one_or_none() + if not entry: + raise NotFoundError(f'Recipe Entry {entryId} not found') + + if entry.recipe != recipe: + raise DomainError( + f'List Entry {entryId} does not belong to List {recipe.name}', + status_code=422, + ) + + return entry diff --git a/tests/test_22_recipe_service.py b/tests/test_22_recipe_service.py new file mode 100644 index 0000000..50cb74f --- /dev/null +++ b/tests/test_22_recipe_service.py @@ -0,0 +1,101 @@ +"""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_create, + recipe_addItem, + recipe_by_id, + recipe_create, +) + +# Always use 'app' fixture so ORM gets initialized +pytestmark = pytest.mark.usefixtures('app') + + +@pytest.mark.unit +def test_recipe_create_defaults(session): + """Test newly created Recipes are empty.""" + lc = recipe_create(session, 'Test') + recipe = recipe_by_id(session, lc.id) + + assert recipe.name == 'Test' + assert not recipe.entries + + +@pytest.mark.unit +def test_recipe_add_product_defaults(session): + """Test adding a Product to a Recipe.""" + recipe = recipe_create(session, 'Test') + entry = recipe_addItem(session, recipe.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(recipe.entries) == 1 + assert recipe.entries[0] == entry + + +@pytest.mark.unit +def test_recipe_add_product_by_id(session): + """Test adding an existing Product to a Recipe by Id.""" + p1 = product_create(session, 'Eggs', category='Dairy') + + recipe = recipe_create(session, 'Test') + entry = recipe_addItem(session, recipe.id, productId=p1.id) + + 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(recipe.entries) == 1 + assert recipe.entries[0] == entry + + +@pytest.mark.unit +def test_recipe_add_product_by_name(session): + """Test adding an existing Product to a Recipe by Name.""" + product_create(session, 'Eggs', category='Dairy') + + recipe = recipe_create(session, 'Test') + entry = recipe_addItem(session, recipe.id, productName='eggs') + + 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(recipe.entries) == 1 + assert recipe.entries[0] == entry + + +@pytest.mark.unit +def test_recipe_add_product_no_remember(session): + """Test adding a Product to a Recipe without remembering it.""" + recipe = recipe_create(session, 'Test') + entry = recipe_addItem( + session, + recipe.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(recipe.entries) == 1 + assert recipe.entries[0] == entry diff --git a/tests/test_22_list_service.py b/tests/test_23_list_service.py similarity index 100% rename from tests/test_22_list_service.py rename to tests/test_23_list_service.py diff --git a/tests/test_31_list_recipes.py b/tests/test_31_list_recipes.py new file mode 100644 index 0000000..c9f1080 --- /dev/null +++ b/tests/test_31_list_recipes.py @@ -0,0 +1,90 @@ +"""Test the List Recipe 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_addRecipe, + list_create, + product_create, + recipe_addItem, + recipe_create, +) + +# Always use 'app' fixture so ORM gets initialized +pytestmark = pytest.mark.usefixtures('app') + + +@pytest.mark.unit +def test_list_add_recipe_empty(session): + """Test adding a Recipe to an empty list.""" + pEggs = product_create(session, 'Eggs') + + recipe = recipe_create(session, 'Test Recipe') + recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large') + recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup') + + lc = list_create(session, 'Test') + lEntries = list_addRecipe(session, lc.id, recipe.id) + + assert(len(lEntries) == 2) + assert(len(lc.entries) == 2) + + assert(lc.entries[0].product.name == 'Eggs') + assert(lc.entries[0].quantity == '2') + assert(lc.entries[0].notes == 'Extra Large') + + assert(lc.entries[1].product.name == 'Milk') + assert(lc.entries[1].quantity == '1 cup') + + +@pytest.mark.unit +def test_list_add_recipe_merge_quantity(session): + """Test adding a Recipe to a list with existing items, merging quantity.""" + pEggs = product_create(session, 'Eggs') + + recipe = recipe_create(session, 'Test Recipe') + recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large') + recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup') + + lc = list_create(session, 'Test') + list_addItem(session, lc.id, productId=pEggs.id, quantity='12') + lEntries = list_addRecipe(session, lc.id, recipe.id) + + assert(len(lEntries) == 2) + assert(len(lc.entries) == 2) + + assert(lc.entries[0].product.name == 'Eggs') + assert(lc.entries[0].quantity == '12, 2 (Test Recipe)') + assert(lc.entries[0].notes == 'Extra Large') + + assert(lc.entries[1].product.name == 'Milk') + assert(lc.entries[1].quantity == '1 cup') + + +@pytest.mark.unit +def test_list_add_recipe_merge_notes(session): + """Test adding a Recipe to a list with existing items, merging notes.""" + pEggs = product_create(session, 'Eggs') + + recipe = recipe_create(session, 'Test Recipe') + recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large') + recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup') + + lc = list_create(session, 'Test') + list_addItem(session, lc.id, productId=pEggs.id, notes='Brown, Cage Free') + lEntries = list_addRecipe(session, lc.id, recipe.id) + + assert(len(lEntries) == 2) + assert(len(lc.entries) == 2) + + assert(lc.entries[0].product.name == 'Eggs') + assert(lc.entries[0].quantity == '2') + assert(lc.entries[0].notes == 'Brown, Cage Free\nExtra Large') + + assert(lc.entries[1].product.name == 'Milk') + assert(lc.entries[1].quantity == '1 cup')