Compare commits
13 Commits
c0aa590042
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a955a45dc | |||
| ce2a433145 | |||
| a871562a47 | |||
| 5002ca093e | |||
| 073dc8ab8c | |||
| 60aa886635 | |||
| 0eafe8786d | |||
| 20977b2378 | |||
| 2d313a9e75 | |||
| 17041c9c8b | |||
| 5e9aacdb8c | |||
| 3064a36b09 | |||
| b43b254a47 |
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -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
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]
|
||||||
|
CMD [ "sigl" ]
|
||||||
19
Makefile
19
Makefile
@@ -9,6 +9,9 @@ coverage coverage-html coverage-report test test-wip test-x : export FLASK_ENV :
|
|||||||
shell-psql serve-psql export : export FLASK_ENV := development
|
shell-psql serve-psql export : export FLASK_ENV := development
|
||||||
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
|
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
|
||||||
|
|
||||||
|
css :
|
||||||
|
npx tailwindcss -i ./src/css/tailwind.css -o static/sigl.dist.css
|
||||||
|
|
||||||
db-init :
|
db-init :
|
||||||
poetry run flask db init
|
poetry run flask db init
|
||||||
|
|
||||||
@@ -21,9 +24,21 @@ db-upgrade :
|
|||||||
db-downgrade :
|
db-downgrade :
|
||||||
poetry run flask db downgrade
|
poetry run flask db downgrade
|
||||||
|
|
||||||
|
docker :
|
||||||
|
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest
|
||||||
|
|
||||||
|
docker-deploy:
|
||||||
|
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest --push
|
||||||
|
|
||||||
lint :
|
lint :
|
||||||
poetry run flake8
|
poetry run flake8
|
||||||
|
|
||||||
|
requirements.txt : poetry.lock
|
||||||
|
poetry export -f requirements.txt --without-hashes -o requirements.txt
|
||||||
|
|
||||||
|
requirements-dev.txt : poetry.lock
|
||||||
|
poetry export --dev -f requirements.txt --without-hashes -o requirements-dev.txt
|
||||||
|
|
||||||
serve :
|
serve :
|
||||||
poetry run python -m sigl
|
poetry run python -m sigl
|
||||||
|
|
||||||
@@ -39,6 +54,8 @@ test-x :
|
|||||||
test-wip :
|
test-wip :
|
||||||
poetry run python -m pytest tests -m wip
|
poetry run python -m pytest tests -m wip
|
||||||
|
|
||||||
.PHONY : db-init db-migrate db-upgrad db-downgrade \
|
.PHONY : css docker \
|
||||||
|
db-init db-migrate db-upgrad db-downgrade \
|
||||||
lint shell serve \
|
lint shell serve \
|
||||||
|
requirements.txt requirements-dev.txt \
|
||||||
test test-wip test-x
|
test test-wip test-x
|
||||||
24
docker/config.py
Normal file
24
docker/config.py
Normal 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
25
docker/docker-entry.sh
Normal 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
1198
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
package.json
Normal file
8
package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "sigl",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Simple Grocery List",
|
||||||
|
"dependencies": {
|
||||||
|
"tailwindcss": "^3.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
poetry.lock
generated
73
poetry.lock
generated
@@ -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"},
|
||||||
|
|||||||
@@ -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
40
requirements-dev.txt
Normal 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
24
requirements.txt
Normal 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"
|
||||||
@@ -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
311
sigl/domain/service.py
Normal 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()
|
||||||
10
sigl/exc.py
10
sigl/exc.py
@@ -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
|
||||||
|
|||||||
@@ -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
120
sigl/templates/base.html.j2
Normal 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 | Version {{ config['APP_VERSION'] }}</p>
|
||||||
|
<p>Copyright ©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>
|
||||||
52
sigl/templates/lists/addItem.html.j2
Normal file
52
sigl/templates/lists/addItem.html.j2
Normal 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 %}
|
||||||
29
sigl/templates/lists/create.html.j2
Normal file
29
sigl/templates/lists/create.html.j2
Normal 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 %}
|
||||||
183
sigl/templates/lists/detail.html.j2
Normal file
183
sigl/templates/lists/detail.html.j2
Normal 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 %}
|
||||||
49
sigl/templates/lists/editItem.html.j2
Normal file
49
sigl/templates/lists/editItem.html.j2
Normal 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 %}
|
||||||
28
sigl/templates/lists/home.html.j2
Normal file
28
sigl/templates/lists/home.html.j2
Normal 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 %}
|
||||||
44
sigl/templates/lists/update.html.j2
Normal file
44
sigl/templates/lists/update.html.j2
Normal 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 %}
|
||||||
35
sigl/templates/products/create.html.j2
Normal file
35
sigl/templates/products/create.html.j2
Normal 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 %}
|
||||||
150
sigl/templates/products/detail.html.j2
Normal file
150
sigl/templates/products/detail.html.j2
Normal 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 %}
|
||||||
82
sigl/templates/products/home.html.j2
Normal file
82
sigl/templates/products/home.html.j2
Normal 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
19
sigl/views/__init__.py
Normal 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
285
sigl/views/lists.py
Normal 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
146
sigl/views/products.py
Normal 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
10
sigl/wsgi.py
Normal 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
3
src/css/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
tailwind.config.js
Normal file
10
tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./sigl/templates/**/*.html.j2',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user