Compare commits

8 Commits

Author SHA1 Message Date
4d0b9b015c Fix empty category handling 2022-12-24 09:45:18 -07:00
bc4f01756d Add Remember flag to Products 2022-12-24 09:38:23 -07:00
66777cfabc Persist list sorting in session 2022-07-15 11:18:39 -07:00
cff6d9cc50 Roll to 0.1.1 2022-07-15 09:30:10 -07:00
2c4f98d567 Fix bottom padding 2022-07-15 09:29:10 -07:00
386341f977 Update Dockerfile 2022-07-15 07:17:03 -07:00
eb1d1e1dd3 Fix Code Styling 2022-07-14 17:17:11 -07:00
21ffc736bc Add Pre-Commit Hooks 2022-07-14 17:12:35 -07:00
19 changed files with 323 additions and 59 deletions

30
.pre-commit-config.yaml Normal file
View 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

View File

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

View File

@@ -54,7 +54,7 @@ test-x :
test-wip : test-wip :
poetry run python -m pytest tests -m wip poetry run python -m pytest tests -m wip
.PHONY : css docker \ .PHONY : css docker docker-deploy \
db-init db-migrate db-upgrad db-downgrade \ db-init db-migrate db-upgrad db-downgrade \
lint shell serve \ lint shell serve \
requirements.txt requirements-dev.txt \ requirements.txt requirements-dev.txt \

View File

@@ -0,0 +1,26 @@
"""empty message
Revision ID: c28b3a6cdc3a
Revises: 22dc32e475dd
Create Date: 2022-12-24 08:56:13.784788
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c28b3a6cdc3a'
down_revision = '22dc32e475dd'
branch_labels = None
depends_on = None
def upgrade():
# Add the 'remember' column and set to true (original default)
op.add_column('products', sa.Column('remember', sa.Boolean(), nullable=True, default=True))
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('products', 'remember')
# ### end Alembic commands ###

View File

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

View File

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

View File

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

View File

@@ -4,27 +4,18 @@ 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 .globals import db from .globals import db
from .tables import ( from .tables import list_entries, lists, product_locations, products
list_entries,
lists,
product_locations,
products,
)
__all__ = ('init_orm', ) __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,

View File

@@ -64,6 +64,7 @@ products = db.Table(
db.Column('name', db.String(128), nullable=False), db.Column('name', db.String(128), nullable=False),
db.Column('category', db.String(128), nullable=False, index=True), db.Column('category', db.String(128), nullable=False, index=True),
db.Column('defaultQty', db.String(128), default=None), db.Column('defaultQty', db.String(128), default=None),
db.Column('remember', db.Boolean, nullable=False, default=True),
# Mixin Columns # Mixin Columns
db.Column('notes', db.String(), default=None), db.Column('notes', db.String(), default=None),

View File

@@ -5,7 +5,7 @@ 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
@@ -27,6 +27,7 @@ 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)

View File

@@ -9,6 +9,7 @@ from typing import List, Optional, Union
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, ShoppingList
@@ -16,11 +17,12 @@ 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] = None,
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 +30,10 @@ def list_addItem(
product by ID and add it to the list. If the `product` parameter is not product by ID and add it to the list. If the `product` parameter is not
provided, a new `Product` will be created with the provided `productName` provided, a new `Product` will be created with the provided `productName`
and `productCategory` values. and `productCategory` values.
If the `remember` parameter is provided and is `False`, the product will
be removed along with the list item, and it will not be offered as a
suggestion when adding items.
""" """
sList = list_by_id(session, id) sList = list_by_id(session, id)
if not sList: if not sList:
@@ -39,6 +45,8 @@ def list_addItem(
raise DomainError('Product Name cannot be empty') raise DomainError('Product Name cannot be empty')
product = Product(name=productName, category=productCategory) product = Product(name=productName, category=productCategory)
if remember is not None:
product.remember = remember
session.add(product) session.add(product)
@@ -96,6 +104,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
raise NotFoundError(f'List {id} does not exist') raise NotFoundError(f'List {id} does not exist')
for entry in sList.entries: for entry in sList.entries:
if not entry.product.remember:
session.delete(entry.product)
if entry.crossedOff: if entry.crossedOff:
session.delete(entry) session.delete(entry)
@@ -107,6 +117,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 +129,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 +144,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 +152,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 +174,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)
@@ -214,7 +226,7 @@ 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]:
@@ -226,11 +238,15 @@ 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 +267,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 +291,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)

View File

@@ -52,7 +52,7 @@
<main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300"> <main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
{% block main %}{% endblock %} {% block main %}{% endblock %}
</main> </main>
<footer class="max-w-3xl mx-auto flex flex-col mt-1 px-2 text-xs text-gray-600"> <footer class="max-w-3xl mx-auto flex flex-col mt-1 mb-24 px-2 text-xs text-gray-600">
<p>Sigl | Simple Grocery List | Version {{ config['APP_VERSION'] }}</p> <p>Sigl | Simple Grocery List | Version {{ config['APP_VERSION'] }}</p>
<p>Copyright &copy;2022 Asymworks, LLC. All Rights Reserved.</p> <p>Copyright &copy;2022 Asymworks, LLC. All Rights Reserved.</p>
</footer> </footer>

View File

@@ -6,17 +6,33 @@ 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 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 +74,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':

View File

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

View File

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

View File

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

View File

@@ -76,3 +76,13 @@ def test_product_model_same_store_fails(session):
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() session.commit()
@pytest.mark.unit
def test_product_model_remembers_by_default(session):
"""Test that the Product defaults to remembering."""
p = Product(name='Eggs', category='Dairy')
session.add(p)
session.commit()
assert p.remember is True

View File

@@ -0,0 +1,62 @@
"""Test the Product Service Entry Points.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import pytest
from sigl.domain.service import product_by_id, product_create, 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

