Compare commits

28 Commits

Author SHA1 Message Date
9bd38bdc56 Add Recipes 2023-02-25 16:06:54 -08:00
a1f8bc791d Update Text Sizing to avoid Zoom-In on iOS 2023-01-05 17:05:51 -08:00
8818219c83 Bump to 0.1.3 2023-01-05 15:11:44 -08:00
5c92d0ba58 Match JtAutocomplete to Tailwind Controls 2023-01-05 15:11:05 -08:00
cd9a5914bd Fix DB Migration to c28b3a6cdc3a 2023-01-05 13:54:03 -08:00
7b236b1c2f Use Autocomplete instead of Select in Add to List screen 2023-01-05 10:07:01 -08:00
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
a871562a47 Add Minimal Dockerfile 2022-07-14 15:04:09 -07:00
5002ca093e Add Tailwind CSS 2022-07-14 14:22:21 -07:00
073dc8ab8c Pacify Linter 2022-07-14 14:18:25 -07:00
60aa886635 Add Product Views 2022-07-14 14:14:50 -07:00
0eafe8786d Add Delete Crossed Off Service 2022-07-14 11:05:12 -07:00
20977b2378 Update and harmonize views 2022-07-14 11:04:55 -07:00
2d313a9e75 Added Views 2022-07-14 09:56:03 -07:00
17041c9c8b Add Cross-Off Logic 2022-07-14 08:48:02 -07:00
5e9aacdb8c Add Item View 2022-07-14 06:41:55 -07:00
3064a36b09 Add Item View 2022-07-14 06:35:36 -07:00
b43b254a47 Add Frontend 2022-07-13 17:12:44 -07:00
50 changed files with 3981 additions and 95 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# Ignore .git
.git
# Ignore Test and Coverage Outputs
.htmlcov
.coverage
tests/.pytest*
# Ignore Configuration and JS/CSS source Files
config/
# Ignore Databases
*.db
# Ignore node_packages
node_modules/

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
assets/dist/* assets/dist/*
config/* config/*
docs/_build/* docs/_build/*
static/*
!config/dev.example.py !config/dev.example.py
!config/test.py !config/test.py

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

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
FROM --platform=$BUILDPLATFORM node:lts-alpine AS builder
WORKDIR /app
COPY sigl sigl
COPY src src
COPY Makefile ./
COPY package.json ./
COPY tailwind.config.js ./
RUN apk add build-base \
&& npm install \
&& make css
FROM python:3.8-slim-buster
RUN groupadd -g 5151 sigl \
&& adduser --disabled-password --uid 5151 --gid 5151 sigl
WORKDIR /home/sigl
RUN apt-get update \
&& apt-get -y upgrade \
&& apt-get -y install --no-install-recommends build-essential \
libpq-dev libmariadbclient-dev
COPY requirements.txt ./
RUN python -m venv venv \
&& venv/bin/pip install -r requirements.txt \
&& venv/bin/pip install gunicorn
COPY sigl sigl
COPY migrations migrations
COPY docker/* ./
COPY pyproject.toml ./
COPY --from=builder /app/static ./static
RUN mkdir -p /var/lib/sigl \
&& chown -R sigl:sigl /var/lib/sigl ./ \
&& chmod +x docker-entry.sh
USER sigl
EXPOSE 5151
VOLUME [ "/var/lib/sigl" ]
CMD [ "sigl" ]
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]

View File

@@ -9,6 +9,9 @@ coverage coverage-html coverage-report test test-wip test-x : export FLASK_ENV :
shell-psql serve-psql export : export FLASK_ENV := development shell-psql serve-psql export : export FLASK_ENV := development
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
css :
npx tailwindcss -i ./src/css/tailwind.css -o static/sigl.dist.css
db-init : db-init :
poetry run flask db init poetry run flask db init
@@ -21,9 +24,21 @@ db-upgrade :
db-downgrade : db-downgrade :
poetry run flask db downgrade poetry run flask db downgrade
docker :
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s)
docker-deploy:
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s) --push
lint : lint :
poetry run flake8 poetry run flake8
requirements.txt : poetry.lock
poetry export -f requirements.txt --without-hashes -o requirements.txt
requirements-dev.txt : poetry.lock
poetry export --dev -f requirements.txt --without-hashes -o requirements-dev.txt
serve : serve :
poetry run python -m sigl poetry run python -m sigl
@@ -39,6 +54,8 @@ test-x :
test-wip : test-wip :
poetry run python -m pytest tests -m wip poetry run python -m pytest tests -m wip
.PHONY : db-init db-migrate db-upgrad db-downgrade \ .PHONY : css docker docker-deploy \
db-init db-migrate db-upgrad db-downgrade \
lint shell serve \ lint shell serve \
requirements.txt requirements-dev.txt \
test test-wip test-x test test-wip test-x

View File

@@ -1,3 +1,22 @@
# Simple Grocery List (Sigl) # 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

View File

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

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:

24
docker/config.py Normal file
View File

@@ -0,0 +1,24 @@
"""Sigl Docker Default Configuration.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
# Session and Token Keys
APP_SESSION_KEY = 'sigl-docker-session-key'
APP_TOKEN_KEY = 'sigl-docker-token-key'
# Database Settings
DB_DRIVER = 'sqlite'
DB_FILE = '/var/lib/sigl/sigl-test.db'
# Mail Configuration
MAIL_ENABLED = False
# Logging Configuration
LOGGING_DEST = 'wsgi'
LOGGING_LEVEL = 'debug'
LOGGING_FORMAT = '[%(asctime)s] [%(remote_addr)s - %(url)s] %(levelname)s in %(module)s: %(message)s'
LOGGING_BACKTRACE = False
SOCKETIO_LOGGING = True
ENGINEIO_LOGGING = True

25
docker/docker-entry.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -e
export FLASK_APP=sigl.factory
export FLASK_ENV=production
export SIGL_CONFIG=${SIGL_CONFIG:-/home/sigl/config.py}
export SIGL_PORT=${SIGL_PORT:-5151}
source venv/bin/activate
if [ "$1" = 'sigl' ]; then
exec gunicorn -k eventlet -b :${SIGL_PORT} --access-logfile - --error-logfile - sigl.wsgi:app
fi
if [ "$1" = 'db' ]; then
if [ "$2" = 'downgrade' ]; then
exec flask db downgrade
else
exec flask db upgrade
fi
exit 0
fi
exec "$@"

View File

@@ -0,0 +1,47 @@
"""empty message
Revision ID: 3d0cab7d7747
Revises: c28b3a6cdc3a
Create Date: 2023-02-25 15:37:19.626908
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3d0cab7d7747'
down_revision = 'c28b3a6cdc3a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('recipes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('createdAt', sa.DateTime(), nullable=True),
sa.Column('modifiedAt', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('recipe_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('recipe_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.String(length=128), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('createdAt', sa.DateTime(), nullable=True),
sa.Column('modifiedAt', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['recipe_id'], ['recipes.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('recipe_entries')
op.drop_table('recipes')
# ### end Alembic commands ###

View File

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

1198
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

8
package.json Normal file
View File

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

73
poetry.lock generated
View File

@@ -1,6 +1,6 @@
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.8.0" version = "1.8.1"
description = "A database migration tool for SQLAlchemy." description = "A database migration tool for SQLAlchemy."
category = "main" category = "main"
optional = false optional = false
@@ -84,7 +84,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "6.4.1" version = "6.4.2"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
category = "dev" category = "dev"
optional = false optional = false
@@ -95,30 +95,26 @@ toml = ["tomli"]
[[package]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.2.1" version = "1.16.0"
description = "DNS toolkit" description = "DNS toolkit"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6,<4.0" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras] [package.extras]
dnssec = ["cryptography (>=2.6,<37.0)"] DNSSEC = ["pycryptodome", "ecdsa (>=0.13)"]
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] IDNA = ["idna (>=2.1)"]
doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"]
idna = ["idna (>=2.1,<4.0)"]
trio = ["trio (>=0.14,<0.20)"]
wmi = ["wmi (>=1.5.1,<2.0.0)"]
[[package]] [[package]]
name = "eventlet" name = "eventlet"
version = "0.33.1" version = "0.30.2"
description = "Highly concurrent networking library" description = "Highly concurrent networking library"
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[package.dependencies] [package.dependencies]
dnspython = ">=1.15.0" dnspython = ">=1.15.0,<2.0.0"
greenlet = ">=0.3" greenlet = ">=0.3"
six = ">=1.10.0" six = ">=1.10.0"
@@ -137,7 +133,7 @@ pyflakes = ">=2.4.0,<2.5.0"
[[package]] [[package]]
name = "flask" name = "flask"
version = "2.1.2" version = "2.1.3"
description = "A simple framework for building complex web applications." description = "A simple framework for building complex web applications."
category = "main" category = "main"
optional = false optional = false
@@ -154,18 +150,6 @@ Werkzeug = ">=2.0"
async = ["asgiref (>=3.2)"] async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"] dotenv = ["python-dotenv"]
[[package]]
name = "flask-cors"
version = "3.0.10"
description = "A Flask extension adding a decorator for CORS support"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
Flask = ">=0.9"
Six = "*"
[[package]] [[package]]
name = "flask-mail" name = "flask-mail"
version = "0.9.1" version = "0.9.1"
@@ -310,23 +294,6 @@ category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[[package]]
name = "marshmallow"
version = "3.17.0"
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
packaging = ">=17.0"
[package.extras]
dev = ["pytest", "pytz", "simplejson", "mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)", "tox"]
docs = ["sphinx (==4.5.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.8)"]
lint = ["mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)"]
tests = ["pytest", "pytz", "simplejson"]
[[package]] [[package]]
name = "mccabe" name = "mccabe"
version = "0.6.1" version = "0.6.1"
@@ -339,7 +306,7 @@ python-versions = "*"
name = "packaging" name = "packaging"
version = "21.3" version = "21.3"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main" category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -386,7 +353,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
name = "pyparsing" name = "pyparsing"
version = "3.0.9" version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars" description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main" category = "dev"
optional = false optional = false
python-versions = ">=3.6.8" python-versions = ">=3.6.8"
@@ -503,20 +470,20 @@ watchdog = ["watchdog"]
[[package]] [[package]]
name = "zipp" name = "zipp"
version = "3.8.0" version = "3.8.1"
description = "Backport of pathlib-compatible object wrapper for zip files" description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.extras] [package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.9" python-versions = "^3.9"
content-hash = "9a061ae1f8fa92a76f3d49706e062795362cdeb6f9b6067105f7dc6227e66018" content-hash = "3679bc9fc39e1e9f7160045f491258dfb6fce1b57b2b7963f11a636ee26106d9"
[metadata.files] [metadata.files]
alembic = [] alembic = []
@@ -533,17 +500,16 @@ codespell = [
] ]
colorama = [] colorama = []
coverage = [] coverage = []
dnspython = [] dnspython = [
{file = "dnspython-1.16.0-py2.py3-none-any.whl", hash = "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"},
{file = "dnspython-1.16.0.zip", hash = "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01"},
]
eventlet = [] eventlet = []
flake8 = [ flake8 = [
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
] ]
flask = [] flask = []
flask-cors = [
{file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"},
{file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"},
]
flask-mail = [ flask-mail = [
{file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"}, {file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"},
] ]
@@ -561,7 +527,6 @@ itsdangerous = []
jinja2 = [] jinja2 = []
mako = [] mako = []
markupsafe = [] markupsafe = []
marshmallow = []
mccabe = [ mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},

View File

@@ -1,29 +1,27 @@
[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"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.9" python = "^3.9"
Flask = "^2.1.2"
Flask-SQLAlchemy = "^2.5.1"
Flask-Mail = "^0.9.1"
Flask-SocketIO = "^5.2.0"
eventlet = "^0.33.1"
Flask-Cors = "^3.0.10"
alembic = "^1.8.0" alembic = "^1.8.0"
SQLAlchemy = "^1.4.39" eventlet = "0.30.2"
Flask = "^2.1.2"
Flask-Mail = "^0.9.1"
Flask-Migrate = "^3.1.0" Flask-Migrate = "^3.1.0"
marshmallow = "^3.17.0" Flask-SocketIO = "^5.2.0"
Flask-SQLAlchemy = "^2.5.1"
SQLAlchemy = "^1.4.39"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
codespell = "^2.1.0"
coverage = "^6.4.1" coverage = "^6.4.1"
flake8 = "^4.0.1" flake8 = "^4.0.1"
pytest = "^7.1.2"
isort = "^5.10.1" isort = "^5.10.1"
codespell = "^2.1.0" pytest = "^7.1.2"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

40
requirements-dev.txt Normal file
View File

@@ -0,0 +1,40 @@
alembic==1.8.1; python_version >= "3.7"
atomicwrites==1.4.1; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.4.0"
attrs==21.4.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7"
bidict==0.22.0; python_version >= "3.7"
blinker==1.4
click==8.1.3; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
codespell==2.1.0; python_version >= "3.5"
colorama==0.4.5; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" and platform_system == "Windows" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.5.0" and platform_system == "Windows"
coverage==6.4.2; python_version >= "3.7"
dnspython==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0"
eventlet==0.30.2
flake8==4.0.1; python_version >= "3.6"
flask-mail==0.9.1
flask-migrate==3.1.0; python_version >= "3.6"
flask-socketio==5.2.0; python_version >= "3.6"
flask-sqlalchemy==2.5.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
flask==2.1.3; python_version >= "3.7"
greenlet==1.1.2; python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") and python_full_version >= "3.5.0"
importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7")
iniconfig==1.1.1; python_version >= "3.7"
isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0"
itsdangerous==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
jinja2==3.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
mako==1.2.1; python_version >= "3.7"
markupsafe==2.1.1; python_version >= "3.7"
mccabe==0.6.1; python_version >= "3.6"
packaging==21.3; python_version >= "3.7"
pluggy==1.0.0; python_version >= "3.7"
py==1.11.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7"
pycodestyle==2.8.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
pyflakes==2.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6"
pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.7"
pytest==7.1.2; python_version >= "3.7"
python-engineio==4.3.3; python_version >= "3.6"
python-socketio==5.7.0; python_version >= "3.6"
six==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0"
sqlalchemy==1.4.39; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0")
tomli==2.0.1; python_version >= "3.7"
werkzeug==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
zipp==3.8.1; python_version < "3.10" and python_version >= "3.7"

24
requirements.txt Normal file
View File

@@ -0,0 +1,24 @@
alembic==1.8.1; python_version >= "3.7"
bidict==0.22.0; python_version >= "3.7"
blinker==1.4
click==8.1.3; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
colorama==0.4.5; python_version >= "3.7" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.7" and python_full_version >= "3.5.0"
dnspython==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0"
eventlet==0.30.2
flask-mail==0.9.1
flask-migrate==3.1.0; python_version >= "3.6"
flask-socketio==5.2.0; python_version >= "3.6"
flask-sqlalchemy==2.5.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
flask==2.1.3; python_version >= "3.7"
greenlet==1.1.2; python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") and python_full_version >= "3.5.0"
importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7")
itsdangerous==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
jinja2==3.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
mako==1.2.1; python_version >= "3.7"
markupsafe==2.1.1; python_version >= "3.7"
python-engineio==4.3.3; python_version >= "3.6"
python-socketio==5.7.0; python_version >= "3.6"
six==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0"
sqlalchemy==1.4.39; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0")
werkzeug==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
zipp==3.8.1; python_version < "3.10" and python_version >= "3.7"

View File

@@ -9,4 +9,4 @@ from .socketio import socketio
app = create_app() app = create_app()
socketio.run(app) socketio.run(app, host='0.0.0.0')

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

View File

@@ -64,6 +64,7 @@ products = db.Table(
db.Column('name', db.String(128), nullable=False), db.Column('name', db.String(128), nullable=False),
db.Column('category', db.String(128), nullable=False, index=True), db.Column('category', db.String(128), nullable=False, index=True),
db.Column('defaultQty', db.String(128), default=None), db.Column('defaultQty', db.String(128), default=None),
db.Column('remember', db.Boolean, nullable=False, default=True),
# Mixin Columns # Mixin Columns
db.Column('notes', db.String(), default=None), db.Column('notes', db.String(), default=None),
@@ -93,3 +94,39 @@ product_locations = db.Table(
db.Column('createdAt', db.DateTime(), default=None), db.Column('createdAt', db.DateTime(), default=None),
db.Column('modifiedAt', db.DateTime(), default=None), db.Column('modifiedAt', db.DateTime(), default=None),
) )
#: Recipe Table
recipes = db.Table(
'recipes',
# Primary Key
db.Column('id', db.Integer, primary_key=True),
# List Attributes
db.Column('name', db.String(128), nullable=False),
# Mixin Columns
db.Column('notes', db.String(), default=None),
db.Column('createdAt', db.DateTime(), default=None),
db.Column('modifiedAt', db.DateTime(), default=None),
)
#: Recipe Entry Table
recipe_entries = db.Table(
'recipe_entries',
# Primary Key
db.Column('id', db.Integer, primary_key=True),
# Shopping List and Product Link
db.Column('recipe_id', db.ForeignKey('recipes.id'), nullable=False),
db.Column('product_id', db.ForeignKey('products.id'), nullable=False),
# Entry Attributes
db.Column('quantity', db.String(128), default=None),
# Mixin Columns
db.Column('notes', db.String(), default=None),
db.Column('createdAt', db.DateTime(), default=None),
db.Column('modifiedAt', db.DateTime(), default=None),
)

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
"""Sigl Recipe Domain Model.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2023 Asymworks, LLC. All Rights Reserved.
"""
from dataclasses import dataclass, field
from typing import List
from .mixins import NotesMixin, TimestampMixin
from .product import Product
__all__ = ('Recipe', 'RecipeEntry')
@dataclass
class RecipeEntry(NotesMixin, TimestampMixin):
"""Information about a Product in a Recipe.
This class contains information about a Product that is in a recipe
list, including the quantity to be purchased and notes about the entry.
"""
id: int = None
quantity: str = None
# Relationship Fields
product: Product = None
recipe: 'Recipe' = None
@dataclass
class Recipe(NotesMixin, TimestampMixin):
"""Top-Level Recipe.
Contains a collection of `RecipeEntry` items which are intended to be
added to shopping lists as a group.
"""
id: int = None
name: str = None
# Relationship Fields
entries: List[RecipeEntry] = field(default_factory=list)

556
sigl/domain/service.py Normal file
View File

@@ -0,0 +1,556 @@
"""Sigl Domain Services.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from typing import List, Optional, Union
from sqlalchemy import func
from sqlalchemy.orm import Session
from sigl.exc import DomainError, NotFoundError
from .models import (
ListEntry,
Product,
ProductLocation,
Recipe,
RecipeEntry,
ShoppingList,
)
def list_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 Shopping List.
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.
"""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {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 = ListEntry(
shoppingList=sList,
product=product,
quantity=quantity,
notes=notes,
)
session.add(entry)
session.commit()
return entry
def list_addRecipe(session: Session, listId: int, recipeId: int) -> List[ListEntry]:
"""Add a Recipe to a Shopping List.
This creates new `ListEntry` items for each `RecipeEntry` within the
`Recipe` object. Note that any Products that are referenced by the Recipe
and that are already in the Shopping List will have the quantity updated
to include the Recipe quantity.
"""
sList = list_by_id(session, listId)
if not sList:
raise NotFoundError(f'List {listId} does not exist')
recipe = recipe_by_id(session, recipeId)
if not recipe:
raise NotFoundError(f'Recipe {recipeId} does not exist')
lEntries = list()
for rEntry in recipe.entries:
lEntry = list_entry_by_productId(session, listId, rEntry.product.id)
if lEntry:
if lEntry.quantity and rEntry.quantity:
lEntry.quantity = f'{lEntry.quantity}, {rEntry.quantity} ({recipe.name})'
elif rEntry.quantity:
lEntry.quantity = rEntry.quantity
if lEntry.notes and rEntry.notes:
lEntry.notes = f'{lEntry.notes}\n{rEntry.notes}'
elif rEntry.notes:
lEntry.notes = rEntry.notes
else:
lEntry = ListEntry(
shoppingList=sList,
product=rEntry.product,
quantity=rEntry.quantity,
notes=rEntry.notes,
)
session.add(lEntry)
lEntries.append(lEntry)
session.commit()
return lEntries
def lists_all(session: Session) -> List[ShoppingList]:
"""Return all Shopping Lists."""
return session.query(ShoppingList).all()
def list_by_id(session: Session, id: int) -> Optional[ShoppingList]:
"""Load a specific Shopping List."""
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()
def list_create(session: Session, name: str, *, notes=None) -> ShoppingList:
"""Create a new Shopping List."""
sList = ShoppingList(name=name, notes=notes)
session.add(sList)
session.commit()
return sList
def list_delete(session: Session, id: int):
"""Delete a Shopping List."""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
session.delete(sList)
session.commit()
def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
"""Delete all Crossed-Off Entries from a Shopping List."""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
for entry in sList.entries:
if entry.crossedOff:
session.delete(entry)
if not entry.product.remember:
session.delete(entry.product)
session.commit()
return sList
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()
def list_editItem(
session: Session,
listId: int,
entryId: int,
*,
quantity: Optional[str] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""Edit an Entry on a Shopping List."""
entry = list_entry_by_id(session, listId, entryId)
entry.quantity = quantity
entry.notes = notes
entry.set_modified_at()
session.add(entry)
session.commit()
return entry
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
List have locations. If the List ID is `None`, all stores for which any
Product has locations are returned.
"""
if id is None:
return list({loc.store for loc in session.query(ProductLocation).all()})
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
stores = set()
for e in sList.entries:
for loc in (e.product.locations or []):
stores.add(loc.store)
if '' in stores:
stores.remove('')
if None in stores:
stores.remove(None)
return list(stores)
def list_update(
session: Session,
id: int,
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)
if not sList:
raise NotFoundError(f'List {id} does not exist')
sList.name = name
sList.notes = notes
sList.set_modified_at()
session.add(sList)
session.commit()
return sList
def list_entry_by_productId(session: Session, listId: int, productId: int) -> Optional[ListEntry]:
"""Load a Shopping List Entry by Product Id."""
sList = list_by_id(session, listId)
if not sList:
raise NotFoundError(f'List {listId} not found')
product = product_by_id(session, productId)
if not product:
raise NotFoundError(f'Product {productId} not found')
return session.query(ListEntry).filter(ListEntry.product == product).one_or_none()
def list_entry_by_id(session: Session, listId: int, entryId: int) -> Optional[ListEntry]:
"""Load a specific Shopping List Entry."""
sList = list_by_id(session, listId)
if not sList:
raise NotFoundError(f'List {listId} not found')
entry = session.query(ListEntry).filter(ListEntry.id == entryId).one_or_none()
if not entry:
raise NotFoundError(f'List Entry {entryId} not found')
if entry.shoppingList != sList:
raise DomainError(
f'List Entry {entryId} does not belong to List {sList.name}',
status_code=422,
)
return entry
def list_entry_set_crossedOff(session: Session, listId: int, entryId: int, crossedOff: bool) -> ListEntry:
"""Set the Crossed-Off state of a List Entry."""
entry = list_entry_by_id(session, listId, entryId)
entry.crossedOff = crossedOff
entry.set_modified_at()
session.add(entry)
session.commit()
return entry
def products_all(session: Session) -> List[Product]:
"""Return all Products."""
return session.query(Product).filter(Product.remember == True).all() # noqa: E712
def product_by_id(session: Session, id: int) -> Optional[Product]:
"""Load a specific Product by Id."""
return session.query(Product).filter(Product.id == id).one_or_none()
def product_by_name(session: Session, name: str) -> Optional[Product]:
"""Load a specific Product by Name."""
return session.query(Product).filter(func.lower(Product.name) == func.lower(name)).one_or_none()
def product_create(
session: Session,
name: str,
*,
category: Optional[str] = '',
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()
return product
def product_delete(session: Session, id: int):
"""Delete a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
session.delete(product)
session.commit()
def product_update(
session: Session,
id: int,
name: str,
category: Optional[str] = None,
notes: Optional[str] = None,
) -> Product:
"""Update a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
product.name = name
product.category = category
product.notes = notes
product.set_modified_at()
session.add(product)
session.commit()
return product
def product_addLocation(
session: Session,
id: int,
store: str,
*,
aisle: Optional[str] = None,
bin: Optional[str] = None,
) -> ProductLocation:
"""Add a Store Location to a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
for loc in product.locations:
if loc.store.lower() == store.lower():
raise DomainError(f'A location already exists for store {loc.store}')
loc = ProductLocation(product=product, store=store, aisle=aisle, bin=bin)
session.add(loc)
session.commit()
return loc
def product_removeLocation(
session: Session,
id: int,
store: str
):
"""Remove a Store Location from a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
for loc in product.locations:
if loc.store.lower() == store.lower():
session.delete(loc)
session.commit()
def recipe_addItem(
session: Session,
id: int,
*,
productId: Optional[int] = None,
productName: Optional[str] = None,
productCategory: Optional[str] = '',
quantity: Optional[str] = None,
remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""Add a Product to a Recipe.
If the `productId` parameter is provided, the method will look up the
product by ID and add it to the list. If the `product` parameter is not
provided, a new `Product` will be created with the provided `productName`
and `productCategory` values.
If the `remember` parameter is provided and is `False`, the product will
be removed along with the list item, and it will not be offered as a
suggestion when adding items.
"""
recipe = recipe_by_id(session, id)
if not recipe:
raise NotFoundError(f'Recipe {id} does not exist')
product = None
if not productId:
if not productName:
raise DomainError('Product Name cannot be empty')
product = product_by_name(session, productName)
if not product:
product = Product(name=productName, category=productCategory)
if remember is not None:
product.remember = remember
session.add(product)
else:
product = product_by_id(session, productId)
if not product:
raise NotFoundError(f'Product {productId} does not exist')
entry = RecipeEntry(
recipe=recipe,
product=product,
quantity=quantity,
notes=notes,
)
session.add(entry)
session.commit()
return entry
def recipes_all(session: Session) -> List[Recipe]:
"""Return all Recipes."""
return session.query(Recipe).all()
def recipe_by_id(session: Session, id: int) -> Optional[Recipe]:
"""Load a specific Recipe."""
return session.query(Recipe).filter(Recipe.id == id).one_or_none()
def recipe_create(session: Session, name: str, *, notes=None) -> Recipe:
"""Create a new Recipe."""
recipe = Recipe(name=name, notes=notes)
session.add(recipe)
session.commit()
return recipe
def recipe_delete(session: Session, id: int):
"""Delete a Recipe."""
recipe = recipe_by_id(session, id)
if not recipe:
raise NotFoundError(f'Recipe {id} does not exist')
session.delete(recipe)
session.commit()
def recipe_deleteItem(session: Session, recipeId: int, entryId: int):
"""Delete an Entry from a Recipe."""
entry = recipe_entry_by_id(session, recipeId, entryId)
if not entry.product.remember:
session.delete(entry.product)
session.delete(entry)
session.commit()
def recipe_editItem(
session: Session,
recipeId: int,
entryId: int,
*,
quantity: Optional[str] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""Edit an Entry in a Recipe."""
entry = recipe_entry_by_id(session, recipeId, entryId)
entry.quantity = quantity
entry.notes = notes
entry.set_modified_at()
session.add(entry)
session.commit()
return entry
def recipe_update(
session: Session,
id: int,
name: Union[str, None] = None,
notes: Union[str, None] = None,
) -> ShoppingList:
"""Update the Name and/or Notes of a Recipe."""
recipe = recipe_by_id(session, id)
if not recipe:
raise NotFoundError(f'Recipe {id} does not exist')
recipe.name = name
recipe.notes = notes
recipe.set_modified_at()
session.add(recipe)
session.commit()
return recipe
def recipe_entry_by_id(session: Session, recipeId: int, entryId: int) -> Optional[RecipeEntry]:
"""Load a specific Recipe Entry."""
recipe = recipe_by_id(session, recipeId)
if not recipe:
raise NotFoundError(f'Recipe {recipeId} not found')
entry = session.query(RecipeEntry).filter(RecipeEntry.id == entryId).one_or_none()
if not entry:
raise NotFoundError(f'Recipe Entry {entryId} not found')
if entry.recipe != recipe:
raise DomainError(
f'List Entry {entryId} does not belong to List {recipe.name}',
status_code=422,
)
return entry

View File

@@ -33,3 +33,13 @@ class ConfigError(Error):
"""Class Constructor.""" """Class Constructor."""
super().__init__(*args) super().__init__(*args)
self.config_key = config_key self.config_key = config_key
class DomainError(Error):
"""Exception raised for domain logic errors."""
pass
class NotFoundError(Error):
"""Exception raised when an object cannot be found."""
default_code = 404

View File

@@ -61,7 +61,7 @@ def create_app(app_config=None, app_name=None):
""" """
app = Flask( app = Flask(
'sigl', 'sigl',
template_folder='templates' static_folder='../static',
) )
# Load Application Name and Version from pyproject.toml # Load Application Name and Version from pyproject.toml
@@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None):
init_socketio(app) init_socketio(app)
# Initialize Frontend # Initialize Frontend
from .frontend import init_frontend from .views import init_views
init_frontend(app) init_views(app)
# Startup Complete # Startup Complete
app.logger.info('{} startup complete'.format(app.config['APP_NAME'])) app.logger.info('{} startup complete'.format(app.config['APP_NAME']))

128
sigl/templates/base.html.j2 Normal file
View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block head_scripts %}{% endblock %}
{% if config['ENV'] == 'production' %}
<link rel="stylesheet" href="{{ url_for('static', filename='sigl.dist.css') }}">
{% else %}
<script src="https://cdn.tailwindcss.com"></script>
{% endif %}
<style>
:root {
/* Match with TailWind border-gray-200 */
--jt-control-border-color: rgb(229 231 235);
--jt-control-border-hover-color: rgb(229 231 235);
--jt-control-focus-outline-color: #07f;
}
</style>
{% block head_styles %}{% endblock %}
</head>
<body class="h-full bg-gray-200">
<header>
<nav class="bg-gray-800 border-b border-gray-600">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-12">
<div class="flex items-center">
<div class="flex-shrink-0">
<img class="h-8 w-8" src="https://tailwindui.com/img/logos/workflow-mark-indigo-500.svg" alt="Workflow">
</div>
<div>
<div class="ml-4 flex items-baseline space-x-4">
<a href="{{ url_for('lists.home') }}" class="{% if request.blueprint == 'lists' %}text-white border-b border-white mx-2 py-1 text-sm font-medium{% else %}text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium{% endif %}" aria-current="page">Shopping Lists</a>
<a href="{{ url_for('products.home') }}" class="{% if request.blueprint == 'products' %}text-white border-b border-white mx-2 py-1 text-sm font-medium{% else %}text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium{% endif %}">Products</a>
</div>
</div>
</div>
<div>
<div class="-mr-2 flex">
<!-- Mobile menu button -->
<button type="button" class="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" aria-expanded="false">
<span class="sr-only">App Settings</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</nav>
<section class="max-w-3xl mx-auto px-2 bg-white md:border-l md:border-r border-b border-gray-300">
{% block header %}{% endblock %}
</section>
</header>
<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 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 %}
<div
x-data="noticesHandler()"
class="fixed inset-0 flex flex-col items-end justify-start h-screen w-screen"
style="pointer-events:none"
@notice.document="add($event.detail)"
>
<template x-for="notice of notices" :key="notice.id">
<div
x-show="visible.includes(notice)"
x-transition:enter="transition ease-in duration-200"
x-transition:enter-start="transform opacity-0 translate-y-2"
x-transition:enter-end="transform opacity-100"
x-transition:leave="transition ease-out duration-500"
x-transition:leave-start="transform translate-x-0 opacity-100"
x-transition:leave-end="transform translate-x-full opacity-0"
@click="remove(notice.id)"
class="rounded max-w-[75%] mt-4 mr-6 px-1 py-1 flex items-center justify-center text-white shadow-lg font-bold text-sm cursor-pointer"
:class="{
'bg-green-500': notice.type === 'success',
'bg-blue-500': notice.type === 'info',
'bg-orange-500': notice.type === 'warning',
'bg-red-500': notice.type === 'error',
}"
style="pointer-events:all"
x-text="notice.text"
>
</div>
</template>
</div>
<script language="javascript">
document.addEventListener('alpine:init', function () {
Alpine.data('noticesHandler', () => ({
notices: [],
visible: [],
add(notice) {
notice.id = Date.now()
this.notices.push(notice)
this.fire(notice.id)
},
fire(id) {
this.visible.push(this.notices.find(notice => notice.id == id))
const timeShown = 2000 * this.visible.length
setTimeout(() => {
this.remove(id)
}, timeShown)
},
remove(id) {
const notice = this.visible.find(notice => notice.id == id)
const index = this.visible.indexOf(notice)
this.visible.splice(index, 1)
},
}));
});
document.addEventListener('alpine:initialized', function () {
{% for category, message in get_flashed_messages(with_categories=True) %}
document.dispatchEvent(new CustomEvent('notice', { detail: { type: {{ category|tojson }}, text: {{ message|tojson }} } }));
{% endfor %}
});
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,29 @@
{% extends 'base.html.j2' %}
{% block title %}Create New List | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-1">
<div class="text-sm font-bold text-gray-800">Create New List</div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Shopping List Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea>
</div>
<div class="flex items-center justify-between">
<div class="flex justify-start items-start">
<a href="{{ url_for('lists.home') }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
</div>
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Create</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends 'base.html.j2' %}
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col py-2" x-data="{notesOpen:false}">
<div class="flex justify-between items-center w-full">
<div class="text-lg font-bold text-gray-800">{{ list.name }}</div>
<div class="flex justify-start items-start pr-1">
<a href="{{ url_for('lists.addItem', id=list.id) }}" class="px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Add Item
</a>
{% if list.notes %}
<button @click="notesOpen=!notesOpen" class="ml-4 py-1 text-sm text-gray-800 hover:text-blue-700">
<svg x-show="!notesOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg x-show="notesOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</button>
{% endif %}
<a href="{{ url_for('lists.update', id=list.id) }}" class="ml-4 py-1 text-sm text-gray-800 hover:text-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</a>
</div>
</div>
{% if list.notes %}
<div class="p-2 text-sm w-full" x-show="notesOpen">{{ list.notes}}</div>
{% endif %}
</div>
{% endblock %}
{% block main %}
<div class="fixed bottom-0 flex flex-col sm:flex-row max-w-3xl w-full mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
<div class="w-full flex flex-grow items-center justify-between px-2 py-2 border-b sm:border-none">
<label for="sorting" class="font-bold">Sort By: </label>
<select name="sorting" id="sorting" class="flex-grow ml-2 p-1 bg-white border rounded" onchange="changeSorting(event)">
<option value="none"{% if sortBy == 'none' %} selected="selected"{% endif %}>None</option>
<option value="category"{% if sortBy == 'category' %} selected="selected"{% endif %}>Category</option>
{% for store in stores %}
<option value="store:{{ store }}"{% if sortBy == 'store' and sortStore|lower == store|lower %} selected="selected"{% endif %}>Location ({{ store }})</option>
{% endfor %}
</select>
</div>
<div class="flex shrink-0 w-full sm:w-fit items-center justify-center px-2 py-2">
<button class="px-2 py-1 text-sm text-white bg-red-600 hover:bg-red-700 border rounded flex justify-between items-center" onclick="deleteCrossedOff()">Delete Crossed-Off Items</button>
</div>
</div>
{% if list.entries|length == 0 %}
<div class="py-2 bg-white">
<div class="ml-4 text-sm">No Items</div>
</div>
{% else %}
{% macro listEntry(entry, bin=None, last=False) -%}
<li
id="item-{{ entry.id }}"
x-data='{crossedOff:{{ entry.crossedOff|tojson }},notesOpen:false}'
class="flex flex-col w-full px-2 py-2{% if not last %} border-b border-gray-300{% endif %}"
:class="{
'bg-gray-400': crossedOff,
'text-gray-600': crossedOff,
}"
>
<div class="flex items-center justify-between w-full">
<div class="flex items-center justify-start flex-grow text-lg" @click.stop.prevent="toggleItem({{ entry.id }}, $data)">
<svg xmlns="http://www.w3.org/2000/svg" class="mr-2 h-5 w-5" :class="{'text-gray-200':!crossedOff,'text-gray-600':crossedOff}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="pointer-events-none" :class="{'line-through':crossedOff}">{{ entry.product.name }}{% if entry.quantity %}<span class="text-gray-600"> | {{ entry.quantity }}</span>{% endif %}</div>
</div>
<div class="flex items-center justify-end">
<div class="mr-2 text-sm text-gray-600">{% if bin %}Bin {{ bin }}{% endif %}</div>
{% if entry.notes or entry.product.notes %}
<button @click="notesOpen=!notesOpen" class="mr-2 py-1 text-sm text-gray-800 hover:text-blue-700">
<svg x-show="!notesOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<svg x-show="notesOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</button>
{% endif %}
<a href="{{ url_for('lists.editItem', listId=list.id, entryId=entry.id) }}" class="py-1 text-sm text-gray-800 hover:text-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</a>
</div>
</div>
{% if entry.notes or entry.product.notes %}
<div x-show="notesOpen" class="p-2 text-sm w-full">
{% if entry.notes %}<p>{{ entry.notes }}</p>{% endif %}
{% if entry.product.notes %}<p>{{ entry.product.notes }}</p>{% endif %}
</div>
{% endif %}
</li>
{%- endmacro %}
{% if sortBy == 'none' %}
<ul>
{% for e in list.entries %}
{{ listEntry(e, last=loop.last) }}
{% endfor %}
</ul>
{% elif sortBy == 'category' %}
{% for hdr, entries in groups.items() %}
{% set outer_loop = loop %}
<div class="text-sm text-gray-600 font-bold py-1 px-4 bg-gray-50 border-b border-gray-300">{{ hdr }}</div>
<ul>
{% for e in entries %}
{{ listEntry(e.entry, last=loop.last and outer_loop.last) }}
{% endfor %}
</ul>
{% endfor %}
{% elif sortBy == 'store' %}
{% for hdr, entries in groups.items() %}
{% set outer_loop = loop %}
<div class="text-sm text-gray-600 font-bold py-1 px-4 bg-gray-50 border-b border-gray-300">Aisle {{ hdr }}</div>
<ul>
{% for e in entries|sort(attribute='bin') %}
{{ listEntry(e.entry, bin=e.bin, last=loop.last and outer_loop.last) }}
{% endfor %}
</ul>
{% endfor %}
{% endif %}
{% endif %}
<form action="{{ url_for('lists.delete', id=list.id) }}" method="post" id="delete-list-form"></form>
<form action="{{ url_for('lists.deleteCrossedOff', id=list.id) }}" method="post" id="delete-crossedOff-form"></form>
<script language="javascript">
function changeSorting(e) {
const value = e.target.value;
const [ sort, store ] = value.split(':');
if (store) {
window.location = `?sort=${encodeURIComponent(sort)}&store=${encodeURIComponent(store)}`;
} else {
window.location = `?sort=${encodeURIComponent(sort)}`;
}
}
function deleteCrossedOff() {
const form = document.getElementById('delete-crossedOff-form');
if (form && confirm('Are you sure you want to delete crossed-off items from "{{ list.name }}"? This cannot be undone.')) {
form.submit();
}
}
function deleteList() {
const form = document.getElementById('delete-list-form');
if (form && confirm('Are you sure you want to delete list "{{ list.name }}"? This cannot be undone.')) {
form.submit();
}
}
function toggleItem(entryId, data) {
const crossedOff = !data.crossedOff;
fetch(
`{{ url_for('lists.crossOff', id=list.id) }}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ entryId, crossedOff }),
}
)
.then(response => response.json())
.then(function(response) {
if (response.ok === true) {
data.crossedOff = crossedOff;
} else {
const { exceptionClass, message } = data;
document.dispatchEvent(
new CustomEvent(
'notice',
{
detail: { type: 'error', text: `${exceptionClass}: ${message}` }
}
)
);
}
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends 'base.html.j2' %}
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col justify-start items-start sm:flex-row sm:justify-between sm:items-center sm:py-1">
<div class="text-sm w-full text-gray-800 py-1 border-b sm:border-none">Edit <span class="font-bold ">{{ entry.product.name }}</span> in <span class="font-bold ">{{ list.name }}</span></div>
<div class="flex w-full sm:w-auto shrink-0 justify-between items-start py-1">
</div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="quantity" class="py-1 text-xs text-gray-700 font-semibold">Quantity:</label>
<input type="text" name="quantity" id="quantity" class="p-1 text-sm border border-gray-200 rounded" value="{{ entry.quantity }}" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded">{{ entry.notes or '' }}</textarea>
</div>
<div class="flex items-center justify-between w-full">
<div class="flex items-center">
<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">
Cancel
</a>
<button type="button" id="delete-list-btn" class="flex ml-2 px-2 py-1 text-sm text-white bg-red-600 hover:bg-red-700 border rounded flex justify-between items-center" onclick="deleteItem()">
<svg xmlns="http://www.w3.org/2000/svg" class="pr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete Item
</button>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Update Item</button>
</div>
</div>
</div>
</form>
<form action="{{ url_for('lists.deleteItem', listId=list.id, entryId=entry.id) }}" method="post" id="delete-item-form"></form>
<script language="javascript">
function deleteItem() {
const form = document.getElementById('delete-item-form');
if (form && confirm('Are you sure you want to delete item "{{ entry.product.name }}" from the list?')) {
form.submit();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html.j2' %}
{% block title %}Home | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg font-bold text-gray-800">All Lists</div>
<a href="{{ url_for('lists.create') }}" class="px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
New List
</a>
</div>
{% endblock %}
{% block main %}
{% if lists|length == 0 %}
<div class="py-2 border-b border-gray-300">
<div class="ml-4">No shopping lists</div>
</div>
{% else %}
<ul class="w-full">
{% for lst in lists %}
<li class="block w-full py-2{% if not loop.last %} border-b border-gray-300{% endif %}">
<a href="{{ url_for('lists.detail', id=lst.id) }}"><div class="px-4">{{ lst.name }} ({{ lst.entries|length }} Items)</div></a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends 'base.html.j2' %}
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg font-bold text-gray-800">Edit {{ list.name }}</div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Shopping List Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" value="{{ list.name }}" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded">{{ list.notes or '' }}</textarea>
</div>
<div class="flex items-center justify-between">
<div class="flex justify-start items-start">
<a href="{{ url_for('lists.detail', id=list.id) }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
<button type="submit" id="delete-list-btn" class="ml-2 px-2 py-1 text-sm text-white bg-red-600 hover:bg-red-700 border rounded flex justify-between items-center" onclick="deleteList()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete List
</button>
</div>
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Update</button>
</div>
</div>
</form>
<form action="{{ url_for('lists.delete', id=list.id) }}" method="post" id="delete-list-form"></form>
<script language="javascript">
function deleteList() {
const form = document.getElementById('delete-list-form');
if (form && confirm('Are you sure you want to delete list "{{ list.name }}"? This cannot be undone.')) {
form.submit();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'base.html.j2' %}
{% block title %}Create New Product | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col justify-start items-start sm:flex-row sm:justify-between sm:items-center sm:py-1">
<div class="text-sm w-full text-gray-800 py-1 border-b sm:border-none">Create New Product</span></span></div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="category" class="py-1 text-xs text-gray-700 font-semibold">Product Category:</label>
<input type="text" name="category" id="category" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea>
</div>
<div class="flex items-center justify-between w-full">
<div class="flex justify-start items-start">
<a href="{{ url_for('products.home') }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Create</button>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,150 @@
{% extends 'base.html.j2' %}
{% block title %}{{ product.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col justify-start items-start sm:flex-row sm:justify-between sm:items-center sm:py-1">
<div class="text-sm w-full text-gray-800 py-1 border-b sm:border-none">Edit <span class="font-bold ">{{ product.name }}</span></span></div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" value="{{ product.name }}" />
</div>
<div class="flex flex-col pb-4">
<label for="category" class="py-1 text-xs text-gray-700 font-semibold">Product Category:</label>
<input type="text" name="category" id="category" class="p-1 text-sm border border-gray-200 rounded" value="{{ product.category }}" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded">{{ product.notes or '' }}</textarea>
</div>
<div class="pb-4">
<fieldset>
<legend class="text-sm font-semibold">Product Locations</legend>
<table class="w-full table-fixed mx-2">
<thead>
<tr class="text-sm">
<th class="w-2/5 text-left">Store</th>
<th class="w-1/4 text-left">Aisle</th>
<th class="w-1/4 text-left">Bin</th>
<th></th>
</tr>
</thead>
<tbody>
{% for loc in product.locations %}
<tr>
<td>{{ loc.store }}</td>
<td>{{ loc.aisle }}</td>
<td>{{ loc.bin or '' }}</td>
<td>
<button type="button" class="pt-1 text-red-600 hover:text-red-700" onclick='removeLocation("{{ loc.store }}")'>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td><input type="text" id="locStore" class="w-full border rounded text" /></td>
<td><input type="text" id="locAisle" class="w-full border rounded text" /></td>
<td><input type="text" id="locBin" class="w-full border rounded text" /></td>
<td>
<button type="button" class="pt-1 text-green-600 hover:text-green-700" onclick="addLocation()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</td>
</tr>
</tfoot>
</table>
</fieldset>
</div>
<div class="flex items-center justify-between w-full">
<div class="flex items-center">
<a href="{{ url_for('products.home') }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
<button type="button" id="delete-item-btn" class="flex ml-2 px-2 py-1 text-sm text-white bg-red-600 hover:bg-red-700 border rounded flex justify-between items-center" onclick="deleteItem()">
<svg xmlns="http://www.w3.org/2000/svg" class="pr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete Product
</button>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Update Item</button>
</div>
</div>
</div>
</form>
<form action="{{ url_for('products.delete', id=product.id) }}" method="post" id="delete-item-form"></form>
<form action="{{ url_for('products.addLocation', id=product.id) }}" method="post" id="add-loc-form">
<input type="hidden" name="store" id="addLocationStore" />
<input type="hidden" name="aisle" id="addLocationAisle" />
<input type="hidden" name="bin" id="addLocationBin" />
</form>
<form action="{{ url_for('products.removeLocation', id=product.id) }}" method="post" id="remove-loc-form">
<input type="hidden" name="store" id="removeLocationStore" />
</form>
<script language="javascript">
function deleteItem() {
const delForm = document.getElementById('delete-item-form');
console.log(delForm);
if (delForm && confirm('Are you sure you want to delete product "{{ product.name }}"? This cannot be undone.')) {
console.log(delForm.action);
delForm.submit();
}
}
function addLocation() {
const form = document.getElementById('add-loc-form');
const fStore = document.getElementById('addLocationStore');
const fAisle = document.getElementById('addLocationAisle');
const fBin = document.getElementById('addLocationBin');
const iStore = document.getElementById('locStore');
const iAisle = document.getElementById('locAisle');
const iBin = document.getElementById('locBin');
if (!form || !fStore || !fAisle || !fBin || !iStore || !iAisle || !iBin) {
document.dispatchEvent(
new CustomEvent('notice', {
detail: {
type: 'error',
text: 'An internal error occurred when adding the product location.',
},
})
);
}
fStore.value = iStore.value;
fAisle.value = iAisle.value;
fBin.value = iBin.value;
form.submit();
}
function removeLocation(store) {
const form = document.getElementById('remove-loc-form');
const fStore = document.getElementById('removeLocationStore');
if (!form || !fStore) {
document.dispatchEvent(
new CustomEvent('notice', {
detail: {
type: 'error',
text: 'An internal error occurred when removing the product location.',
},
})
);
}
fStore.value = store;
form.submit();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% extends 'base.html.j2' %}
{% block title %}Products | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg grow mr-2 border-b text-gray-800">
<input type="search" id="search" class="w-full outline-none focus:outline-none" placeholder="Search Products" oninput="filterList()" />
</div>
<a href="{{ url_for('products.create') }}" class="shrink-0 px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
New Product
</a>
</div>
{% endblock %}
{% block main %}
{% if products|length == 0 %}
<div class="py-2 border-b border-gray-300">
<div class="ml-4">No Products</div>
</div>
{% else %}
{% for hdr in groups.keys()|sort %}
{% set outer_loop = loop %}
<div class="sigl-category text-sm text-gray-600 font-bold py-1 px-4 bg-gray-50 border-b border-gray-300">{{ hdr }}</div>
<ul class="sigl-list">
{% for e in groups[hdr]|sort(attribute='name') %}
<li data-value="{{ e.name|lower }}" class="sigl-list-item block w-full py-2{% if not (loop.last and outer_loop.last) %} border-b border-gray-300{% endif %}">
<a href="{{ url_for('products.detail', id=e.id) }}"><div class="px-4">{{ e.name }}</div></a>
</li>
{% endfor %}
</ul>
{% endfor %}
<div id="sigl-no-products-found" class="hidden py-2 border-b border-gray-300">
<div class="ml-4">No Products Found</div>
</div>
{% endif %}
<script language="javascript">
function filterList() {
let nshown = 0;
const searchEl = document.getElementById('search');
const searchText = searchEl && searchEl.value.toLowerCase() || '';
document.querySelectorAll('.sigl-list-item').forEach(function (itm) {
const itmValue = itm.dataset.value;
if (searchText === '') {
console.log(`Showing ${itmValue}`)
itm.classList.remove('hidden');
nshown += 1;
} else {
if (itmValue.includes(searchText)) {
console.log(`Hiding ${itmValue}`)
itm.classList.remove('hidden');
nshown += 1;
} else {
console.log(`Showing ${itmValue}`)
itm.classList.add('hidden');
}
}
});
document.querySelectorAll('.sigl-category').forEach(function (itm) {
const ul = itm.nextElementSibling;
const lis = [].slice.call(ul.children);
const nShowing = lis
.map((itm) => itm.classList.contains('hidden') ? 0 : 1)
.reduce((p, i) => p + i, 0);
if (nShowing === 0) {
itm.classList.add('hidden');
} else {
itm.classList.remove('hidden');
}
});
const noneFound = document.getElementById('sigl-no-products-found');
if (nshown === 0) {
noneFound.classList.remove('hidden');
} else {
noneFound.classList.add('hidden');
}
}
</script>
{% endblock %}

19
sigl/views/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Sigl View Blueprints.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .lists import bp as list_bp
from .products import bp as products_bp
__all__ = ('init_views', )
def init_views(app):
"""Register the View Blueprints with the Application."""
app.register_blueprint(list_bp)
app.register_blueprint(products_bp)
# Notify Initialization Complete
app.logger.debug('Views Initialized')

309
sigl/views/lists.py Normal file
View File

@@ -0,0 +1,309 @@
"""Sigl Shopping List View Blueprint.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import (
Blueprint,
current_app,
flash,
jsonify,
make_response,
redirect,
render_template,
request,
session,
url_for,
)
from sigl.database import db
from sigl.domain.service import (
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', )
#: Lists Blueprint
bp = Blueprint('lists', __name__)
@bp.route('/')
@bp.route('/lists')
def home():
"""Sigl Home Page / All Shopping Lists View."""
lists = lists_all(db.session)
return render_template('lists/home.html.j2', lists=lists)
@bp.route('/lists/new', methods=('GET', 'POST'))
def create():
"""Create Shopping List View."""
if request.method == 'POST':
list_name = request.form['name'].strip()
list_notes = request.form['notes'].strip()
if not list_name:
flash('Error: List Name is required')
return render_template('lists/create.html.j2')
list = list_create(db.session, list_name, notes=list_notes)
return redirect(url_for('lists.detail', id=list.id))
else:
return render_template('lists/create.html.j2')
@bp.route('/lists/<int:id>')
def detail(id):
"""Shopping List Detail View."""
try:
sList = list_by_id(db.session, id)
if not sList:
raise NotFoundError(f'List {id} not found')
# 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':
category = e.product.category or 'Uncategorized'
if category not in groups:
groups[category] = [{'entry': e}]
else:
groups[category].append({'entry': e})
elif sortBy == 'store':
aisle = 'Unknown'
bin = None
locs = e.product.locations
for loc in locs:
if loc.store.lower() == sortStore.lower():
aisle = loc.aisle
bin = loc.bin
if aisle not in groups:
groups[aisle] = [{'entry': e, 'bin': bin}]
else:
groups[aisle].append({'entry': e, 'bin': bin})
else:
category = 'Unsorted'
if category not in groups:
groups[category] = [{'entry': e}]
else:
groups[category].append({'entry': e})
return render_template(
'lists/detail.html.j2',
list=list_by_id(db.session, id),
sortBy=sortBy,
sortStore=sortStore,
groups=groups,
stores=list_stores(db.session, id),
)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.home'))
@bp.route('/lists/<int:id>/update', methods=('GET', 'POST'))
def update(id):
"""Update a Shopping List."""
try:
sList = list_by_id(db.session, id)
if request.method == 'POST':
list_update(
db.session,
id,
name=request.form.get('name', sList.name).strip(),
notes=request.form.get('notes', sList.notes).strip(),
)
return redirect(url_for('lists.detail', id=id))
return render_template(
'lists/update.html.j2',
list=sList,
)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.detail', id=id))
@bp.route('/lists/<int:id>/delete', methods=('POST', ))
def delete(id):
"""Delete a Shopping List."""
try:
list_delete(db.session, id)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.home'))
@bp.route('/lists/<int:id>/deleteCrossedOff', methods=('POST', ))
def deleteCrossedOff(id):
"""Delete all Crossed-Off Items on a Shopping List."""
try:
list_deleteCrossedOff(db.session, id)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.detail', id=id))
@bp.route('/lists/<int:id>/crossOff', methods=('POST', ))
def crossOff(id):
"""Cross Off an Item from a Shopping List.
This view is an API endpoint that expects a JSON body with keys `entryId`
and `crossedOff`. The crossed-off state of the entry will be set to the
provided state, and a JSON response will contain a single key `ok` set to
`true`.
If an error occurs, the response code will be set to 4xx and the response
body will be a JSON object with keys `exceptionClass` and `message` with
details of the error.
"""
try:
data = request.json
for k in ('entryId', 'crossedOff'):
if k not in data:
raise DomainError(f'Missing data key {k}', status_code=422)
list_entry_set_crossedOff(
db.session,
id,
data['entryId'],
bool(data['crossedOff']),
)
return make_response(jsonify({'ok': True}), 200)
except Error as e:
return make_response(
jsonify({
'exceptionClass': e.__class__.__name__,
'message': str(e),
}),
e.status_code,
)
@bp.route('/lists/<int:id>/addItem', methods=('GET', 'POST'))
def addItem(id):
"""Add an Item to a Shopping List."""
try:
sList = list_by_id(db.session, id)
products = products_all(db.session)
if request.method == 'POST':
if 'productName' not in request.form:
flash(
'An internal error occurred. Please reload the page and try again',
'error'
)
return render_template(
'/lists/addItem.html.j2',
list=sList,
products=products,
)
productName = request.form.get('productName', '').strip()
productCategory = request.form.get('productCategory', '').strip()
remember = request.form.get('remember', 'off') == 'on'
quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip()
current_app.logger.info(f'Remember Value: {remember}')
list_addItem(
db.session,
id,
productName=productName,
productCategory=productCategory,
remember=remember,
quantity=quantity,
notes=notes,
)
return redirect(url_for('lists.detail', id=id))
except Error as e:
flash(str(e), 'error')
return render_template(
'/lists/addItem.html.j2',
list=sList,
products=products,
)
@bp.route('/lists/<int:listId>/editItem/<int:entryId>', methods=('GET', 'POST'))
def editItem(listId, entryId):
"""Edit an Item on a Shopping List."""
try:
entry = list_entry_by_id(db.session, listId, entryId)
if request.method == 'POST':
quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip()
list_editItem(
db.session,
listId,
entryId,
quantity=quantity,
notes=notes,
)
return redirect(url_for('lists.detail', id=listId))
except Error as e:
flash(str(e), 'error')
return render_template(
'/lists/editItem.html.j2',
list=entry.shoppingList,
entry=entry,
)
@bp.route('/lists/<int:listId>/deleteItem/<int:entryId>', methods=('POST', ))
def deleteItem(listId, entryId):
"""Delete an Item from a Shopping List."""
try:
list_deleteItem(db.session, listId, entryId)
return redirect(url_for('lists.detail', id=listId))
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.editItem', listId=listId, entryId=entryId))

143
sigl/views/products.py Normal file
View File

@@ -0,0 +1,143 @@
"""Sigl Products View Blueprint.
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 sigl.database import db
from sigl.domain.service import (
list_stores,
product_addLocation,
product_by_id,
product_create,
product_delete,
product_removeLocation,
product_update,
products_all,
)
from sigl.exc import Error, NotFoundError
__all__ = ('bp', )
#: Lists Blueprint
bp = Blueprint('products', __name__)
@bp.route('/products')
def home():
"""All Products View."""
products = products_all(db.session)
groups = dict()
for product in products:
cat = 'No Category'
if product.category:
cat = product.category
if cat not in groups:
groups[cat] = [product]
else:
groups[cat].append(product)
return render_template('products/home.html.j2', products=products, groups=groups)
@bp.route('/products/new', methods=('GET', 'POST'))
def create():
"""Create a new Product."""
if request.method == 'POST':
product_name = request.form['name'].strip()
product_category = request.form['category'].strip()
product_notes = request.form['notes'].strip()
if not product_name:
flash('Error: Product Name is required')
return render_template('products/create.html.j2')
product = product_create(
db.session,
product_name,
category=product_category,
notes=product_notes,
)
return redirect(url_for('products.detail', id=product.id))
return render_template('products/create.html.j2')
@bp.route('/products/<int:id>', methods=('GET', 'POST'))
def detail(id):
"""Product Detail/Editing View."""
try:
product = product_by_id(db.session, id)
if not product:
raise NotFoundError(f'Product {id} not found')
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.home'))
if request.method == 'POST':
try:
name = request.form['name'].strip()
category = request.form['category'].strip()
notes = request.form['notes'].strip()
product_update(
db.session,
id,
name,
category,
notes,
)
return redirect(url_for('products.home'))
except Error as e:
flash(str(e), 'error')
return render_template(
'products/detail.html.j2',
product=product,
stores=list_stores(db.session, None),
)
@bp.route('/products/<int:id>/delete', methods=('POST', ))
def delete(id):
"""Delete a Product."""
try:
product_delete(db.session, id)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.home'))
@bp.route('/products/<int:id>/addLocation', methods=('POST', ))
def addLocation(id):
"""Add a Location to a Product."""
store = request.form['store'].strip()
aisle = request.form['aisle'].strip()
bin = request.form['bin'].strip()
try:
product_addLocation(db.session, id, store, aisle=aisle, bin=bin)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.detail', id=id))
@bp.route('/products/<int:id>/removeLocation', methods=('POST', ))
def removeLocation(id):
"""Remove a Location from a Product."""
store = request.form['store'].strip()
print(request.form)
try:
product_removeLocation(db.session, id, store)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.detail', id=id))

9
sigl/wsgi.py Normal file
View File

@@ -0,0 +1,9 @@
"""Sigl WSGI Entry Point.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .factory import create_app
app = create_app()

3
src/css/tailwind.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
tailwind.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./sigl/templates/**/*.html.j2',
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -11,7 +11,6 @@ from alembic.config import Config
import flask import flask
import pytest import pytest
import sigl
from sigl.database import db as _db from sigl.database import db as _db
from sigl.factory import create_app from sigl.factory import create_app
@@ -129,6 +128,7 @@ def session(request, monkeypatch, app):
monkeypatch.setattr(_db, 'session', session) monkeypatch.setattr(_db, 'session', session)
def teardown(): def teardown():
if transaction.is_active:
transaction.rollback() transaction.rollback()
connection.close() connection.close()
session.remove() session.remove()

View File

@@ -31,17 +31,17 @@ def test_product_model_init(session):
def test_product_model_can_add_location(session): def test_product_model_can_add_location(session):
"""Test that a Location can be added to a Product.""" """Test that a Location can be added to a Product."""
p = Product(name='Eggs', category='Dairy') p = Product(name='Eggs', category='Dairy')
l = ProductLocation(product=p, store='Pavilions', aisle='Back Wall') loc = ProductLocation(product=p, store='Pavilions', aisle='Back Wall')
session.add(p) session.add(p)
session.add(l) session.add(loc)
session.commit() session.commit()
assert l.aisle == 'Back Wall' assert loc.aisle == 'Back Wall'
assert l.bin is None assert loc.bin is None
assert l.product == p assert loc.product == p
assert l in p.locations assert loc in p.locations
@pytest.mark.unit @pytest.mark.unit
@@ -76,3 +76,13 @@ def test_product_model_same_store_fails(session):
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
session.commit() session.commit()
@pytest.mark.unit
def test_product_model_remembers_by_default(session):
"""Test that the Product defaults to remembering."""
p = Product(name='Eggs', category='Dairy')
session.add(p)
session.commit()
assert p.remember is True

View File

@@ -0,0 +1,75 @@
"""Test the Product Service Entry Points.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import pytest
from sigl.domain.service import (
product_by_id,
product_by_name,
product_create,
products_all,
)
# Always use 'app' fixture so ORM gets initialized
pytestmark = pytest.mark.usefixtures('app')
@pytest.mark.unit
def test_product_create_defaults(session):
"""Test newly created Products have no Locations."""
pc = product_create(session, 'Eggs', category='Dairy')
p = product_by_id(session, pc.id)
assert p.name == 'Eggs'
assert p.category == 'Dairy'
assert p.remember is True
assert not p.locations
@pytest.mark.unit
def test_product_create_without_category(session):
"""Test that a Product can be created with a blank Category."""
pc = product_create(session, 'Eggs')
assert pc.id is not None
assert pc.name == 'Eggs'
assert pc.category == ''
assert pc.remember is True
assert not pc.locations
@pytest.mark.unit
def test_product_create_forget(session):
"""Test newly created Products can have remember as false."""
pc = product_create(session, 'Eggs', category='Dairy', remember=False)
p = product_by_id(session, pc.id)
assert p.name == 'Eggs'
assert p.category == 'Dairy'
assert p.remember is False
assert not p.locations
@pytest.mark.unit
def test_product_all_items_skips_non_remembered(session):
"""Test that querying all Product items skips non-remembered Products."""
p1 = product_create(session, 'Apples')
p2 = product_create(session, 'Bananas', remember=False)
p3 = product_create(session, 'Carrots')
products = products_all(session)
assert len(products) == 2
assert p1 in products
assert p3 in products
assert p2 not in products
@pytest.mark.unit
def test_product_lookup_by_name(session):
"""Test that a Product can be looked up by Name (case-insensitive)."""
p1 = product_create(session, 'Apples')
product = product_by_name(session, 'apples')
assert product == p1

View File

@@ -0,0 +1,101 @@
"""Test the Product Service Entry Points.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import pytest
from sigl.domain.service import (
product_create,
recipe_addItem,
recipe_by_id,
recipe_create,
)
# Always use 'app' fixture so ORM gets initialized
pytestmark = pytest.mark.usefixtures('app')
@pytest.mark.unit
def test_recipe_create_defaults(session):
"""Test newly created Recipes are empty."""
lc = recipe_create(session, 'Test')
recipe = recipe_by_id(session, lc.id)
assert recipe.name == 'Test'
assert not recipe.entries
@pytest.mark.unit
def test_recipe_add_product_defaults(session):
"""Test adding a Product to a Recipe."""
recipe = recipe_create(session, 'Test')
entry = recipe_addItem(session, recipe.id, productName='Eggs', productCategory='Dairy')
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is True
assert len(recipe.entries) == 1
assert recipe.entries[0] == entry
@pytest.mark.unit
def test_recipe_add_product_by_id(session):
"""Test adding an existing Product to a Recipe by Id."""
p1 = product_create(session, 'Eggs', category='Dairy')
recipe = recipe_create(session, 'Test')
entry = recipe_addItem(session, recipe.id, productId=p1.id)
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is True
assert len(recipe.entries) == 1
assert recipe.entries[0] == entry
@pytest.mark.unit
def test_recipe_add_product_by_name(session):
"""Test adding an existing Product to a Recipe by Name."""
product_create(session, 'Eggs', category='Dairy')
recipe = recipe_create(session, 'Test')
entry = recipe_addItem(session, recipe.id, productName='eggs')
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is True
assert len(recipe.entries) == 1
assert recipe.entries[0] == entry
@pytest.mark.unit
def test_recipe_add_product_no_remember(session):
"""Test adding a Product to a Recipe without remembering it."""
recipe = recipe_create(session, 'Test')
entry = recipe_addItem(
session,
recipe.id,
productName='Eggs',
productCategory='Dairy',
remember=False,
)
assert entry.id is not None
assert entry.product is not None
assert entry.product.name == 'Eggs'
assert entry.product.category == 'Dairy'
assert entry.product.remember is False
assert len(recipe.entries) == 1
assert recipe.entries[0] == entry

View File

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

View File

@@ -0,0 +1,90 @@
"""Test the List Recipe Service Entry Points.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
import pytest
from sigl.domain.service import (
list_addItem,
list_addRecipe,
list_create,
product_create,
recipe_addItem,
recipe_create,
)
# Always use 'app' fixture so ORM gets initialized
pytestmark = pytest.mark.usefixtures('app')
@pytest.mark.unit
def test_list_add_recipe_empty(session):
"""Test adding a Recipe to an empty list."""
pEggs = product_create(session, 'Eggs')
recipe = recipe_create(session, 'Test Recipe')
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
lc = list_create(session, 'Test')
lEntries = list_addRecipe(session, lc.id, recipe.id)
assert(len(lEntries) == 2)
assert(len(lc.entries) == 2)
assert(lc.entries[0].product.name == 'Eggs')
assert(lc.entries[0].quantity == '2')
assert(lc.entries[0].notes == 'Extra Large')
assert(lc.entries[1].product.name == 'Milk')
assert(lc.entries[1].quantity == '1 cup')
@pytest.mark.unit
def test_list_add_recipe_merge_quantity(session):
"""Test adding a Recipe to a list with existing items, merging quantity."""
pEggs = product_create(session, 'Eggs')
recipe = recipe_create(session, 'Test Recipe')
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
lc = list_create(session, 'Test')
list_addItem(session, lc.id, productId=pEggs.id, quantity='12')
lEntries = list_addRecipe(session, lc.id, recipe.id)
assert(len(lEntries) == 2)
assert(len(lc.entries) == 2)
assert(lc.entries[0].product.name == 'Eggs')
assert(lc.entries[0].quantity == '12, 2 (Test Recipe)')
assert(lc.entries[0].notes == 'Extra Large')
assert(lc.entries[1].product.name == 'Milk')
assert(lc.entries[1].quantity == '1 cup')
@pytest.mark.unit
def test_list_add_recipe_merge_notes(session):
"""Test adding a Recipe to a list with existing items, merging notes."""
pEggs = product_create(session, 'Eggs')
recipe = recipe_create(session, 'Test Recipe')
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
lc = list_create(session, 'Test')
list_addItem(session, lc.id, productId=pEggs.id, notes='Brown, Cage Free')
lEntries = list_addRecipe(session, lc.id, recipe.id)
assert(len(lEntries) == 2)
assert(len(lc.entries) == 2)
assert(lc.entries[0].product.name == 'Eggs')
assert(lc.entries[0].quantity == '2')
assert(lc.entries[0].notes == 'Brown, Cage Free\nExtra Large')
assert(lc.entries[1].product.name == 'Milk')
assert(lc.entries[1].quantity == '1 cup')