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
EXPOSE 5151
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]
VOLUME [ "/var/lib/sigl" ]
CMD [ "sigl" ]
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]

View File

@@ -25,10 +25,10 @@ db-downgrade :
poetry run flask db downgrade
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 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 :
poetry run flake8
@@ -54,7 +54,7 @@ test-x :
test-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 \
lint shell serve \
requirements.txt requirements-dev.txt \

View File

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

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",
"version": "0.1.0",
"version": "0.1.1",
"description": "Simple Grocery List",
"dependencies": {
"tailwindcss": "^3.1.6"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "sigl"
version = "0.1.0"
version = "0.1.4"
description = "Simple Grocery List"
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
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.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',
)
})

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ from .mixins import NotesMixin, TimestampMixin
if TYPE_CHECKING:
from .list import ListEntry
from .recipe import RecipeEntry
__all__ = ('Product', 'ProductLocation')
@@ -27,9 +28,11 @@ 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)
recipes: List['RecipeEntry'] = 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 sqlalchemy import func
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(
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] = '',
quantity: Optional[str] = None,
remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""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
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:
@@ -39,9 +52,13 @@ def list_addItem(
if not productName:
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:
product = product_by_id(session, productId)
@@ -61,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()
@@ -99,6 +161,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
for entry in sList.entries:
if entry.crossedOff:
session.delete(entry)
if not entry.product.remember:
session.delete(entry.product)
session.commit()
@@ -108,6 +172,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 +184,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 +199,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 +229,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)
@@ -181,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)
@@ -215,23 +294,32 @@ 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 == True).all() # noqa: E712
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()
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(
session: Session,
name: str,
*,
category: Optional[str],
notes: Optional[str],
category: Optional[str] = '',
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 +340,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 +364,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)
@@ -310,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

View File

@@ -12,6 +12,14 @@
{% else %}
<script src="https://cdn.tailwindcss.com"></script>
{% 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 %}
</head>
<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">
{% block main %}{% endblock %}
</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>Copyright &copy;2022 Asymworks, LLC. All Rights Reserved.</p>
</footer>

View File

@@ -2,51 +2,67 @@
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<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>
{% 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 %}
<form method="post">
<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>
<div class="flex flex-col pb-4">
<label for="product" class="py-1 text-xs 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'>
<option value="" disabled selected>Select a Product</option>
<option value="new">Create a New Product</option>
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
<option value="{{ p.id }}">{{ p.name }}{% if p.category %} (in {{ p.category }}){% endif %}</option>
{% endfor %}
</select>
<label for="product" class="py-1 text-sm text-gray-700 font-semibold">Product:</label>
<jt-autocomplete clearable>
<input id="product" name="productName" class="p-1 border border-gray-200 rounded" list="product-list" x-model="productName" @blur="newProduct=!isExistingProduct(productName)" />
</jt-autocomplete>
<span class="text-sm text-blue-300" x-show="newProduct">New Product</span>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="productId == 'new'">
<div class="w-full sm:mr-1 flex flex-col pb-4">
<label for="productName" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" id="productName" name="productName" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="w-full sm:ml-1 flex flex-col pb-4">
<label for="productCategory" class="py-1 text-xs text-gray-700 font-semibold">Category:</label>
<input type="text" id="productCategory" name="productCategory" class="p-1 text-sm border border-gray-200 rounded" />
<div class="flex flex-row align-center justify-start gap-2 pb-4" x-show="newProduct">
<input type="checkbox" id="rememberProduct" name="remember" checked />
<label for="rememberProduct" class="text-sm text-gray-700 font-semibold">Remember Product</label>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="newProduct">
<div class="w-full flex flex-col pb-4">
<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 border border-gray-200 rounded" />
</div>
</div>
</fieldset>
<div class="flex flex-col pb-4">
<label for="quantity" class="py-1 text-xs text-gray-700 font-semibold">Quantity:</label>
<input type="text" name="quantity" id="quantity" class="p-1 text-sm border border-gray-200 rounded" />
<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 border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea>
<label for="notes" class="py-1 text-sm text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 border border-gray-200 rounded"></textarea>
</div>
<div class="flex items-center justify-between">
<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
</a>
</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>
</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 %}

View File

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

View File

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

View File

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

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