Compare commits

..

11 Commits

Author SHA1 Message Date
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
34 changed files with 2998 additions and 78 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

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
FROM 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
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]
CMD [ "sigl" ]

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
@@ -24,6 +27,12 @@ db-downgrade :
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 +48,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 \
db-init db-migrate db-upgrad db-downgrade \
lint shell serve \ lint shell serve \
test test-wip test-x requirements.txt requirements-dev.txt \
test test-wip test-x

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

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.0",
"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

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

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

@@ -0,0 +1,311 @@
"""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.orm import Session
from sigl.exc import DomainError, NotFoundError
from .models import ListEntry, Product, ProductLocation, ShoppingList
def list_addItem(
session: Session,
id: int,
*,
productId: Optional[int],
productName: Optional[str],
productCategory: Optional[str],
quantity: Optional[str],
notes: Optional[str],
) -> 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.
"""
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(name=productName, category=productCategory)
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 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)
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)
session.delete(entry)
session.commit()
def list_editItem(
session: Session,
listId: int,
entryId: int,
*,
quantity: Optional[str],
notes: Optional[str],
) -> 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]) -> 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(set([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],
notes: Union[str, 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_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).all()
def product_by_id(session: Session, id: int) -> Optional[Product]:
"""Load a specific Product."""
return session.query(Product).filter(Product.id == id).one_or_none()
def product_create(
session: Session,
name: str,
*,
category: Optional[str],
notes: Optional[str],
) -> Product:
"""Create a new Product."""
product = Product(name=name, category=category, notes=notes)
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],
notes: Optional[str],
) -> 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],
bin: Optional[str]
) -> 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()

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

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

@@ -0,0 +1,120 @@
<!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 %}
{% 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 px-2 text-xs text-gray-600">
<p>Sigl | Simple Grocery List</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,52 @@
{% extends 'base.html.j2' %}
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-1">
<div class="text-sm font-bold text-gray-800">Add Item to {{ list.name }}</div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<fieldset class="flex flex-col" x-data="{productId:''}">
<legend class="sr-only">Select Product to Add</legend>
<div class="flex flex-col pb-4">
<label for="product" class="py-1 text-xs text-gray-700 font-semibold">Product:</label>
<select id="product" name="product" class="flex-grow p-1 text-sm bg-white border rounded" x-model='productId'>
<option value="" disabled selected>Select a Product</option>
<option value="new">Create a New Product</option>
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
<option value="{{ p.id }}">{{ p.name }}{% if p.category %} (in {{ p.category }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="productId == 'new'">
<div class="w-full sm:mr-1 flex flex-col pb-4">
<label for="productName" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" id="productName" name="productName" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="w-full sm:ml-1 flex flex-col pb-4">
<label for="productCategory" class="py-1 text-xs text-gray-700 font-semibold">Category:</label>
<input type="text" id="productCategory" name="productCategory" class="p-1 text-sm border border-gray-200 rounded" />
</div>
</div>
</fieldset>
<div class="flex flex-col pb-4">
<label for="quantity" class="py-1 text-xs text-gray-700 font-semibold">Quantity:</label>
<input type="text" name="quantity" id="quantity" class="p-1 text-sm border border-gray-200 rounded" />
</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.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>
</div>
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Add Item</button>
</div>
</div>
</form>
{% 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')

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

@@ -0,0 +1,285 @@
"""Sigl Shopping List View Blueprint.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import (
Blueprint,
flash, jsonify, make_response, redirect, render_template, request, url_for
)
from sigl.exc import DomainError, Error, NotFoundError
from sigl.database import db
from sigl.domain.service import (
list_entry_by_id, lists_all, list_by_id, list_create, list_delete,
list_update, list_addItem, list_deleteItem, list_editItem, list_stores,
list_deleteCrossedOff, list_entry_set_crossedOff,
products_all,
)
__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')
sortBy = request.args.get('sort', 'none')
sortStore = request.args.get('store', '')
if sortBy not in ('none', 'category', 'store'):
flash(f'Invalid sorting mode {sortBy}', 'warning')
sortBy = 'category'
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 'product' 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,
)
productId = request.form['product']
productName = request.form.get('productName', '').strip()
productCategory = request.form.get('productCategory', '').strip()
quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip()
if productId == 'new' or productId == '':
productId = None
list_addItem(
db.session,
id,
productId=productId,
productName=productName,
productCategory=productCategory,
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))

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

@@ -0,0 +1,146 @@
"""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.exc import Error, NotFoundError
from sigl.database import db
from sigl.domain.service import (
list_stores,
products_all,
product_by_id,
product_create,
product_delete,
product_update,
product_addLocation,
product_removeLocation,
)
__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))

10
sigl/wsgi.py Normal file
View File

@@ -0,0 +1,10 @@
"""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

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