Compare commits

12 Commits

22 changed files with 956 additions and 62 deletions

View File

@@ -41,5 +41,8 @@ RUN mkdir -p /var/lib/sigl \
USER sigl USER sigl
EXPOSE 5151 EXPOSE 5151
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ] VOLUME [ "/var/lib/sigl" ]
CMD [ "sigl" ] CMD [ "sigl" ]
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]

View File

@@ -25,10 +25,10 @@ db-downgrade :
poetry run flask db downgrade poetry run flask db downgrade
docker : docker :
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s)
docker-deploy: docker-deploy:
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest --push docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s) --push
lint : lint :
poetry run flake8 poetry run flake8
@@ -54,7 +54,7 @@ test-x :
test-wip : test-wip :
poetry run python -m pytest tests -m wip poetry run python -m pytest tests -m wip
.PHONY : css docker \ .PHONY : css docker docker-deploy \
db-init db-migrate db-upgrad db-downgrade \ db-init db-migrate db-upgrad db-downgrade \
lint shell serve \ lint shell serve \
requirements.txt requirements-dev.txt \ requirements.txt requirements-dev.txt \

View File

@@ -17,7 +17,7 @@ APP_TOKEN_VALIDITY = 7200
# Development Database Settings (overridden by PyTest app_config Fixture) # Development Database Settings (overridden by PyTest app_config Fixture)
DB_DRIVER = 'sqlite' DB_DRIVER = 'sqlite'
DB_FILE = 'sigl-test.db' DB_FILE = 'sigl-test-next.db'
# Mail Configuration # Mail Configuration
MAIL_ENABLED = True MAIL_ENABLED = True

View File

@@ -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 ###

View File

