Compare commits
14 Commits
e6e7c20479
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd38bdc56 | |||
| a1f8bc791d | |||
| 8818219c83 | |||
| 5c92d0ba58 | |||
| cd9a5914bd | |||
| 7b236b1c2f | |||
| 4d0b9b015c | |||
| bc4f01756d | |||
| 66777cfabc | |||
| cff6d9cc50 | |||
| 2c4f98d567 | |||
| 386341f977 | |||
| eb1d1e1dd3 | |||
| 21ffc736bc |
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
|
rev: v2.7.4
|
||||||
|
hooks:
|
||||||
|
- id: pyupgrade
|
||||||
|
args: [--py37-plus]
|
||||||
|
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.0.0
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
args:
|
||||||
|
- --ignore-words-list=sigl
|
||||||
|
- --skip="./.*,*.csv,*.json"
|
||||||
|
- --quiet-level=2
|
||||||
|
exclude_types: [csv, json]
|
||||||
|
|
||||||
|
- repo: https://gitlab.com/pycqa/flake8
|
||||||
|
rev: 3.8.4
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
additional_dependencies:
|
||||||
|
- flake8-docstrings==1.5.0
|
||||||
|
- pydocstyle==5.1.1
|
||||||
|
files: ^(sigl|tests)/.+\.py$
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/isort
|
||||||
|
rev: 5.5.3
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
@@ -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" ]
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
47
migrations/versions/3d0cab7d7747_.py
Normal file
47
migrations/versions/3d0cab7d7747_.py
Normal 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 ###
|
||||||
27
migrations/versions/c28b3a6cdc3a_.py
Normal file
27
migrations/versions/c28b3a6cdc3a_.py
Normal 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 ###
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
def init_shell(): # pragma: no cover
|
def init_shell(): # pragma: no cover
|
||||||
"""Initialize the Flask Shell Context."""
|
"""Initialize the Flask Shell Context."""
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from sigl.database import db
|
from sigl.database import db
|
||||||
from sigl.domain.models import (
|
from sigl.domain.models import ListEntry, Product, ProductLocation, ShoppingList
|
||||||
ListEntry,
|
|
||||||
Product,
|
|
||||||
ProductLocation,
|
|
||||||
ShoppingList,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# Imports
|
# Imports
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ Simple Grocery List (Sigl) | sigl.app
|
|||||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sigl.domain.models import (
|
from sigl.domain.models import Product, ProductLocation
|
||||||
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 (
|
from .tables import (
|
||||||
@@ -16,6 +14,8 @@ from .tables import (
|
|||||||
lists,
|
lists,
|
||||||
product_locations,
|
product_locations,
|
||||||
products,
|
products,
|
||||||
|
recipe_entries,
|
||||||
|
recipes,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ('init_orm', )
|
__all__ = ('init_orm', )
|
||||||
@@ -23,8 +23,7 @@ __all__ = ('init_orm', )
|
|||||||
|
|
||||||
def init_orm():
|
def init_orm():
|
||||||
"""Initialize the Sigl ORM."""
|
"""Initialize the Sigl ORM."""
|
||||||
|
# List Entries
|
||||||
# # List Entries
|
|
||||||
db.mapper(ListEntry, list_entries, properties={
|
db.mapper(ListEntry, list_entries, properties={
|
||||||
'product': db.relationship(
|
'product': db.relationship(
|
||||||
Product,
|
Product,
|
||||||
@@ -36,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(
|
||||||
@@ -43,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',
|
||||||
@@ -66,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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, TYPE_CHECKING
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from .mixins import NotesMixin, TimestampMixin
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
42
sigl/domain/models/recipe.py
Normal file
42
sigl/domain/models/recipe.py
Normal 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)
|
||||||
@@ -6,21 +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.
|
||||||
|
|
||||||
@@ -28,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:
|
||||||
@@ -38,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)
|
||||||
@@ -60,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()
|
||||||
@@ -98,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()
|
||||||
|
|
||||||
@@ -107,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()
|
||||||
@@ -117,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)
|
||||||
@@ -132,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
|
||||||
@@ -140,7 +207,7 @@ def list_stores(session: Session, id: Optional[int]) -> List[str]:
|
|||||||
Product has locations are returned.
|
Product has locations are returned.
|
||||||
"""
|
"""
|
||||||
if id is None:
|
if id is None:
|
||||||
return list(set([loc.store for loc in session.query(ProductLocation).all()]))
|
return list({loc.store for loc in session.query(ProductLocation).all()})
|
||||||
|
|
||||||
sList = list_by_id(session, id)
|
sList = list_by_id(session, id)
|
||||||
if not sList:
|
if not sList:
|
||||||
@@ -162,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)
|
||||||
@@ -180,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)
|
||||||
@@ -214,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()
|
||||||
|
|
||||||
@@ -251,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)
|
||||||
@@ -275,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)
|
||||||
@@ -309,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
|
||||||
|
|||||||
@@ -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 ©2022 Asymworks, LLC. All Rights Reserved.</p>
|
<p>Copyright ©2022 Asymworks, LLC. All Rights Reserved.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -6,17 +6,34 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
flash, jsonify, make_response, redirect, render_template, request, url_for
|
current_app,
|
||||||
|
flash,
|
||||||
|
jsonify,
|
||||||
|
make_response,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from sigl.exc import DomainError, Error, NotFoundError
|
|
||||||
from sigl.database import db
|
from sigl.database import db
|
||||||
from sigl.domain.service import (
|
from sigl.domain.service import (
|
||||||
list_entry_by_id, lists_all, list_by_id, list_create, list_delete,
|
list_addItem,
|
||||||
list_update, list_addItem, list_deleteItem, list_editItem, list_stores,
|
list_by_id,
|
||||||
list_deleteCrossedOff, list_entry_set_crossedOff,
|
list_create,
|
||||||
|
list_delete,
|
||||||
|
list_deleteCrossedOff,
|
||||||
|
list_deleteItem,
|
||||||
|
list_editItem,
|
||||||
|
list_entry_by_id,
|
||||||
|
list_entry_set_crossedOff,
|
||||||
|
list_stores,
|
||||||
|
list_update,
|
||||||
|
lists_all,
|
||||||
products_all,
|
products_all,
|
||||||
)
|
)
|
||||||
|
from sigl.exc import DomainError, Error, NotFoundError
|
||||||
|
|
||||||
__all__ = ('bp', )
|
__all__ = ('bp', )
|
||||||
|
|
||||||
@@ -58,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':
|
||||||
@@ -201,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'
|
||||||
@@ -212,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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,23 +4,20 @@ Simple Grocery List (Sigl) | sigl.app
|
|||||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import (
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
Blueprint,
|
|
||||||
flash, redirect, render_template, request, url_for
|
|
||||||
)
|
|
||||||
|
|
||||||
from sigl.exc import Error, NotFoundError
|
|
||||||
from sigl.database import db
|
from sigl.database import db
|
||||||
from sigl.domain.service import (
|
from sigl.domain.service import (
|
||||||
list_stores,
|
list_stores,
|
||||||
products_all,
|
product_addLocation,
|
||||||
product_by_id,
|
product_by_id,
|
||||||
product_create,
|
product_create,
|
||||||
product_delete,
|
product_delete,
|
||||||
product_update,
|
|
||||||
product_addLocation,
|
|
||||||
product_removeLocation,
|
product_removeLocation,
|
||||||
|
product_update,
|
||||||
|
products_all,
|
||||||
)
|
)
|
||||||
|
from sigl.exc import Error, NotFoundError
|
||||||
|
|
||||||
__all__ = ('bp', )
|
__all__ = ('bp', )
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,4 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
|
|
||||||
from .factory import create_app
|
from .factory import create_app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
75
tests/test_21_product_service.py
Normal file
75
tests/test_21_product_service.py
Normal 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
|
||||||
101
tests/test_22_recipe_service.py
Normal file
101
tests/test_22_recipe_service.py
Normal 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
|
||||||
144
tests/test_23_list_service.py
Normal file
144
tests/test_23_list_service.py
Normal 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
|
||||||
90
tests/test_31_list_recipes.py
Normal file
90
tests/test_31_list_recipes.py
Normal 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')
|
||||||
Reference in New Issue
Block a user