Compare commits
19 Commits
c0aa590042
...
0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| cff6d9cc50 | |||
| 2c4f98d567 | |||
| 386341f977 | |||
| eb1d1e1dd3 | |||
| 21ffc736bc | |||
| e6e7c20479 | |||
| 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/*
|
||||
config/*
|
||||
docs/_build/*
|
||||
static/*
|
||||
|
||||
!config/dev.example.py
|
||||
!config/test.py
|
||||
|
||||
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
repos:
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.7.4
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.0.0
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=sigl
|
||||
- --skip="./.*,*.csv,*.json"
|
||||
- --quiet-level=2
|
||||
exclude_types: [csv, json]
|
||||
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.4
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-docstrings==1.5.0
|
||||
- pydocstyle==5.1.1
|
||||
files: ^(sigl|tests)/.+\.py$
|
||||
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.5.3
|
||||
hooks:
|
||||
- id: isort
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY sigl sigl
|
||||
COPY src src
|
||||
COPY Makefile ./
|
||||
COPY package.json ./
|
||||
COPY tailwind.config.js ./
|
||||
|
||||
RUN apk add build-base \
|
||||
&& npm install \
|
||||
&& make css
|
||||
|
||||
FROM python:3.8-slim-buster
|
||||
|
||||
RUN groupadd -g 5151 sigl \
|
||||
&& adduser --disabled-password --uid 5151 --gid 5151 sigl
|
||||
|
||||
WORKDIR /home/sigl
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get -y upgrade \
|
||||
&& apt-get -y install --no-install-recommends build-essential \
|
||||
libpq-dev libmariadbclient-dev
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN python -m venv venv \
|
||||
&& venv/bin/pip install -r requirements.txt \
|
||||
&& venv/bin/pip install gunicorn
|
||||
|
||||
COPY sigl sigl
|
||||
COPY migrations migrations
|
||||
COPY docker/* ./
|
||||
COPY pyproject.toml ./
|
||||
|
||||
COPY --from=builder /app/static ./static
|
||||
|
||||
RUN mkdir -p /var/lib/sigl \
|
||||
&& chown -R sigl:sigl /var/lib/sigl ./ \
|
||||
&& chmod +x docker-entry.sh
|
||||
|
||||
USER sigl
|
||||
EXPOSE 5151
|
||||
VOLUME [ "/var/lib/sigl" ]
|
||||
|
||||
CMD [ "sigl" ]
|
||||
|
||||
ENTRYPOINT [ "/home/sigl/docker-entry.sh" ]
|
||||
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 SIGL_CONFIG := ../config/dev-docker.py
|
||||
|
||||
css :
|
||||
npx tailwindcss -i ./src/css/tailwind.css -o static/sigl.dist.css
|
||||
|
||||
db-init :
|
||||
poetry run flask db init
|
||||
|
||||
@@ -21,9 +24,21 @@ db-upgrade :
|
||||
db-downgrade :
|
||||
poetry run flask db downgrade
|
||||
|
||||
docker :
|
||||
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest
|
||||
|
||||
docker-deploy:
|
||||
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest --push
|
||||
|
||||
lint :
|
||||
poetry run flake8
|
||||
|
||||
requirements.txt : poetry.lock
|
||||
poetry export -f requirements.txt --without-hashes -o requirements.txt
|
||||
|
||||
requirements-dev.txt : poetry.lock
|
||||
poetry export --dev -f requirements.txt --without-hashes -o requirements-dev.txt
|
||||
|
||||
serve :
|
||||
poetry run python -m sigl
|
||||
|
||||
@@ -39,6 +54,8 @@ test-x :
|
||||
test-wip :
|
||||
poetry run python -m pytest tests -m wip
|
||||
|
||||
.PHONY : db-init db-migrate db-upgrad db-downgrade \
|
||||
.PHONY : css docker \
|
||||
db-init db-migrate db-upgrad db-downgrade \
|
||||
lint shell serve \
|
||||
requirements.txt requirements-dev.txt \
|
||||
test test-wip test-x
|
||||
19
README.md
19
README.md
@@ -1,3 +1,22 @@
|
||||
# Simple Grocery List (Sigl)
|
||||
|
||||
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install [Docker](https://www.docker.com/) and
|
||||
[Docker Compose](https://docs.docker.com/compose/install/) for your platform.
|
||||
Then run the following commands to clone the Jade Tree repository and run a
|
||||
local instance of Jade Tree on your machine. Note that the database migration
|
||||
only has to be done once to set up a fresh database or to upgrade a database to
|
||||
the latest schema.
|
||||
|
||||
```sh
|
||||
$ git clone https://github.com/asymworks/sigl.git sigl
|
||||
$ docker-compose -f sigl/docker-compose.yaml up -d
|
||||
$ docker-compose -f sigl/docker-compose.yaml \
|
||||
exec app /home/sigl/docker-entry.sh db upgrade
|
||||
$ docker-compose -f sigl/docker-compose.yaml restart app
|
||||
```
|
||||
|
||||
Then access the Sigl server at http://localhost:5151
|
||||
|
||||
18
docker-compose.yaml
Normal file
18
docker-compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
#
|
||||
# Docker Compose File for Sigl Server
|
||||
#
|
||||
|
||||
version: '3.7'
|
||||
services:
|
||||
app:
|
||||
image: asymworks/sigl:latest
|
||||
ports:
|
||||
- 5151:5151
|
||||
volumes:
|
||||
- sigl_data:/var/lib/sigl
|
||||
- ./docker/config.py:/home/sigl/config.py:ro
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
sigl_data:
|
||||
24
docker/config.py
Normal file
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.1",
|
||||
"description": "Simple Grocery List",
|
||||
"dependencies": {
|
||||
"tailwindcss": "^3.1.6"
|
||||
}
|
||||
}
|
||||
73
poetry.lock
generated
73
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.8.0"
|
||||
version = "1.8.1"
|
||||
description = "A database migration tool for SQLAlchemy."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -84,7 +84,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.4.1"
|
||||
version = "6.4.2"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -95,30 +95,26 @@ toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.2.1"
|
||||
version = "1.16.0"
|
||||
description = "DNS toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6,<4.0"
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[package.extras]
|
||||
dnssec = ["cryptography (>=2.6,<37.0)"]
|
||||
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
|
||||
doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"]
|
||||
idna = ["idna (>=2.1,<4.0)"]
|
||||
trio = ["trio (>=0.14,<0.20)"]
|
||||
wmi = ["wmi (>=1.5.1,<2.0.0)"]
|
||||
DNSSEC = ["pycryptodome", "ecdsa (>=0.13)"]
|
||||
IDNA = ["idna (>=2.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "eventlet"
|
||||
version = "0.33.1"
|
||||
version = "0.30.2"
|
||||
description = "Highly concurrent networking library"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = ">=1.15.0"
|
||||
dnspython = ">=1.15.0,<2.0.0"
|
||||
greenlet = ">=0.3"
|
||||
six = ">=1.10.0"
|
||||
|
||||
@@ -137,7 +133,7 @@ pyflakes = ">=2.4.0,<2.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "2.1.2"
|
||||
version = "2.1.3"
|
||||
description = "A simple framework for building complex web applications."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -154,18 +150,6 @@ Werkzeug = ">=2.0"
|
||||
async = ["asgiref (>=3.2)"]
|
||||
dotenv = ["python-dotenv"]
|
||||
|
||||
[[package]]
|
||||
name = "flask-cors"
|
||||
version = "3.0.10"
|
||||
description = "A Flask extension adding a decorator for CORS support"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
Flask = ">=0.9"
|
||||
Six = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flask-mail"
|
||||
version = "0.9.1"
|
||||
@@ -310,23 +294,6 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "marshmallow"
|
||||
version = "3.17.0"
|
||||
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=17.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pytest", "pytz", "simplejson", "mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)", "tox"]
|
||||
docs = ["sphinx (==4.5.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.8)"]
|
||||
lint = ["mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)"]
|
||||
tests = ["pytest", "pytz", "simplejson"]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.6.1"
|
||||
@@ -339,7 +306,7 @@ python-versions = "*"
|
||||
name = "packaging"
|
||||
version = "21.3"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
@@ -386,7 +353,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
name = "pyparsing"
|
||||
version = "3.0.9"
|
||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||
category = "main"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.8"
|
||||
|
||||
@@ -503,20 +470,20 @@ watchdog = ["watchdog"]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.8.0"
|
||||
version = "3.8.1"
|
||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "9a061ae1f8fa92a76f3d49706e062795362cdeb6f9b6067105f7dc6227e66018"
|
||||
content-hash = "3679bc9fc39e1e9f7160045f491258dfb6fce1b57b2b7963f11a636ee26106d9"
|
||||
|
||||
[metadata.files]
|
||||
alembic = []
|
||||
@@ -533,17 +500,16 @@ codespell = [
|
||||
]
|
||||
colorama = []
|
||||
coverage = []
|
||||
dnspython = []
|
||||
dnspython = [
|
||||
{file = "dnspython-1.16.0-py2.py3-none-any.whl", hash = "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"},
|
||||
{file = "dnspython-1.16.0.zip", hash = "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01"},
|
||||
]
|
||||
eventlet = []
|
||||
flake8 = [
|
||||
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
|
||||
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
|
||||
]
|
||||
flask = []
|
||||
flask-cors = [
|
||||
{file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"},
|
||||
{file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"},
|
||||
]
|
||||
flask-mail = [
|
||||
{file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"},
|
||||
]
|
||||
@@ -561,7 +527,6 @@ itsdangerous = []
|
||||
jinja2 = []
|
||||
mako = []
|
||||
markupsafe = []
|
||||
marshmallow = []
|
||||
mccabe = [
|
||||
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
||||
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
[tool.poetry]
|
||||
name = "sigl"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Simple Grocery List"
|
||||
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
|
||||
license = "BSD-3-Clause"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9"
|
||||
Flask = "^2.1.2"
|
||||
Flask-SQLAlchemy = "^2.5.1"
|
||||
Flask-Mail = "^0.9.1"
|
||||
Flask-SocketIO = "^5.2.0"
|
||||
eventlet = "^0.33.1"
|
||||
Flask-Cors = "^3.0.10"
|
||||
alembic = "^1.8.0"
|
||||
SQLAlchemy = "^1.4.39"
|
||||
eventlet = "0.30.2"
|
||||
Flask = "^2.1.2"
|
||||
Flask-Mail = "^0.9.1"
|
||||
Flask-Migrate = "^3.1.0"
|
||||
marshmallow = "^3.17.0"
|
||||
Flask-SocketIO = "^5.2.0"
|
||||
Flask-SQLAlchemy = "^2.5.1"
|
||||
SQLAlchemy = "^1.4.39"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
codespell = "^2.1.0"
|
||||
coverage = "^6.4.1"
|
||||
flake8 = "^4.0.1"
|
||||
pytest = "^7.1.2"
|
||||
isort = "^5.10.1"
|
||||
codespell = "^2.1.0"
|
||||
pytest = "^7.1.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
|
||||
40
requirements-dev.txt
Normal file
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()
|
||||
|
||||
socketio.run(app)
|
||||
socketio.run(app, host='0.0.0.0')
|
||||
|
||||
@@ -8,15 +8,11 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
def init_shell(): # pragma: no cover
|
||||
"""Initialize the Flask Shell Context."""
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from sigl.database import db
|
||||
from sigl.domain.models import (
|
||||
ListEntry,
|
||||
Product,
|
||||
ProductLocation,
|
||||
ShoppingList,
|
||||
)
|
||||
from sigl.domain.models import ListEntry, Product, ProductLocation, ShoppingList
|
||||
|
||||
return {
|
||||
# Imports
|
||||
|
||||
@@ -4,27 +4,18 @@ Simple Grocery List (Sigl) | sigl.app
|
||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
|
||||
from sigl.domain.models import (
|
||||
Product,
|
||||
ProductLocation,
|
||||
)
|
||||
from sigl.domain.models import Product, ProductLocation
|
||||
from sigl.domain.models.list import ListEntry, ShoppingList
|
||||
|
||||
from .globals import db
|
||||
from .tables import (
|
||||
list_entries,
|
||||
lists,
|
||||
product_locations,
|
||||
products,
|
||||
)
|
||||
from .tables import list_entries, lists, product_locations, products
|
||||
|
||||
__all__ = ('init_orm', )
|
||||
|
||||
|
||||
def init_orm():
|
||||
"""Initialize the Sigl ORM."""
|
||||
|
||||
# # List Entries
|
||||
# List Entries
|
||||
db.mapper(ListEntry, list_entries, properties={
|
||||
'product': db.relationship(
|
||||
Product,
|
||||
|
||||
@@ -5,7 +5,7 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from .mixins import NotesMixin, TimestampMixin
|
||||
|
||||
|
||||
312
sigl/domain/service.py
Normal file
312
sigl/domain/service.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""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({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."""
|
||||
super().__init__(*args)
|
||||
self.config_key = config_key
|
||||
|
||||
|
||||
class DomainError(Error):
|
||||
"""Exception raised for domain logic errors."""
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""Exception raised when an object cannot be found."""
|
||||
default_code = 404
|
||||
|
||||
@@ -61,7 +61,7 @@ def create_app(app_config=None, app_name=None):
|
||||
"""
|
||||
app = Flask(
|
||||
'sigl',
|
||||
template_folder='templates'
|
||||
static_folder='../static',
|
||||
)
|
||||
|
||||
# Load Application Name and Version from pyproject.toml
|
||||
@@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None):
|
||||
init_socketio(app)
|
||||
|
||||
# Initialize Frontend
|
||||
from .frontend import init_frontend
|
||||
init_frontend(app)
|
||||
from .views import init_views
|
||||
init_views(app)
|
||||
|
||||
# Startup Complete
|
||||
app.logger.info('{} startup complete'.format(app.config['APP_NAME']))
|
||||
|
||||
120
sigl/templates/base.html.j2
Normal file
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 mb-24 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')
|
||||
300
sigl/views/lists.py
Normal file
300
sigl/views/lists.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""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.database import db
|
||||
from sigl.domain.service import (
|
||||
list_addItem,
|
||||
list_by_id,
|
||||
list_create,
|
||||
list_delete,
|
||||
list_deleteCrossedOff,
|
||||
list_deleteItem,
|
||||
list_editItem,
|
||||
list_entry_by_id,
|
||||
list_entry_set_crossedOff,
|
||||
list_stores,
|
||||
list_update,
|
||||
lists_all,
|
||||
products_all,
|
||||
)
|
||||
from sigl.exc import DomainError, Error, NotFoundError
|
||||
|
||||
__all__ = ('bp', )
|
||||
|
||||
|
||||
#: Lists Blueprint
|
||||
bp = Blueprint('lists', __name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@bp.route('/lists')
|
||||
def home():
|
||||
"""Sigl Home Page / All Shopping Lists View."""
|
||||
lists = lists_all(db.session)
|
||||
return render_template('lists/home.html.j2', lists=lists)
|
||||
|
||||
|
||||
@bp.route('/lists/new', methods=('GET', 'POST'))
|
||||
def create():
|
||||
"""Create Shopping List View."""
|
||||
if request.method == 'POST':
|
||||
list_name = request.form['name'].strip()
|
||||
list_notes = request.form['notes'].strip()
|
||||
if not list_name:
|
||||
flash('Error: List Name is required')
|
||||
return render_template('lists/create.html.j2')
|
||||
|
||||
list = list_create(db.session, list_name, notes=list_notes)
|
||||
return redirect(url_for('lists.detail', id=list.id))
|
||||
|
||||
else:
|
||||
return render_template('lists/create.html.j2')
|
||||
|
||||
|
||||
@bp.route('/lists/<int:id>')
|
||||
def detail(id):
|
||||
"""Shopping List Detail View."""
|
||||
try:
|
||||
sList = list_by_id(db.session, id)
|
||||
if not sList:
|
||||
raise NotFoundError(f'List {id} not found')
|
||||
|
||||
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))
|
||||
143
sigl/views/products.py
Normal file
143
sigl/views/products.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Sigl Products View Blueprint.
|
||||
|
||||
Simple Grocery List (Sigl) | sigl.app
|
||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from sigl.database import db
|
||||
from sigl.domain.service import (
|
||||
list_stores,
|
||||
product_addLocation,
|
||||
product_by_id,
|
||||
product_create,
|
||||
product_delete,
|
||||
product_removeLocation,
|
||||
product_update,
|
||||
products_all,
|
||||
)
|
||||
from sigl.exc import Error, NotFoundError
|
||||
|
||||
__all__ = ('bp', )
|
||||
|
||||
|
||||
#: Lists Blueprint
|
||||
bp = Blueprint('products', __name__)
|
||||
|
||||
|
||||
@bp.route('/products')
|
||||
def home():
|
||||
"""All Products View."""
|
||||
products = products_all(db.session)
|
||||
groups = dict()
|
||||
for product in products:
|
||||
cat = 'No Category'
|
||||
if product.category:
|
||||
cat = product.category
|
||||
|
||||
if cat not in groups:
|
||||
groups[cat] = [product]
|
||||
else:
|
||||
groups[cat].append(product)
|
||||
|
||||
return render_template('products/home.html.j2', products=products, groups=groups)
|
||||
|
||||
|
||||
@bp.route('/products/new', methods=('GET', 'POST'))
|
||||
def create():
|
||||
"""Create a new Product."""
|
||||
if request.method == 'POST':
|
||||
product_name = request.form['name'].strip()
|
||||
product_category = request.form['category'].strip()
|
||||
product_notes = request.form['notes'].strip()
|
||||
if not product_name:
|
||||
flash('Error: Product Name is required')
|
||||
return render_template('products/create.html.j2')
|
||||
|
||||
product = product_create(
|
||||
db.session,
|
||||
product_name,
|
||||
category=product_category,
|
||||
notes=product_notes,
|
||||
)
|
||||
return redirect(url_for('products.detail', id=product.id))
|
||||
return render_template('products/create.html.j2')
|
||||
|
||||
|
||||
@bp.route('/products/<int:id>', methods=('GET', 'POST'))
|
||||
def detail(id):
|
||||
"""Product Detail/Editing View."""
|
||||
try:
|
||||
product = product_by_id(db.session, id)
|
||||
if not product:
|
||||
raise NotFoundError(f'Product {id} not found')
|
||||
|
||||
except Error as e:
|
||||
flash(str(e), 'error')
|
||||
return redirect(url_for('products.home'))
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
name = request.form['name'].strip()
|
||||
category = request.form['category'].strip()
|
||||
notes = request.form['notes'].strip()
|
||||
|
||||
product_update(
|
||||
db.session,
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
notes,
|
||||
)
|
||||
|
||||
return redirect(url_for('products.home'))
|
||||
|
||||
except Error as e:
|
||||
flash(str(e), 'error')
|
||||
|
||||
return render_template(
|
||||
'products/detail.html.j2',
|
||||
product=product,
|
||||
stores=list_stores(db.session, None),
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/products/<int:id>/delete', methods=('POST', ))
|
||||
def delete(id):
|
||||
"""Delete a Product."""
|
||||
try:
|
||||
product_delete(db.session, id)
|
||||
except Error as e:
|
||||
flash(str(e), 'error')
|
||||
|
||||
return redirect(url_for('products.home'))
|
||||
|
||||
|
||||
@bp.route('/products/<int:id>/addLocation', methods=('POST', ))
|
||||
def addLocation(id):
|
||||
"""Add a Location to a Product."""
|
||||
store = request.form['store'].strip()
|
||||
aisle = request.form['aisle'].strip()
|
||||
bin = request.form['bin'].strip()
|
||||
|
||||
try:
|
||||
product_addLocation(db.session, id, store, aisle=aisle, bin=bin)
|
||||
except Error as e:
|
||||
flash(str(e), 'error')
|
||||
|
||||
return redirect(url_for('products.detail', id=id))
|
||||
|
||||
|
||||
@bp.route('/products/<int:id>/removeLocation', methods=('POST', ))
|
||||
def removeLocation(id):
|
||||
"""Remove a Location from a Product."""
|
||||
store = request.form['store'].strip()
|
||||
print(request.form)
|
||||
|
||||
try:
|
||||
product_removeLocation(db.session, id, store)
|
||||
except Error as e:
|
||||
flash(str(e), 'error')
|
||||
|
||||
return redirect(url_for('products.detail', id=id))
|
||||
9
sigl/wsgi.py
Normal file
9
sigl/wsgi.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Sigl WSGI Entry Point.
|
||||
|
||||
Simple Grocery List (Sigl) | sigl.app
|
||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||
"""
|
||||
|
||||
from .factory import create_app
|
||||
|
||||
app = create_app()
|
||||
3
src/css/tailwind.css
Normal file
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 pytest
|
||||
|
||||
import sigl
|
||||
from sigl.database import db as _db
|
||||
from sigl.factory import create_app
|
||||
|
||||
|
||||
@@ -31,17 +31,17 @@ def test_product_model_init(session):
|
||||
def test_product_model_can_add_location(session):
|
||||
"""Test that a Location can be added to a Product."""
|
||||
p = Product(name='Eggs', category='Dairy')
|
||||
l = ProductLocation(product=p, store='Pavilions', aisle='Back Wall')
|
||||
loc = ProductLocation(product=p, store='Pavilions', aisle='Back Wall')
|
||||
|
||||
session.add(p)
|
||||
session.add(l)
|
||||
session.add(loc)
|
||||
session.commit()
|
||||
|
||||
assert l.aisle == 'Back Wall'
|
||||
assert l.bin is None
|
||||
assert l.product == p
|
||||
assert loc.aisle == 'Back Wall'
|
||||
assert loc.bin is None
|
||||
assert loc.product == p
|
||||
|
||||
assert l in p.locations
|
||||
assert loc in p.locations
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
Reference in New Issue
Block a user