Compare commits

11 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
e6e7c20479 Add Docker Compose and Quick Start 2022-07-14 17:09:13 -07:00
3a955a45dc Setup multiplatform Docker builds 2022-07-14 15:53:28 -07:00
ce2a433145 Add Version to Base Template 2022-07-14 15:05:53 -07:00
21 changed files with 369 additions and 62 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

@@ -1,4 +1,4 @@
FROM node:lts-alpine AS builder
FROM --platform=$BUILDPLATFORM node:lts-alpine AS builder
WORKDIR /app
COPY sigl sigl
@@ -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

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

View File

@@ -1,3 +1,22 @@
# Simple Grocery List (Sigl)
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.
## Quick Start
Install [Docker](https://www.docker.com/) and
[Docker Compose](https://docs.docker.com/compose/install/) for your platform.
Then run the following commands to clone the Jade Tree repository and run a
local instance of Jade Tree on your machine. Note that the database migration
only has to be done once to set up a fresh database or to upgrade a database to
the latest schema.
```sh
$ git clone https://github.com/asymworks/sigl.git sigl
$ docker-compose -f sigl/docker-compose.yaml up -d
$ docker-compose -f sigl/docker-compose.yaml \
exec app /home/sigl/docker-entry.sh db upgrade
$ docker-compose -f sigl/docker-compose.yaml restart app
```
Then access the Sigl server at http://localhost:5151

18
docker-compose.yaml Normal file
View File

@@ -0,0 +1,18 @@
---
#
# Docker Compose File for Sigl Server
#
version: '3.7'
services:
app:
image: asymworks/sigl:latest
ports:
- 5151:5151
volumes:
- sigl_data:/var/lib/sigl
- ./docker/config.py:/home/sigl/config.py:ro
restart: always
volumes:
sigl_data:

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",
"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.1"
description = "Simple Grocery List"
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
license = "BSD-3-Clause"

View File

@@ -8,15 +8,11 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
def init_shell(): # pragma: no cover
"""Initialize the Flask Shell Context."""
import datetime
import sqlalchemy as sa
from sigl.database import db
from sigl.domain.models import (
ListEntry,
Product,
ProductLocation,
ShoppingList,
)
from sigl.domain.models import ListEntry, Product, ProductLocation, ShoppingList
return {
# Imports

View File

@@ -4,27 +4,18 @@ Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from sigl.domain.models import (
Product,
ProductLocation,
)
from sigl.domain.models import Product, ProductLocation
from sigl.domain.models.list import ListEntry, ShoppingList
from .globals import db
from .tables import (
list_entries,
lists,
product_locations,
products,
)
from .tables import list_entries, lists, product_locations, products
__all__ = ('init_orm', )
def init_orm():
"""Initialize the Sigl ORM."""
# # List Entries
# List Entries
db.mapper(ListEntry, list_entries, properties={
'product': db.relationship(
Product,

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

View File

@@ -5,7 +5,7 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from dataclasses import dataclass, field
from typing import List, TYPE_CHECKING
from typing import TYPE_CHECKING, List
from .mixins import NotesMixin, TimestampMixin
@@ -27,6 +27,7 @@ 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)

View File

@@ -9,6 +9,7 @@ from typing import List, Optional, Union
from sqlalchemy.orm import Session
from sigl.exc import DomainError, NotFoundError
from .models import ListEntry, Product, ProductLocation, ShoppingList
@@ -16,11 +17,12 @@ 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] = None,
quantity: Optional[str] = None,
remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""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
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,6 +45,8 @@ def list_addItem(
raise DomainError('Product Name cannot be empty')
product = Product(name=productName, category=productCategory)
if remember is not None:
product.remember = remember
session.add(product)
@@ -96,6 +104,8 @@ def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
raise NotFoundError(f'List {id} does not exist')
for entry in sList.entries:
if not entry.product.remember:
session.delete(entry.product)
if entry.crossedOff:
session.delete(entry)
@@ -107,6 +117,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()
@@ -117,8 +129,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)
@@ -132,7 +144,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
@@ -140,7 +152,7 @@ def list_stores(session: Session, id: Optional[int]) -> List[str]:
Product has locations are returned.
"""
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)
if not sList:
@@ -162,8 +174,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)
@@ -214,7 +226,7 @@ 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]:
@@ -226,11 +238,15 @@ 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()
@@ -251,8 +267,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)
@@ -275,8 +291,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)

View File

@@ -52,8 +52,8 @@
<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">
<p>Sigl | Simple Grocery List</p>
<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>
{% block body_scripts %}{% endblock %}

View File

@@ -6,17 +6,33 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from flask import (
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.domain.service import (
list_entry_by_id, lists_all, list_by_id, list_create, list_delete,
list_update, list_addItem, list_deleteItem, list_editItem, list_stores,
list_deleteCrossedOff, list_entry_set_crossedOff,
list_addItem,
list_by_id,
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,
)
from sigl.exc import DomainError, Error, NotFoundError
__all__ = ('bp', )
@@ -58,13 +74,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':

View File

@@ -4,23 +4,20 @@ Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import (
Blueprint,
flash, redirect, render_template, request, url_for
)
from flask import Blueprint, flash, redirect, render_template, request, url_for
from sigl.exc import Error, NotFoundError
from sigl.database import db
from sigl.domain.service import (
list_stores,
products_all,
product_addLocation,
product_by_id,
product_create,
product_delete,
product_update,
product_addLocation,
product_removeLocation,
product_update,
products_all,
)
from sigl.exc import Error, NotFoundError
__all__ = ('bp', )

View File

@@ -6,5 +6,4 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from .factory import create_app
app = create_app()

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