Compare commits

22 Commits

Author SHA1 Message Date
4d0b9b015c Fix empty category handling 2022-12-24 09:45:18 -07:00
bc4f01756d Add Remember flag to Products 2022-12-24 09:38:23 -07:00
66777cfabc Persist list sorting in session 2022-07-15 11:18:39 -07:00
cff6d9cc50 Roll to 0.1.1 2022-07-15 09:30:10 -07:00
2c4f98d567 Fix bottom padding 2022-07-15 09:29:10 -07:00
386341f977 Update Dockerfile 2022-07-15 07:17:03 -07:00
eb1d1e1dd3 Fix Code Styling 2022-07-14 17:17:11 -07:00
21ffc736bc Add Pre-Commit Hooks 2022-07-14 17:12:35 -07:00
e6e7c20479 Add Docker Compose and Quick Start 2022-07-14 17:09:13 -07:00
3a955a45dc Setup multiplatform Docker builds 2022-07-14 15:53:28 -07:00
ce2a433145 Add Version to Base Template 2022-07-14 15:05:53 -07:00
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
44 changed files with 3327 additions and 100 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/*
config/*
docs/_build/*
static/*
!config/dev.example.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 SIGL_CONFIG := ../config/dev-docker.py
css :
npx tailwindcss -i ./src/css/tailwind.css -o static/sigl.dist.css
db-init :
poetry run flask db init
@@ -21,9 +24,21 @@ db-upgrade :
db-downgrade :
poetry run flask db downgrade
docker :
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest
docker-deploy:
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest --push
lint :
poetry run flake8
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 :
poetry run python -m sigl
@@ -39,6 +54,8 @@ test-x :
test-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 \
requirements.txt requirements-dev.txt \
test test-wip test-x

View File

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

18
docker-compose.yaml Normal file
View File

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

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

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]]
name = "alembic"
version = "1.8.0"
version = "1.8.1"
description = "A database migration tool for SQLAlchemy."
category = "main"
optional = false
@@ -84,7 +84,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "coverage"
version = "6.4.1"
version = "6.4.2"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
@@ -95,30 +95,26 @@ toml = ["tomli"]
[[package]]
name = "dnspython"
version = "2.2.1"
version = "1.16.0"
description = "DNS toolkit"
category = "main"
optional = false
python-versions = ">=3.6,<4.0"
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
dnssec = ["cryptography (>=2.6,<37.0)"]
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
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)"]
DNSSEC = ["pycryptodome", "ecdsa (>=0.13)"]
IDNA = ["idna (>=2.1)"]
[[package]]
name = "eventlet"
version = "0.33.1"
version = "0.30.2"
description = "Highly concurrent networking library"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
dnspython = ">=1.15.0"
dnspython = ">=1.15.0,<2.0.0"
greenlet = ">=0.3"
six = ">=1.10.0"
@@ -137,7 +133,7 @@ pyflakes = ">=2.4.0,<2.5.0"
[[package]]
name = "flask"
version = "2.1.2"
version = "2.1.3"
description = "A simple framework for building complex web applications."
category = "main"
optional = false
@@ -154,18 +150,6 @@ Werkzeug = ">=2.0"
async = ["asgiref (>=3.2)"]
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]]
name = "flask-mail"
version = "0.9.1"
@@ -310,23 +294,6 @@ category = "main"
optional = false
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]]
name = "mccabe"
version = "0.6.1"
@@ -339,7 +306,7 @@ python-versions = "*"
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6"
@@ -386,7 +353,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6.8"
@@ -503,20 +470,20 @@ watchdog = ["watchdog"]
[[package]]
name = "zipp"
version = "3.8.0"
version = "3.8.1"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
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)"]
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.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "9a061ae1f8fa92a76f3d49706e062795362cdeb6f9b6067105f7dc6227e66018"
content-hash = "3679bc9fc39e1e9f7160045f491258dfb6fce1b57b2b7963f11a636ee26106d9"
[metadata.files]
alembic = []
@@ -533,17 +500,16 @@ codespell = [
]
colorama = []
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 = []
flake8 = [
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
]
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 = [
{file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"},
]
@@ -561,7 +527,6 @@ itsdangerous = []
jinja2 = []
mako = []
markupsafe = []
marshmallow = []
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},

View File

@@ -1,29 +1,27 @@
[tool.poetry]
name = "sigl"
version = "0.1.0"
version = "0.1.1"
description = "Simple Grocery List"
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
license = "BSD-3-Clause"
[tool.poetry.dependencies]
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"
SQLAlchemy = "^1.4.39"
eventlet = "0.30.2"
Flask = "^2.1.2"
Flask-Mail = "^0.9.1"
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]
codespell = "^2.1.0"
coverage = "^6.4.1"
flake8 = "^4.0.1"
pytest = "^7.1.2"
isort = "^5.10.1"
codespell = "^2.1.0"
pytest = "^7.1.2"
[build-system]
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()
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
"""Initialize the Flask Shell Context."""
import datetime
import sqlalchemy as sa
from sigl.database import db
from sigl.domain.models import (
ListEntry,
Product,
ProductLocation,
ShoppingList,
)
from sigl.domain.models import ListEntry, Product, ProductLocation, ShoppingList
return {
# Imports

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from dataclasses import dataclass, field
from typing import List, TYPE_CHECKING
from typing import TYPE_CHECKING, List
from .mixins import NotesMixin, TimestampMixin
@@ -27,6 +27,7 @@ class Product(NotesMixin, TimestampMixin):
name: str = None
category: str = None
defaultQty: str = None
remember: bool = True
# Relationship Fields
entries: List['ListEntry'] = field(default_factory=list)

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

@@ -0,0 +1,327 @@
"""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] = None,
productName: Optional[str] = None,
productCategory: Optional[str] = None,
quantity: Optional[str] = None,
remember: Optional[bool] = None,
notes: Optional[str] = None,
) -> ListEntry:
"""Add a Product to a Shopping List.
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(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 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 not entry.product.remember:
session.delete(entry.product)
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)
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_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."""
return session.query(Product).filter(Product.id == id).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()

View File

@@ -33,3 +33,13 @@ class ConfigError(Error):
"""Class Constructor."""
super().__init__(*args)
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(
'sigl',
template_folder='templates'
static_folder='../static',
)
# Load Application Name and Version from pyproject.toml
@@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None):
init_socketio(app)
# Initialize Frontend
from .frontend import init_frontend
init_frontend(app)
from .views import init_views
init_views(app)
# Startup Complete
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 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,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')

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

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 pytest
import sigl
from sigl.database import db as _db
from sigl.factory import create_app
@@ -129,7 +128,8 @@ def session(request, monkeypatch, app):
monkeypatch.setattr(_db, 'session', session)
def teardown():
transaction.rollback()
if transaction.is_active:
transaction.rollback()
connection.close()
session.remove()

View File

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

View File

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

View File

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