View File

@@ -0,0 +1,107 @@
"""Test the Product Service Entry Points.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import pytest
from sigl.domain.service import (
list_addItem,
list_by_id,
list_create,
list_deleteCrossedOff,
list_entry_set_crossedOff,
product_by_id,
)
# Always use 'app' fixture so ORM gets initialized
pytestmark = pytest.mark.usefixtures('app')
@pytest.mark.unit
def test_list_create_defaults(session):
"""Test newly created Lists are empty."""
lc = list_create(session, 'Test')
list = list_by_id(session, lc.id)
assert list.name == 'Test'
assert not list.entries
@pytest.mark.unit
def test_list_add_product_defaults(session):
"""Test adding a Product to a List."""
list = list_create(session, 'Test')
entry = list_addItem(session, list.id, productName='Eggs', productCategory='Dairy')
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is True
assert len(list.entries) == 1
assert list.entries[0] == entry
@pytest.mark.unit
def test_list_add_product_no_remember(session):
"""Test adding a Product to a List without remembering it."""
list = list_create(session, 'Test')
entry = list_addItem(
session,
list.id,
productName='Eggs',
productCategory='Dairy',
remember=False,
)
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is False
assert len(list.entries) == 1
assert list.entries[0] == entry
@pytest.mark.unit
def test_list_removes_product_with_remember(session):
"""Test that checking off and deleting a remembered Product does not delete the Product Entry."""
list = list_create(session, 'Test')
entry = list_addItem(
session,
list.id,
productName='Eggs',
productCategory='Dairy',
remember=True,
)
pid = entry.product.id
list_entry_set_crossedOff(session, list.id, entry.id, True)
list_deleteCrossedOff(session, list.id)
assert product_by_id(session, pid) is not None
@pytest.mark.unit
def test_list_removes_product_no_remember(session):
"""Test that checking off and deleting a non-remembered Product deletes the Product Entry also."""
list = list_create(session, 'Test')
entry = list_addItem(
session,
list.id,
productName='Eggs',
productCategory='Dairy',
remember=False,
)
pid = entry.product.id
list_entry_set_crossedOff(session, list.id, entry.id, True)
list_deleteCrossedOff(session, list.id)
assert product_by_id(session, pid) is None