@@ -0,0 +1,27 @@
"""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))
op.execute('update products set remember=true where remember=null')
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('products', 'remember')
# ### end Alembic commands ###

View File

@@ -1,6 +1,6 @@
{ {
"name": "sigl", "name": "sigl",
"version": "0.1.0", "version": "0.1.1",
"description": "Simple Grocery List", "description": "Simple Grocery List",
"dependencies": { "dependencies": {
"tailwindcss": "^3.1.6" "tailwindcss": "^3.1.6"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "sigl" name = "sigl"
version = "0.1.0" version = "0.1.4"
description = "Simple Grocery List" description = "Simple Grocery List"
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"] authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
license = "BSD-3-Clause" license = "BSD-3-Clause"

View File

@@ -6,9 +6,17 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from sigl.domain.models import Product, ProductLocation from sigl.domain.models import Product, ProductLocation
from sigl.domain.models.list import ListEntry, ShoppingList from sigl.domain.models.list import ListEntry, ShoppingList
from sigl.domain.models.recipe import Recipe, RecipeEntry
from .globals import db 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', ) __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 # Products
db.mapper(Product, products, properties={ db.mapper(Product, products, properties={
'entries': db.relationship( 'entries': db.relationship(
@@ -34,6 +54,11 @@ def init_orm():
back_populates='product', back_populates='product',
cascade='all, delete-orphan', cascade='all, delete-orphan',
), ),
'recipes': db.relationship(
RecipeEntry,
back_populates='product',
cascade='all, delete-orphan',
),
'locations': db.relationship( 'locations': db.relationship(
ProductLocation, ProductLocation,
back_populates='product', back_populates='product',
@@ -57,3 +82,12 @@ def init_orm():
cascade='all, delete-orphan', cascade='all, delete-orphan',
) )
}) })
# Recipes
db.mapper(Recipe, recipes, properties={
'entries': db.relationship(
RecipeEntry,
back_populates='recipe',
cascade='all, delete-orphan',
)
})

View File

@@ -64,6 +64,7 @@ products = db.Table(
db.Column('name', db.String(128), nullable=False), db.Column('name', db.String(128), nullable=False),
db.Column('category', db.String(128), nullable=False, index=True), db.Column('category', db.String(128), nullable=False, index=True),
db.Column('defaultQty', db.String(128), default=None), db.Column('defaultQty', db.String(128), default=None),
db.Column('remember', db.Boolean, nullable=False, default=True),
# Mixin Columns # Mixin Columns
db.Column('notes', db.String(), default=None), db.Column('notes', db.String(), default=None),
@@ -93,3 +94,39 @@ product_locations = db.Table(
db.Column('createdAt', db.DateTime(), default=None), db.Column('createdAt', db.DateTime(), default=None),
db.Column('modifiedAt', 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),
)

View File

@@ -6,10 +6,13 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from .list import ListEntry, ShoppingList from .list import ListEntry, ShoppingList
from .product import Product, ProductLocation from .product import Product, ProductLocation
from .recipe import Recipe, RecipeEntry
__all__ = ( __all__ = (
'ListEntry', 'ListEntry',
'Product', 'Product',
'ProductLocation', 'ProductLocation',
'Recipe',
'RecipeEntry',
'ShoppingList', 'ShoppingList',
) )

View File

@@ -11,6 +11,7 @@ from .mixins import NotesMixin, TimestampMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from .list import ListEntry from .list import ListEntry
from .recipe import RecipeEntry
__all__ = ('Product', 'ProductLocation') __all__ = ('Product', 'ProductLocation')
@@ -27,9 +28,11 @@ class Product(NotesMixin, TimestampMixin):
name: str = None name: str = None
category: str = None category: str = None
defaultQty: str = None defaultQty: str = None
remember: bool = True
# Relationship Fields # Relationship Fields
entries: List['ListEntry'] = field(default_factory=list) entries: List['ListEntry'] = field(default_factory=list)
recipes: List['RecipeEntry'] = field(default_factory=list)
locations: List['ProductLocation'] = field(default_factory=list) locations: List['ProductLocation'] = field(default_factory=list)

View File

@@ -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)

View File

@@ -6,22 +6,31 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from typing import List, Optional, Union from typing import List, Optional, Union
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sigl.exc import DomainError, NotFoundError 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( def list_addItem(
session: Session, session: Session,
id: int, id: int,
*, *,
productId: Optional[int], productId: Optional[int] = None,
productName: Optional[str], productName: Optional[str] = None,
productCategory: Optional[str], productCategory: Optional[str] = '',
quantity: Optional[str], quantity: Optional[str] = None,
notes: Optional[str], remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> ListEntry: ) -> ListEntry:
"""Add a Product to a Shopping List. """Add a Product to a Shopping List.
@@ -29,6 +38,10 @@ def list_addItem(
product by ID and add it to the list. If the `product` parameter is not 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` provided, a new `Product` will be created with the provided `productName`
and `productCategory` values. 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) sList = list_by_id(session, id)
if not sList: if not sList:
@@ -39,9 +52,13 @@ def list_addItem(
if not productName: if not productName:
raise DomainError('Product Name cannot be empty') raise DomainError('Product Name cannot be empty')
product = Product(name=productName, category=productCategory) 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) session.add(product)
else: else:
product = product_by_id(session, productId) product = product_by_id(session, productId)
@@ -61,6 +78,51 @@ def list_addItem(
return entry 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]: def lists_all(session: Session) -> List[ShoppingList]:
"""Return all Shopping Lists.""" """Return all Shopping Lists."""
return session.query(ShoppingList).all() return session.query(ShoppingList).all()
@@ -99,6 +161,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
for entry in sList.entries: for entry in sList.entries:
if entry.crossedOff: if entry.crossedOff:
session.delete(entry) session.delete(entry)
if not entry.product.remember:
session.delete(entry.product)
session.commit() session.commit()
@@ -108,6 +172,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
def list_deleteItem(session: Session, listId: int, entryId: int): def list_deleteItem(session: Session, listId: int, entryId: int):
"""Delete an Entry from a Shopping List.""" """Delete an Entry from a Shopping List."""
entry = list_entry_by_id(session, listId, entryId) entry = list_entry_by_id(session, listId, entryId)
if not entry.product.remember:
session.delete(entry.product)
session.delete(entry) session.delete(entry)
session.commit() session.commit()
@@ -118,8 +184,8 @@ def list_editItem(
listId: int, listId: int,
entryId: int, entryId: int,
*, *,
quantity: Optional[str], quantity: Optional[str] = None,
notes: Optional[str], notes: Optional[str] = None,
) -> ListEntry: ) -> ListEntry:
"""Edit an Entry on a Shopping List.""" """Edit an Entry on a Shopping List."""
entry = list_entry_by_id(session, listId, entryId) entry = list_entry_by_id(session, listId, entryId)
@@ -133,7 +199,7 @@ def list_editItem(
return entry 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. """Get a list of all Stores for the List.
This helper returns a list of all Stores for which the Products in the This helper returns a list of all Stores for which the Products in the
@@ -163,8 +229,8 @@ def list_stores(session: Session, id: Optional[int]) -> List[str]:
def list_update( def list_update(
session: Session, session: Session,
id: int, id: int,
name: Union[str, None], name: Union[str, None] = None,
notes: Union[str, None], notes: Union[str, None] = None,
) -> ShoppingList: ) -> ShoppingList:
"""Update the Name and/or Notes of a Shopping List.""" """Update the Name and/or Notes of a Shopping List."""
sList = list_by_id(session, id) sList = list_by_id(session, id)
@@ -181,6 +247,19 @@ def list_update(
return sList 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]: def list_entry_by_id(session: Session, listId: int, entryId: int) -> Optional[ListEntry]:
"""Load a specific Shopping List Entry.""" """Load a specific Shopping List Entry."""
sList = list_by_id(session, listId) sList = list_by_id(session, listId)
@@ -215,23 +294,32 @@ def list_entry_set_crossedOff(session: Session, listId: int, entryId: int, cross
def products_all(session: Session) -> List[Product]: def products_all(session: Session) -> List[Product]:
"""Return all Products.""" """Return all Products."""
return session.query(Product).all() return session.query(Product).filter(Product.remember == True).all() # noqa: E712
def product_by_id(session: Session, id: int) -> Optional[Product]: def product_by_id(session: Session, id: int) -> Optional[Product]:
"""Load a specific Product.""" """Load a specific Product by Id."""
return session.query(Product).filter(Product.id == id).one_or_none() return session.query(Product).filter(Product.id == id).one_or_none()
def product_by_name(session: Session, name: str) -> Optional[Product]:
"""Load a specific Product by Name."""
return session.query(Product).filter(func.lower(Product.name) == func.lower(name)).one_or_none()
def product_create( def product_create(
session: Session, session: Session,
name: str, name: str,
*, *,
category: Optional[str], category: Optional[str] = '',
notes: Optional[str], remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> Product: ) -> Product:
"""Create a new Product.""" """Create a new Product."""
product = Product(name=name, category=category, notes=notes) product = Product(name=name, category=category, notes=notes)
if remember is not None:
product.remember = remember
session.add(product) session.add(product)
session.commit() session.commit()
@@ -252,8 +340,8 @@ def product_update(
session: Session, session: Session,
id: int, id: int,
name: str, name: str,
category: Optional[str], category: Optional[str] = None,
notes: Optional[str], notes: Optional[str] = None,
) -> Product: ) -> Product:
"""Update a Product.""" """Update a Product."""
product = product_by_id(session, id) product = product_by_id(session, id)
@@ -276,8 +364,8 @@ def product_addLocation(
id: int, id: int,
store: str, store: str,
*, *,
aisle: Optional[str], aisle: Optional[str] = None,
bin: Optional[str] bin: Optional[str] = None,
) -> ProductLocation: ) -> ProductLocation:
"""Add a Store Location to a Product.""" """Add a Store Location to a Product."""
product = product_by_id(session, id) product = product_by_id(session, id)
@@ -310,3 +398,159 @@ def product_removeLocation(
session.delete(loc) session.delete(loc)
session.commit() 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

View File

@@ -12,6 +12,14 @@
{% else %} {% else %}
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
{% endif %} {% endif %}
<style>
:root {
/* Match with TailWind border-gray-200 */
--jt-control-border-color: rgb(229 231 235);
--jt-control-border-hover-color: rgb(229 231 235);
--jt-control-focus-outline-color: #07f;
}
</style>
{% block head_styles %}{% endblock %} {% block head_styles %}{% endblock %}
</head> </head>
<body class="h-full bg-gray-200"> <body class="h-full bg-gray-200">
@@ -52,7 +60,7 @@
<main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300"> <main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
{% block main %}{% endblock %} {% block main %}{% endblock %}
</main> </main>
<footer class="max-w-3xl mx-auto flex flex-col mt-1 px-2 text-xs text-gray-600"> <footer class="max-w-3xl mx-auto flex flex-col mt-1 mb-24 px-2 text-xs text-gray-600">
<p>Sigl | Simple Grocery List | Version {{ config['APP_VERSION'] }}</p> <p>Sigl | Simple Grocery List | Version {{ config['APP_VERSION'] }}</p>
<p>Copyright &copy;2022 Asymworks, LLC. All Rights Reserved.</p> <p>Copyright &copy;2022 Asymworks, LLC. All Rights Reserved.</p>
</footer> </footer>

View File

@@ -2,51 +2,67 @@
{% block title %}{{ list.name }} | Sigl{% endblock %} {% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %} {% block header %}
<div class="flex justify-between items-center py-1"> <div class="flex justify-between items-center py-1">
<div class="text-sm font-bold text-gray-800">Add Item to {{ list.name }}</div> <div class="font-bold text-gray-800">Add Item to {{ list.name }}</div>
</div> </div>
{% endblock %} {% endblock %}
{% block head_scripts %}
<script src="https://unpkg.com/@jadetree/ui/dist/components/autocomplete.iife.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@jadetree/ui/css/index.css" />
{% endblock %}
{% block main %} {% block main %}
<form method="post"> <form method="post">
<div class="py-2 px-4 flex flex-col"> <div class="py-2 px-4 flex flex-col">
<fieldset class="flex flex-col" x-data="{productId:''}"> <fieldset class="flex flex-col" x-data="{productName:'',newProduct:false}">
<legend class="sr-only">Select Product to Add</legend> <legend class="sr-only">Select Product to Add</legend>
<div class="flex flex-col pb-4"> <div class="flex flex-col pb-4">
<label for="product" class="py-1 text-xs text-gray-700 font-semibold">Product:</label> <label for="product" class="py-1 text-sm text-gray-700 font-semibold">Product:</label>
<select id="product" name="product" class="flex-grow p-1 text-sm bg-white border rounded" x-model='productId'> <jt-autocomplete clearable>
<option value="" disabled selected>Select a Product</option> <input id="product" name="productName" class="p-1 border border-gray-200 rounded" list="product-list" x-model="productName" @blur="newProduct=!isExistingProduct(productName)" />
<option value="new">Create a New Product</option> </jt-autocomplete>
{% for p in products|sort(attribute='category')|sort(attribute='name') %} <span class="text-sm text-blue-300" x-show="newProduct">New Product</span>
<option value="{{ p.id }}">{{ p.name }}{% if p.category %} (in {{ p.category }}){% endif %}</option>
{% endfor %}
</select>
</div> </div>
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="productId == 'new'"> <div class="flex flex-row align-center justify-start gap-2 pb-4" x-show="newProduct">
<div class="w-full sm:mr-1 flex flex-col pb-4"> <input type="checkbox" id="rememberProduct" name="remember" checked />
<label for="productName" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label> <label for="rememberProduct" class="text-sm text-gray-700 font-semibold">Remember Product</label>
<input type="text" id="productName" name="productName" class="p-1 text-sm border border-gray-200 rounded" /> </div>
</div> <div class="flex flex-col sm:flex-row sm:justify-between" x-show="newProduct">
<div class="w-full sm:ml-1 flex flex-col pb-4"> <div class="w-full flex flex-col pb-4">
<label for="productCategory" class="py-1 text-xs text-gray-700 font-semibold">Category:</label> <label for="productCategory" class="py-1 text-sm text-gray-700 font-semibold">Category:</label>
<input type="text" id="productCategory" name="productCategory" class="p-1 text-sm border border-gray-200 rounded" /> <input type="text" id="productCategory" name="productCategory" class="p-1 border border-gray-200 rounded" />
</div> </div>
</div> </div>
</fieldset> </fieldset>
<div class="flex flex-col pb-4"> <div class="flex flex-col pb-4">
<label for="quantity" class="py-1 text-xs text-gray-700 font-semibold">Quantity:</label> <label for="quantity" class="py-1 text-sm text-gray-700 font-semibold">Quantity:</label>
<input type="text" name="quantity" id="quantity" class="p-1 text-sm border border-gray-200 rounded" /> <input type="text" name="quantity" id="quantity" class="p-1 border border-gray-200 rounded" />
</div> </div>
<div class="flex flex-col pb-4"> <div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label> <label for="notes" class="py-1 text-sm text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea> <textarea name="notes" id="notes" class="p-1 border border-gray-200 rounded"></textarea>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex justify-start items-start"> <div class="flex justify-start items-start">
<a href="{{ url_for('lists.detail', id=list.id) }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center"> <a href="{{ url_for('lists.detail', id=list.id) }}" class="px-2 py-1 text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel Cancel
</a> </a>
</div> </div>
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Add Item</button> <button type="submit" class="px-2 py-1 border rounded text-white bg-blue-600 hover:bg-blue-700">Add Item</button>
</div> </div>
</div> </div>
</form> </form>
<datalist id="product-list">
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
<option{% if p.category %} data-category="{{ p.category }}"{% endif %}>{{ p.name }}</option>
{% endfor %}
</datalist>
{% endblock %}
{% block body_scripts %}
<script language="javascript">
function isExistingProduct(product) {
if (!product) return true;
const products = Array.from(document.querySelectorAll('#product-list option'))
.map((opt) => opt.textContent.toLowerCase().trim());
return products.includes(product.toLowerCase().trim());
}
</script>
{% endblock %} {% endblock %}

View File

@@ -6,12 +6,14 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from flask import ( from flask import (
Blueprint, Blueprint,
current_app,
flash, flash,
jsonify, jsonify,
make_response, make_response,
redirect, redirect,
render_template, render_template,
request, request,
session,
url_for, url_for,
) )
@@ -73,13 +75,21 @@ def detail(id):
if not sList: if not sList:
raise NotFoundError(f'List {id} not found') raise NotFoundError(f'List {id} not found')
sortBy = request.args.get('sort', 'none') # Load sorting from request (or session)
sortStore = request.args.get('store', '') sSort = session.get(f'sorting-{id}', {})
sortBy = request.args.get('sort', sSort.get('sort', 'none'))
sortStore = request.args.get('store', sSort.get('store', ''))
if sortBy not in ('none', 'category', 'store'): if sortBy not in ('none', 'category', 'store'):
flash(f'Invalid sorting mode {sortBy}', 'warning') flash(f'Invalid sorting mode {sortBy}', 'warning')
sortBy = 'category' sortBy = 'category'
# Store sorting back to the session
session[f'sorting-{id}'] = {
'sort': sortBy,
'store': sortStore,
}
groups = dict() groups = dict()
for e in sList.entries: for e in sList.entries:
if sortBy == 'category': if sortBy == 'category':
@@ -216,7 +226,7 @@ def addItem(id):
sList = list_by_id(db.session, id) sList = list_by_id(db.session, id)
products = products_all(db.session) products = products_all(db.session)
if request.method == 'POST': if request.method == 'POST':
if 'product' not in request.form: if 'productName' not in request.form:
flash( flash(
'An internal error occurred. Please reload the page and try again', 'An internal error occurred. Please reload the page and try again',
'error' 'error'
@@ -227,21 +237,20 @@ def addItem(id):
products=products, products=products,
) )
productId = request.form['product']
productName = request.form.get('productName', '').strip() productName = request.form.get('productName', '').strip()
productCategory = request.form.get('productCategory', '').strip() productCategory = request.form.get('productCategory', '').strip()
remember = request.form.get('remember', 'off') == 'on'
quantity = request.form.get('quantity', '').strip() quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip() notes = request.form.get('notes', '').strip()
if productId == 'new' or productId == '': current_app.logger.info(f'Remember Value: {remember}')
productId = None
list_addItem( list_addItem(
db.session, db.session,
id, id,
productId=productId,
productName=productName, productName=productName,
productCategory=productCategory, productCategory=productCategory,
remember=remember,
quantity=quantity, quantity=quantity,
notes=notes, notes=notes,
) )

View File

@@ -128,7 +128,8 @@ def session(request, monkeypatch, app):
monkeypatch.setattr(_db, 'session', session) monkeypatch.setattr(_db, 'session', session)
def teardown(): def teardown():
transaction.rollback() if transaction.is_active:
transaction.rollback()
connection.close() connection.close()
session.remove() session.remove()

View File

@@ -76,3 +76,13 @@ def test_product_model_same_store_fails(session):
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() 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

View File

@@ -0,0 +1,75 @@
"""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_by_name,
product_create,
products_all,
)
# 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_without_category(session):
"""Test that a Product can be created with a blank Category."""
pc = product_create(session, 'Eggs')
assert pc.id is not None
assert pc.name == 'Eggs'
assert pc.category == ''
assert pc.remember is True
assert not pc.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
@pytest.mark.unit
def test_product_all_items_skips_non_remembered(session):
"""Test that querying all Product items skips non-remembered Products."""
p1 = product_create(session, 'Apples')
p2 = product_create(session, 'Bananas', remember=False)
p3 = product_create(session, 'Carrots')
products = products_all(session)
assert len(products) == 2
assert p1 in products
assert p3 in products
assert p2 not in products
@pytest.mark.unit
def test_product_lookup_by_name(session):
"""Test that a Product can be looked up by Name (case-insensitive)."""
p1 = product_create(session, 'Apples')
product = product_by_name(session, 'apples')
assert product == p1

View File

@@ -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

View File

@@ -0,0 +1,144 @@
"""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,
product_create,
)
# 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_by_id(session):
"""Test adding an existing Product to a List by Id."""
p1 = product_create(session, 'Eggs', category='Dairy')
list = list_create(session, 'Test')
entry = list_addItem(session, list.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(list.entries) == 1
assert list.entries[0] == entry
@pytest.mark.unit
def test_list_add_product_by_name(session):
"""Test adding an existing Product to a List by Name."""
product_create(session, 'Eggs', category='Dairy')
list = list_create(session, 'Test')
entry = list_addItem(session, list.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(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

View File

@@ -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')