Compare commits
28 Commits
c0aa590042
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bd38bdc56 | |||
| a1f8bc791d | |||
| 8818219c83 | |||
| 5c92d0ba58 | |||
| cd9a5914bd | |||
| 7b236b1c2f | |||
| 4d0b9b015c | |||
| bc4f01756d | |||
| 66777cfabc | |||
| 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/*
|
assets/dist/*
|
||||||
config/*
|
config/*
|
||||||
docs/_build/*
|
docs/_build/*
|
||||||
|
static/*
|
||||||
|
|
||||||
!config/dev.example.py
|
!config/dev.example.py
|
||||||
!config/test.py
|
!config/test.py
|
||||||
|
|||||||
30
.pre-commit-config.yaml
Normal file
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" ]
|
||||||
21
Makefile
21
Makefile
@@ -9,6 +9,9 @@ coverage coverage-html coverage-report test test-wip test-x : export FLASK_ENV :
|
|||||||
shell-psql serve-psql export : export FLASK_ENV := development
|
shell-psql serve-psql export : export FLASK_ENV := development
|
||||||
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
|
shell-psql serve-psql export : export SIGL_CONFIG := ../config/dev-docker.py
|
||||||
|
|
||||||
|
css :
|
||||||
|
npx tailwindcss -i ./src/css/tailwind.css -o static/sigl.dist.css
|
||||||
|
|
||||||
db-init :
|
db-init :
|
||||||
poetry run flask db init
|
poetry run flask db init
|
||||||
|
|
||||||
@@ -21,9 +24,21 @@ db-upgrade :
|
|||||||
db-downgrade :
|
db-downgrade :
|
||||||
poetry run flask db downgrade
|
poetry run flask db downgrade
|
||||||
|
|
||||||
|
docker :
|
||||||
|
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s)
|
||||||
|
|
||||||
|
docker-deploy:
|
||||||
|
docker buildx build --platform=linux/amd64,linux/arm64 . -t asymworks/sigl:latest -t asymworks/sigl:$(shell poetry version -s) --push
|
||||||
|
|
||||||
lint :
|
lint :
|
||||||
poetry run flake8
|
poetry run flake8
|
||||||
|
|
||||||
|
requirements.txt : poetry.lock
|
||||||
|
poetry export -f requirements.txt --without-hashes -o requirements.txt
|
||||||
|
|
||||||
|
requirements-dev.txt : poetry.lock
|
||||||
|
poetry export --dev -f requirements.txt --without-hashes -o requirements-dev.txt
|
||||||
|
|
||||||
serve :
|
serve :
|
||||||
poetry run python -m sigl
|
poetry run python -m sigl
|
||||||
|
|
||||||
@@ -39,6 +54,8 @@ test-x :
|
|||||||
test-wip :
|
test-wip :
|
||||||
poetry run python -m pytest tests -m wip
|
poetry run python -m pytest tests -m wip
|
||||||
|
|
||||||
.PHONY : db-init db-migrate db-upgrad db-downgrade \
|
.PHONY : css docker docker-deploy \
|
||||||
|
db-init db-migrate db-upgrad db-downgrade \
|
||||||
lint shell serve \
|
lint shell serve \
|
||||||
test test-wip test-x
|
requirements.txt requirements-dev.txt \
|
||||||
|
test test-wip test-x
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -1,3 +1,22 @@
|
|||||||
# Simple Grocery List (Sigl)
|
# Simple Grocery List (Sigl)
|
||||||
|
|
||||||
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.
|
Simple Grocery List, or **Sigl** (pronounced "sigil") is a lightweight shopping list manager designed for shared grocery lists (or any other shopping list). Sigl is designed to be intuitive and flexible, emphasizing an ad-hoc approach to shopping lists rather than "recipe" based lists.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Install [Docker](https://www.docker.com/) and
|
||||||
|
[Docker Compose](https://docs.docker.com/compose/install/) for your platform.
|
||||||
|
Then run the following commands to clone the Jade Tree repository and run a
|
||||||
|
local instance of Jade Tree on your machine. Note that the database migration
|
||||||
|
only has to be done once to set up a fresh database or to upgrade a database to
|
||||||
|
the latest schema.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ git clone https://github.com/asymworks/sigl.git sigl
|
||||||
|
$ docker-compose -f sigl/docker-compose.yaml up -d
|
||||||
|
$ docker-compose -f sigl/docker-compose.yaml \
|
||||||
|
exec app /home/sigl/docker-entry.sh db upgrade
|
||||||
|
$ docker-compose -f sigl/docker-compose.yaml restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access the Sigl server at http://localhost:5151
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ APP_TOKEN_VALIDITY = 7200
|
|||||||
|
|
||||||
# Development Database Settings (overridden by PyTest app_config Fixture)
|
# Development Database Settings (overridden by PyTest app_config Fixture)
|
||||||
DB_DRIVER = 'sqlite'
|
DB_DRIVER = 'sqlite'
|
||||||
DB_FILE = 'sigl-test.db'
|
DB_FILE = 'sigl-test-next.db'
|
||||||
|
|
||||||
# Mail Configuration
|
# Mail Configuration
|
||||||
MAIL_ENABLED = True
|
MAIL_ENABLED = True
|
||||||
|
|||||||
18
docker-compose.yaml
Normal file
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 "$@"
|
||||||
47
migrations/versions/3d0cab7d7747_.py
Normal file
47
migrations/versions/3d0cab7d7747_.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 3d0cab7d7747
|
||||||
|
Revises: c28b3a6cdc3a
|
||||||
|
Create Date: 2023-02-25 15:37:19.626908
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3d0cab7d7747'
|
||||||
|
down_revision = 'c28b3a6cdc3a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('recipes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('notes', sa.String(), nullable=True),
|
||||||
|
sa.Column('createdAt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('modifiedAt', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('recipe_entries',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('recipe_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('quantity', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('notes', sa.String(), nullable=True),
|
||||||
|
sa.Column('createdAt', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('modifiedAt', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['recipe_id'], ['recipes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('recipe_entries')
|
||||||
|
op.drop_table('recipes')
|
||||||
|
# ### end Alembic commands ###
|
||||||
27
migrations/versions/c28b3a6cdc3a_.py
Normal file
27
migrations/versions/c28b3a6cdc3a_.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: c28b3a6cdc3a
|
||||||
|
Revises: 22dc32e475dd
|
||||||
|
Create Date: 2022-12-24 08:56:13.784788
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c28b3a6cdc3a'
|
||||||
|
down_revision = '22dc32e475dd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add the 'remember' column and set to true (original default)
|
||||||
|
op.add_column('products', sa.Column('remember', sa.Boolean(), nullable=True, default=True))
|
||||||
|
op.execute('update products set remember=true where remember=null')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('products', 'remember')
|
||||||
|
# ### end Alembic commands ###
|
||||||
1198
package-lock.json
generated
Normal file
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]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
version = "1.8.0"
|
version = "1.8.1"
|
||||||
description = "A database migration tool for SQLAlchemy."
|
description = "A database migration tool for SQLAlchemy."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@@ -84,7 +84,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "6.4.1"
|
version = "6.4.2"
|
||||||
description = "Code coverage measurement for Python"
|
description = "Code coverage measurement for Python"
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
@@ -95,30 +95,26 @@ toml = ["tomli"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dnspython"
|
name = "dnspython"
|
||||||
version = "2.2.1"
|
version = "1.16.0"
|
||||||
description = "DNS toolkit"
|
description = "DNS toolkit"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6,<4.0"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
dnssec = ["cryptography (>=2.6,<37.0)"]
|
DNSSEC = ["pycryptodome", "ecdsa (>=0.13)"]
|
||||||
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
|
IDNA = ["idna (>=2.1)"]
|
||||||
doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"]
|
|
||||||
idna = ["idna (>=2.1,<4.0)"]
|
|
||||||
trio = ["trio (>=0.14,<0.20)"]
|
|
||||||
wmi = ["wmi (>=1.5.1,<2.0.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eventlet"
|
name = "eventlet"
|
||||||
version = "0.33.1"
|
version = "0.30.2"
|
||||||
description = "Highly concurrent networking library"
|
description = "Highly concurrent networking library"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
dnspython = ">=1.15.0"
|
dnspython = ">=1.15.0,<2.0.0"
|
||||||
greenlet = ">=0.3"
|
greenlet = ">=0.3"
|
||||||
six = ">=1.10.0"
|
six = ">=1.10.0"
|
||||||
|
|
||||||
@@ -137,7 +133,7 @@ pyflakes = ">=2.4.0,<2.5.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "2.1.2"
|
version = "2.1.3"
|
||||||
description = "A simple framework for building complex web applications."
|
description = "A simple framework for building complex web applications."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@@ -154,18 +150,6 @@ Werkzeug = ">=2.0"
|
|||||||
async = ["asgiref (>=3.2)"]
|
async = ["asgiref (>=3.2)"]
|
||||||
dotenv = ["python-dotenv"]
|
dotenv = ["python-dotenv"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "flask-cors"
|
|
||||||
version = "3.0.10"
|
|
||||||
description = "A Flask extension adding a decorator for CORS support"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
Flask = ">=0.9"
|
|
||||||
Six = "*"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask-mail"
|
name = "flask-mail"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -310,23 +294,6 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "marshmallow"
|
|
||||||
version = "3.17.0"
|
|
||||||
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
packaging = ">=17.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["pytest", "pytz", "simplejson", "mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)", "tox"]
|
|
||||||
docs = ["sphinx (==4.5.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.8)"]
|
|
||||||
lint = ["mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)"]
|
|
||||||
tests = ["pytest", "pytz", "simplejson"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -339,7 +306,7 @@ python-versions = "*"
|
|||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "21.3"
|
version = "21.3"
|
||||||
description = "Core utilities for Python packages"
|
description = "Core utilities for Python packages"
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
@@ -386,7 +353,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
|||||||
name = "pyparsing"
|
name = "pyparsing"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||||
category = "main"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6.8"
|
python-versions = ">=3.6.8"
|
||||||
|
|
||||||
@@ -503,20 +470,20 @@ watchdog = ["watchdog"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
version = "3.8.0"
|
version = "3.8.1"
|
||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
|
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
|
||||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "9a061ae1f8fa92a76f3d49706e062795362cdeb6f9b6067105f7dc6227e66018"
|
content-hash = "3679bc9fc39e1e9f7160045f491258dfb6fce1b57b2b7963f11a636ee26106d9"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
alembic = []
|
alembic = []
|
||||||
@@ -533,17 +500,16 @@ codespell = [
|
|||||||
]
|
]
|
||||||
colorama = []
|
colorama = []
|
||||||
coverage = []
|
coverage = []
|
||||||
dnspython = []
|
dnspython = [
|
||||||
|
{file = "dnspython-1.16.0-py2.py3-none-any.whl", hash = "sha256:f69c21288a962f4da86e56c4905b49d11aba7938d3d740e80d9e366ee4f1632d"},
|
||||||
|
{file = "dnspython-1.16.0.zip", hash = "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01"},
|
||||||
|
]
|
||||||
eventlet = []
|
eventlet = []
|
||||||
flake8 = [
|
flake8 = [
|
||||||
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
|
{file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
|
||||||
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
|
{file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
|
||||||
]
|
]
|
||||||
flask = []
|
flask = []
|
||||||
flask-cors = [
|
|
||||||
{file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"},
|
|
||||||
{file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"},
|
|
||||||
]
|
|
||||||
flask-mail = [
|
flask-mail = [
|
||||||
{file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"},
|
{file = "Flask-Mail-0.9.1.tar.gz", hash = "sha256:22e5eb9a940bf407bcf30410ecc3708f3c56cc44b29c34e1726fe85006935f41"},
|
||||||
]
|
]
|
||||||
@@ -561,7 +527,6 @@ itsdangerous = []
|
|||||||
jinja2 = []
|
jinja2 = []
|
||||||
mako = []
|
mako = []
|
||||||
markupsafe = []
|
markupsafe = []
|
||||||
marshmallow = []
|
|
||||||
mccabe = [
|
mccabe = [
|
||||||
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
|
||||||
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
|
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "sigl"
|
name = "sigl"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
description = "Simple Grocery List"
|
description = "Simple Grocery List"
|
||||||
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
|
authors = ["Jonathan Krauss <jkrauss@asymworks.com>"]
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.9"
|
python = "^3.9"
|
||||||
Flask = "^2.1.2"
|
|
||||||
Flask-SQLAlchemy = "^2.5.1"
|
|
||||||
Flask-Mail = "^0.9.1"
|
|
||||||
Flask-SocketIO = "^5.2.0"
|
|
||||||
eventlet = "^0.33.1"
|
|
||||||
Flask-Cors = "^3.0.10"
|
|
||||||
alembic = "^1.8.0"
|
alembic = "^1.8.0"
|
||||||
SQLAlchemy = "^1.4.39"
|
eventlet = "0.30.2"
|
||||||
|
Flask = "^2.1.2"
|
||||||
|
Flask-Mail = "^0.9.1"
|
||||||
Flask-Migrate = "^3.1.0"
|
Flask-Migrate = "^3.1.0"
|
||||||
marshmallow = "^3.17.0"
|
Flask-SocketIO = "^5.2.0"
|
||||||
|
Flask-SQLAlchemy = "^2.5.1"
|
||||||
|
SQLAlchemy = "^1.4.39"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
codespell = "^2.1.0"
|
||||||
coverage = "^6.4.1"
|
coverage = "^6.4.1"
|
||||||
flake8 = "^4.0.1"
|
flake8 = "^4.0.1"
|
||||||
pytest = "^7.1.2"
|
|
||||||
isort = "^5.10.1"
|
isort = "^5.10.1"
|
||||||
codespell = "^2.1.0"
|
pytest = "^7.1.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
40
requirements-dev.txt
Normal file
40
requirements-dev.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
alembic==1.8.1; python_version >= "3.7"
|
||||||
|
atomicwrites==1.4.1; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.4.0"
|
||||||
|
attrs==21.4.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7"
|
||||||
|
bidict==0.22.0; python_version >= "3.7"
|
||||||
|
blinker==1.4
|
||||||
|
click==8.1.3; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
codespell==2.1.0; python_version >= "3.5"
|
||||||
|
colorama==0.4.5; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" and platform_system == "Windows" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.5.0" and platform_system == "Windows"
|
||||||
|
coverage==6.4.2; python_version >= "3.7"
|
||||||
|
dnspython==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0"
|
||||||
|
eventlet==0.30.2
|
||||||
|
flake8==4.0.1; python_version >= "3.6"
|
||||||
|
flask-mail==0.9.1
|
||||||
|
flask-migrate==3.1.0; python_version >= "3.6"
|
||||||
|
flask-socketio==5.2.0; python_version >= "3.6"
|
||||||
|
flask-sqlalchemy==2.5.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
|
||||||
|
flask==2.1.3; python_version >= "3.7"
|
||||||
|
greenlet==1.1.2; python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") and python_full_version >= "3.5.0"
|
||||||
|
importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7")
|
||||||
|
iniconfig==1.1.1; python_version >= "3.7"
|
||||||
|
isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0"
|
||||||
|
itsdangerous==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
jinja2==3.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
mako==1.2.1; python_version >= "3.7"
|
||||||
|
markupsafe==2.1.1; python_version >= "3.7"
|
||||||
|
mccabe==0.6.1; python_version >= "3.6"
|
||||||
|
packaging==21.3; python_version >= "3.7"
|
||||||
|
pluggy==1.0.0; python_version >= "3.7"
|
||||||
|
py==1.11.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7"
|
||||||
|
pycodestyle==2.8.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
|
||||||
|
pyflakes==2.4.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6"
|
||||||
|
pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.7"
|
||||||
|
pytest==7.1.2; python_version >= "3.7"
|
||||||
|
python-engineio==4.3.3; python_version >= "3.6"
|
||||||
|
python-socketio==5.7.0; python_version >= "3.6"
|
||||||
|
six==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0"
|
||||||
|
sqlalchemy==1.4.39; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0")
|
||||||
|
tomli==2.0.1; python_version >= "3.7"
|
||||||
|
werkzeug==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
zipp==3.8.1; python_version < "3.10" and python_version >= "3.7"
|
||||||
24
requirements.txt
Normal file
24
requirements.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
alembic==1.8.1; python_version >= "3.7"
|
||||||
|
bidict==0.22.0; python_version >= "3.7"
|
||||||
|
blinker==1.4
|
||||||
|
click==8.1.3; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
colorama==0.4.5; python_version >= "3.7" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.7" and python_full_version >= "3.5.0"
|
||||||
|
dnspython==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0"
|
||||||
|
eventlet==0.30.2
|
||||||
|
flask-mail==0.9.1
|
||||||
|
flask-migrate==3.1.0; python_version >= "3.6"
|
||||||
|
flask-socketio==5.2.0; python_version >= "3.6"
|
||||||
|
flask-sqlalchemy==2.5.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
|
||||||
|
flask==2.1.3; python_version >= "3.7"
|
||||||
|
greenlet==1.1.2; python_version >= "3" and python_full_version < "3.0.0" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") or python_version >= "3" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and (python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0") and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.7") and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.6") and python_full_version >= "3.5.0"
|
||||||
|
importlib-metadata==4.12.0; python_version < "3.10" and python_version >= "3.7" and (python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7")
|
||||||
|
itsdangerous==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
jinja2==3.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
mako==1.2.1; python_version >= "3.7"
|
||||||
|
markupsafe==2.1.1; python_version >= "3.7"
|
||||||
|
python-engineio==4.3.3; python_version >= "3.6"
|
||||||
|
python-socketio==5.7.0; python_version >= "3.6"
|
||||||
|
six==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0"
|
||||||
|
sqlalchemy==1.4.39; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.6.0")
|
||||||
|
werkzeug==2.1.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.7"
|
||||||
|
zipp==3.8.1; python_version < "3.10" and python_version >= "3.7"
|
||||||
@@ -9,4 +9,4 @@ from .socketio import socketio
|
|||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
socketio.run(app)
|
socketio.run(app, host='0.0.0.0')
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
def init_shell(): # pragma: no cover
|
def init_shell(): # pragma: no cover
|
||||||
"""Initialize the Flask Shell Context."""
|
"""Initialize the Flask Shell Context."""
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from sigl.database import db
|
from sigl.database import db
|
||||||
from sigl.domain.models import (
|
from sigl.domain.models import ListEntry, Product, ProductLocation, ShoppingList
|
||||||
ListEntry,
|
|
||||||
Product,
|
|
||||||
ProductLocation,
|
|
||||||
ShoppingList,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# Imports
|
# Imports
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ Simple Grocery List (Sigl) | sigl.app
|
|||||||
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sigl.domain.models import (
|
from sigl.domain.models import Product, ProductLocation
|
||||||
Product,
|
|
||||||
ProductLocation,
|
|
||||||
)
|
|
||||||
from sigl.domain.models.list import ListEntry, ShoppingList
|
from sigl.domain.models.list import ListEntry, ShoppingList
|
||||||
|
from sigl.domain.models.recipe import Recipe, RecipeEntry
|
||||||
|
|
||||||
from .globals import db
|
from .globals import db
|
||||||
from .tables import (
|
from .tables import (
|
||||||
@@ -16,6 +14,8 @@ from .tables import (
|
|||||||
lists,
|
lists,
|
||||||
product_locations,
|
product_locations,
|
||||||
products,
|
products,
|
||||||
|
recipe_entries,
|
||||||
|
recipes,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ('init_orm', )
|
__all__ = ('init_orm', )
|
||||||
@@ -23,8 +23,7 @@ __all__ = ('init_orm', )
|
|||||||
|
|
||||||
def init_orm():
|
def init_orm():
|
||||||
"""Initialize the Sigl ORM."""
|
"""Initialize the Sigl ORM."""
|
||||||
|
# List Entries
|
||||||
# # List Entries
|
|
||||||
db.mapper(ListEntry, list_entries, properties={
|
db.mapper(ListEntry, list_entries, properties={
|
||||||
'product': db.relationship(
|
'product': db.relationship(
|
||||||
Product,
|
Product,
|
||||||
@@ -36,6 +35,18 @@ def init_orm():
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Recipe Entries
|
||||||
|
db.mapper(RecipeEntry, recipe_entries, properties={
|
||||||
|
'product': db.relationship(
|
||||||
|
Product,
|
||||||
|
back_populates='recipes'
|
||||||
|
),
|
||||||
|
'recipe': db.relationship(
|
||||||
|
Recipe,
|
||||||
|
back_populates='entries',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
# Products
|
# Products
|
||||||
db.mapper(Product, products, properties={
|
db.mapper(Product, products, properties={
|
||||||
'entries': db.relationship(
|
'entries': db.relationship(
|
||||||
@@ -43,6 +54,11 @@ def init_orm():
|
|||||||
back_populates='product',
|
back_populates='product',
|
||||||
cascade='all, delete-orphan',
|
cascade='all, delete-orphan',
|
||||||
),
|
),
|
||||||
|
'recipes': db.relationship(
|
||||||
|
RecipeEntry,
|
||||||
|
back_populates='product',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
),
|
||||||
'locations': db.relationship(
|
'locations': db.relationship(
|
||||||
ProductLocation,
|
ProductLocation,
|
||||||
back_populates='product',
|
back_populates='product',
|
||||||
@@ -66,3 +82,12 @@ def init_orm():
|
|||||||
cascade='all, delete-orphan',
|
cascade='all, delete-orphan',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Recipes
|
||||||
|
db.mapper(Recipe, recipes, properties={
|
||||||
|
'entries': db.relationship(
|
||||||
|
RecipeEntry,
|
||||||
|
back_populates='recipe',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ products = db.Table(
|
|||||||
db.Column('name', db.String(128), nullable=False),
|
db.Column('name', db.String(128), nullable=False),
|
||||||
db.Column('category', db.String(128), nullable=False, index=True),
|
db.Column('category', db.String(128), nullable=False, index=True),
|
||||||
db.Column('defaultQty', db.String(128), default=None),
|
db.Column('defaultQty', db.String(128), default=None),
|
||||||
|
db.Column('remember', db.Boolean, nullable=False, default=True),
|
||||||
|
|
||||||
# Mixin Columns
|
# Mixin Columns
|
||||||
db.Column('notes', db.String(), default=None),
|
db.Column('notes', db.String(), default=None),
|
||||||
@@ -93,3 +94,39 @@ product_locations = db.Table(
|
|||||||
db.Column('createdAt', db.DateTime(), default=None),
|
db.Column('createdAt', db.DateTime(), default=None),
|
||||||
db.Column('modifiedAt', db.DateTime(), default=None),
|
db.Column('modifiedAt', db.DateTime(), default=None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Recipe Table
|
||||||
|
recipes = db.Table(
|
||||||
|
'recipes',
|
||||||
|
|
||||||
|
# Primary Key
|
||||||
|
db.Column('id', db.Integer, primary_key=True),
|
||||||
|
|
||||||
|
# List Attributes
|
||||||
|
db.Column('name', db.String(128), nullable=False),
|
||||||
|
|
||||||
|
# Mixin Columns
|
||||||
|
db.Column('notes', db.String(), default=None),
|
||||||
|
db.Column('createdAt', db.DateTime(), default=None),
|
||||||
|
db.Column('modifiedAt', db.DateTime(), default=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Recipe Entry Table
|
||||||
|
recipe_entries = db.Table(
|
||||||
|
'recipe_entries',
|
||||||
|
|
||||||
|
# Primary Key
|
||||||
|
db.Column('id', db.Integer, primary_key=True),
|
||||||
|
|
||||||
|
# Shopping List and Product Link
|
||||||
|
db.Column('recipe_id', db.ForeignKey('recipes.id'), nullable=False),
|
||||||
|
db.Column('product_id', db.ForeignKey('products.id'), nullable=False),
|
||||||
|
|
||||||
|
# Entry Attributes
|
||||||
|
db.Column('quantity', db.String(128), default=None),
|
||||||
|
|
||||||
|
# Mixin Columns
|
||||||
|
db.Column('notes', db.String(), default=None),
|
||||||
|
db.Column('createdAt', db.DateTime(), default=None),
|
||||||
|
db.Column('modifiedAt', db.DateTime(), default=None),
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
|
|
||||||
from .list import ListEntry, ShoppingList
|
from .list import ListEntry, ShoppingList
|
||||||
from .product import Product, ProductLocation
|
from .product import Product, ProductLocation
|
||||||
|
from .recipe import Recipe, RecipeEntry
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ListEntry',
|
'ListEntry',
|
||||||
'Product',
|
'Product',
|
||||||
'ProductLocation',
|
'ProductLocation',
|
||||||
|
'Recipe',
|
||||||
|
'RecipeEntry',
|
||||||
'ShoppingList',
|
'ShoppingList',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, TYPE_CHECKING
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from .mixins import NotesMixin, TimestampMixin
|
from .mixins import NotesMixin, TimestampMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .list import ListEntry
|
from .list import ListEntry
|
||||||
|
from .recipe import RecipeEntry
|
||||||
|
|
||||||
__all__ = ('Product', 'ProductLocation')
|
__all__ = ('Product', 'ProductLocation')
|
||||||
|
|
||||||
@@ -27,9 +28,11 @@ class Product(NotesMixin, TimestampMixin):
|
|||||||
name: str = None
|
name: str = None
|
||||||
category: str = None
|
category: str = None
|
||||||
defaultQty: str = None
|
defaultQty: str = None
|
||||||
|
remember: bool = True
|
||||||
|
|
||||||
# Relationship Fields
|
# Relationship Fields
|
||||||
entries: List['ListEntry'] = field(default_factory=list)
|
entries: List['ListEntry'] = field(default_factory=list)
|
||||||
|
recipes: List['RecipeEntry'] = field(default_factory=list)
|
||||||
locations: List['ProductLocation'] = field(default_factory=list)
|
locations: List['ProductLocation'] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
42
sigl/domain/models/recipe.py
Normal file
42
sigl/domain/models/recipe.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Sigl Recipe Domain Model.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2023 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .mixins import NotesMixin, TimestampMixin
|
||||||
|
from .product import Product
|
||||||
|
|
||||||
|
__all__ = ('Recipe', 'RecipeEntry')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RecipeEntry(NotesMixin, TimestampMixin):
|
||||||
|
"""Information about a Product in a Recipe.
|
||||||
|
|
||||||
|
This class contains information about a Product that is in a recipe
|
||||||
|
list, including the quantity to be purchased and notes about the entry.
|
||||||
|
"""
|
||||||
|
id: int = None
|
||||||
|
quantity: str = None
|
||||||
|
|
||||||
|
# Relationship Fields
|
||||||
|
product: Product = None
|
||||||
|
recipe: 'Recipe' = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Recipe(NotesMixin, TimestampMixin):
|
||||||
|
"""Top-Level Recipe.
|
||||||
|
|
||||||
|
Contains a collection of `RecipeEntry` items which are intended to be
|
||||||
|
added to shopping lists as a group.
|
||||||
|
"""
|
||||||
|
id: int = None
|
||||||
|
name: str = None
|
||||||
|
|
||||||
|
# Relationship Fields
|
||||||
|
entries: List[RecipeEntry] = field(default_factory=list)
|
||||||
556
sigl/domain/service.py
Normal file
556
sigl/domain/service.py
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
"""Sigl Domain Services.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from sigl.exc import DomainError, NotFoundError
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ListEntry,
|
||||||
|
Product,
|
||||||
|
ProductLocation,
|
||||||
|
Recipe,
|
||||||
|
RecipeEntry,
|
||||||
|
ShoppingList,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_addItem(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
*,
|
||||||
|
productId: Optional[int] = None,
|
||||||
|
productName: Optional[str] = None,
|
||||||
|
productCategory: Optional[str] = '',
|
||||||
|
quantity: Optional[str] = None,
|
||||||
|
remember: Optional[bool] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> ListEntry:
|
||||||
|
"""Add a Product to a Shopping List.
|
||||||
|
|
||||||
|
If the `productId` parameter is provided, the method will look up the
|
||||||
|
product by ID and add it to the list. If the `product` parameter is not
|
||||||
|
provided, a new `Product` will be created with the provided `productName`
|
||||||
|
and `productCategory` values.
|
||||||
|
|
||||||
|
If the `remember` parameter is provided and is `False`, the product will
|
||||||
|
be removed along with the list item, and it will not be offered as a
|
||||||
|
suggestion when adding items.
|
||||||
|
"""
|
||||||
|
sList = list_by_id(session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} does not exist')
|
||||||
|
|
||||||
|
product = None
|
||||||
|
if not productId:
|
||||||
|
if not productName:
|
||||||
|
raise DomainError('Product Name cannot be empty')
|
||||||
|
|
||||||
|
product = product_by_name(session, productName)
|
||||||
|
if not product:
|
||||||
|
product = Product(name=productName, category=productCategory)
|
||||||
|
if remember is not None:
|
||||||
|
product.remember = remember
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
|
||||||
|
else:
|
||||||
|
product = product_by_id(session, productId)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {productId} does not exist')
|
||||||
|
|
||||||
|
entry = ListEntry(
|
||||||
|
shoppingList=sList,
|
||||||
|
product=product,
|
||||||
|
quantity=quantity,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def list_addRecipe(session: Session, listId: int, recipeId: int) -> List[ListEntry]:
|
||||||
|
"""Add a Recipe to a Shopping List.
|
||||||
|
|
||||||
|
This creates new `ListEntry` items for each `RecipeEntry` within the
|
||||||
|
`Recipe` object. Note that any Products that are referenced by the Recipe
|
||||||
|
and that are already in the Shopping List will have the quantity updated
|
||||||
|
to include the Recipe quantity.
|
||||||
|
"""
|
||||||
|
sList = list_by_id(session, listId)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {listId} does not exist')
|
||||||
|
|
||||||
|
recipe = recipe_by_id(session, recipeId)
|
||||||
|
if not recipe:
|
||||||
|
raise NotFoundError(f'Recipe {recipeId} does not exist')
|
||||||
|
|
||||||
|
lEntries = list()
|
||||||
|
for rEntry in recipe.entries:
|
||||||
|
lEntry = list_entry_by_productId(session, listId, rEntry.product.id)
|
||||||
|
if lEntry:
|
||||||
|
if lEntry.quantity and rEntry.quantity:
|
||||||
|
lEntry.quantity = f'{lEntry.quantity}, {rEntry.quantity} ({recipe.name})'
|
||||||
|
elif rEntry.quantity:
|
||||||
|
lEntry.quantity = rEntry.quantity
|
||||||
|
|
||||||
|
if lEntry.notes and rEntry.notes:
|
||||||
|
lEntry.notes = f'{lEntry.notes}\n{rEntry.notes}'
|
||||||
|
elif rEntry.notes:
|
||||||
|
lEntry.notes = rEntry.notes
|
||||||
|
|
||||||
|
else:
|
||||||
|
lEntry = ListEntry(
|
||||||
|
shoppingList=sList,
|
||||||
|
product=rEntry.product,
|
||||||
|
quantity=rEntry.quantity,
|
||||||
|
notes=rEntry.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(lEntry)
|
||||||
|
lEntries.append(lEntry)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return lEntries
|
||||||
|
|
||||||
|
|
||||||
|
def lists_all(session: Session) -> List[ShoppingList]:
|
||||||
|
"""Return all Shopping Lists."""
|
||||||
|
return session.query(ShoppingList).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_by_id(session: Session, id: int) -> Optional[ShoppingList]:
|
||||||
|
"""Load a specific Shopping List."""
|
||||||
|
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_create(session: Session, name: str, *, notes=None) -> ShoppingList:
|
||||||
|
"""Create a new Shopping List."""
|
||||||
|
sList = ShoppingList(name=name, notes=notes)
|
||||||
|
session.add(sList)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return sList
|
||||||
|
|
||||||
|
|
||||||
|
def list_delete(session: Session, id: int):
|
||||||
|
"""Delete a Shopping List."""
|
||||||
|
sList = list_by_id(session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} does not exist')
|
||||||
|
|
||||||
|
session.delete(sList)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_deleteCrossedOff(session: Session, id: int) -> ShoppingList:
|
||||||
|
"""Delete all Crossed-Off Entries from a Shopping List."""
|
||||||
|
sList = list_by_id(session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} does not exist')
|
||||||
|
|
||||||
|
for entry in sList.entries:
|
||||||
|
if entry.crossedOff:
|
||||||
|
session.delete(entry)
|
||||||
|
if not entry.product.remember:
|
||||||
|
session.delete(entry.product)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return sList
|
||||||
|
|
||||||
|
|
||||||
|
def list_deleteItem(session: Session, listId: int, entryId: int):
|
||||||
|
"""Delete an Entry from a Shopping List."""
|
||||||
|
entry = list_entry_by_id(session, listId, entryId)
|
||||||
|
if not entry.product.remember:
|
||||||
|
session.delete(entry.product)
|
||||||
|
|
||||||
|
session.delete(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_editItem(
|
||||||
|
session: Session,
|
||||||
|
listId: int,
|
||||||
|
entryId: int,
|
||||||
|
*,
|
||||||
|
quantity: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> ListEntry:
|
||||||
|
"""Edit an Entry on a Shopping List."""
|
||||||
|
entry = list_entry_by_id(session, listId, entryId)
|
||||||
|
entry.quantity = quantity
|
||||||
|
entry.notes = notes
|
||||||
|
entry.set_modified_at()
|
||||||
|
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def list_stores(session: Session, id: Optional[int] = None) -> List[str]:
|
||||||
|
"""Get a list of all Stores for the List.
|
||||||
|
|
||||||
|
This helper returns a list of all Stores for which the Products in the
|
||||||
|
List have locations. If the List ID is `None`, all stores for which any
|
||||||
|
Product has locations are returned.
|
||||||
|
"""
|
||||||
|
if id is None:
|
||||||
|
return list({loc.store for loc in session.query(ProductLocation).all()})
|
||||||
|
|
||||||
|
sList = list_by_id(session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} does not exist')
|
||||||
|
|
||||||
|
stores = set()
|
||||||
|
for e in sList.entries:
|
||||||
|
for loc in (e.product.locations or []):
|
||||||
|
stores.add(loc.store)
|
||||||
|
|
||||||
|
if '' in stores:
|
||||||
|
stores.remove('')
|
||||||
|
if None in stores:
|
||||||
|
stores.remove(None)
|
||||||
|
|
||||||
|
return list(stores)
|
||||||
|
|
||||||
|
|
||||||
|
def list_update(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
name: Union[str, None] = None,
|
||||||
|
notes: Union[str, None] = None,
|
||||||
|
) -> ShoppingList:
|
||||||
|
"""Update the Name and/or Notes of a Shopping List."""
|
||||||
|
sList = list_by_id(session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} does not exist')
|
||||||
|
|
||||||
|
sList.name = name
|
||||||
|
sList.notes = notes
|
||||||
|
sList.set_modified_at()
|
||||||
|
|
||||||
|
session.add(sList)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return sList
|
||||||
|
|
||||||
|
|
||||||
|
def list_entry_by_productId(session: Session, listId: int, productId: int) -> Optional[ListEntry]:
|
||||||
|
"""Load a Shopping List Entry by Product Id."""
|
||||||
|
sList = list_by_id(session, listId)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {listId} not found')
|
||||||
|
|
||||||
|
product = product_by_id(session, productId)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {productId} not found')
|
||||||
|
|
||||||
|
return session.query(ListEntry).filter(ListEntry.product == product).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_entry_by_id(session: Session, listId: int, entryId: int) -> Optional[ListEntry]:
|
||||||
|
"""Load a specific Shopping List Entry."""
|
||||||
|
sList = list_by_id(session, listId)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {listId} not found')
|
||||||
|
|
||||||
|
entry = session.query(ListEntry).filter(ListEntry.id == entryId).one_or_none()
|
||||||
|
if not entry:
|
||||||
|
raise NotFoundError(f'List Entry {entryId} not found')
|
||||||
|
|
||||||
|
if entry.shoppingList != sList:
|
||||||
|
raise DomainError(
|
||||||
|
f'List Entry {entryId} does not belong to List {sList.name}',
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def list_entry_set_crossedOff(session: Session, listId: int, entryId: int, crossedOff: bool) -> ListEntry:
|
||||||
|
"""Set the Crossed-Off state of a List Entry."""
|
||||||
|
entry = list_entry_by_id(session, listId, entryId)
|
||||||
|
|
||||||
|
entry.crossedOff = crossedOff
|
||||||
|
entry.set_modified_at()
|
||||||
|
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def products_all(session: Session) -> List[Product]:
|
||||||
|
"""Return all Products."""
|
||||||
|
return session.query(Product).filter(Product.remember == True).all() # noqa: E712
|
||||||
|
|
||||||
|
|
||||||
|
def product_by_id(session: Session, id: int) -> Optional[Product]:
|
||||||
|
"""Load a specific Product by Id."""
|
||||||
|
return session.query(Product).filter(Product.id == id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def product_by_name(session: Session, name: str) -> Optional[Product]:
|
||||||
|
"""Load a specific Product by Name."""
|
||||||
|
return session.query(Product).filter(func.lower(Product.name) == func.lower(name)).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def product_create(
|
||||||
|
session: Session,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
category: Optional[str] = '',
|
||||||
|
remember: Optional[bool] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> Product:
|
||||||
|
"""Create a new Product."""
|
||||||
|
product = Product(name=name, category=category, notes=notes)
|
||||||
|
if remember is not None:
|
||||||
|
product.remember = remember
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def product_delete(session: Session, id: int):
|
||||||
|
"""Delete a Product."""
|
||||||
|
product = product_by_id(session, id)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {id} does not exist')
|
||||||
|
|
||||||
|
session.delete(product)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def product_update(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
name: str,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> Product:
|
||||||
|
"""Update a Product."""
|
||||||
|
product = product_by_id(session, id)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {id} does not exist')
|
||||||
|
|
||||||
|
product.name = name
|
||||||
|
product.category = category
|
||||||
|
product.notes = notes
|
||||||
|
product.set_modified_at()
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def product_addLocation(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
store: str,
|
||||||
|
*,
|
||||||
|
aisle: Optional[str] = None,
|
||||||
|
bin: Optional[str] = None,
|
||||||
|
) -> ProductLocation:
|
||||||
|
"""Add a Store Location to a Product."""
|
||||||
|
product = product_by_id(session, id)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {id} does not exist')
|
||||||
|
|
||||||
|
for loc in product.locations:
|
||||||
|
if loc.store.lower() == store.lower():
|
||||||
|
raise DomainError(f'A location already exists for store {loc.store}')
|
||||||
|
|
||||||
|
loc = ProductLocation(product=product, store=store, aisle=aisle, bin=bin)
|
||||||
|
session.add(loc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def product_removeLocation(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
store: str
|
||||||
|
):
|
||||||
|
"""Remove a Store Location from a Product."""
|
||||||
|
product = product_by_id(session, id)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {id} does not exist')
|
||||||
|
|
||||||
|
for loc in product.locations:
|
||||||
|
if loc.store.lower() == store.lower():
|
||||||
|
session.delete(loc)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_addItem(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
*,
|
||||||
|
productId: Optional[int] = None,
|
||||||
|
productName: Optional[str] = None,
|
||||||
|
productCategory: Optional[str] = '',
|
||||||
|
quantity: Optional[str] = None,
|
||||||
|
remember: Optional[bool] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> ListEntry:
|
||||||
|
"""Add a Product to a Recipe.
|
||||||
|
|
||||||
|
If the `productId` parameter is provided, the method will look up the
|
||||||
|
product by ID and add it to the list. If the `product` parameter is not
|
||||||
|
provided, a new `Product` will be created with the provided `productName`
|
||||||
|
and `productCategory` values.
|
||||||
|
|
||||||
|
If the `remember` parameter is provided and is `False`, the product will
|
||||||
|
be removed along with the list item, and it will not be offered as a
|
||||||
|
suggestion when adding items.
|
||||||
|
"""
|
||||||
|
recipe = recipe_by_id(session, id)
|
||||||
|
if not recipe:
|
||||||
|
raise NotFoundError(f'Recipe {id} does not exist')
|
||||||
|
|
||||||
|
product = None
|
||||||
|
if not productId:
|
||||||
|
if not productName:
|
||||||
|
raise DomainError('Product Name cannot be empty')
|
||||||
|
|
||||||
|
product = product_by_name(session, productName)
|
||||||
|
if not product:
|
||||||
|
product = Product(name=productName, category=productCategory)
|
||||||
|
if remember is not None:
|
||||||
|
product.remember = remember
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
|
||||||
|
else:
|
||||||
|
product = product_by_id(session, productId)
|
||||||
|
if not product:
|
||||||
|
raise NotFoundError(f'Product {productId} does not exist')
|
||||||
|
|
||||||
|
entry = RecipeEntry(
|
||||||
|
recipe=recipe,
|
||||||
|
product=product,
|
||||||
|
quantity=quantity,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def recipes_all(session: Session) -> List[Recipe]:
|
||||||
|
"""Return all Recipes."""
|
||||||
|
return session.query(Recipe).all()
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_by_id(session: Session, id: int) -> Optional[Recipe]:
|
||||||
|
"""Load a specific Recipe."""
|
||||||
|
return session.query(Recipe).filter(Recipe.id == id).one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_create(session: Session, name: str, *, notes=None) -> Recipe:
|
||||||
|
"""Create a new Recipe."""
|
||||||
|
recipe = Recipe(name=name, notes=notes)
|
||||||
|
session.add(recipe)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_delete(session: Session, id: int):
|
||||||
|
"""Delete a Recipe."""
|
||||||
|
recipe = recipe_by_id(session, id)
|
||||||
|
if not recipe:
|
||||||
|
raise NotFoundError(f'Recipe {id} does not exist')
|
||||||
|
|
||||||
|
session.delete(recipe)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_deleteItem(session: Session, recipeId: int, entryId: int):
|
||||||
|
"""Delete an Entry from a Recipe."""
|
||||||
|
entry = recipe_entry_by_id(session, recipeId, entryId)
|
||||||
|
if not entry.product.remember:
|
||||||
|
session.delete(entry.product)
|
||||||
|
|
||||||
|
session.delete(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_editItem(
|
||||||
|
session: Session,
|
||||||
|
recipeId: int,
|
||||||
|
entryId: int,
|
||||||
|
*,
|
||||||
|
quantity: Optional[str] = None,
|
||||||
|
notes: Optional[str] = None,
|
||||||
|
) -> ListEntry:
|
||||||
|
"""Edit an Entry in a Recipe."""
|
||||||
|
entry = recipe_entry_by_id(session, recipeId, entryId)
|
||||||
|
entry.quantity = quantity
|
||||||
|
entry.notes = notes
|
||||||
|
entry.set_modified_at()
|
||||||
|
|
||||||
|
session.add(entry)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_update(
|
||||||
|
session: Session,
|
||||||
|
id: int,
|
||||||
|
name: Union[str, None] = None,
|
||||||
|
notes: Union[str, None] = None,
|
||||||
|
) -> ShoppingList:
|
||||||
|
"""Update the Name and/or Notes of a Recipe."""
|
||||||
|
recipe = recipe_by_id(session, id)
|
||||||
|
if not recipe:
|
||||||
|
raise NotFoundError(f'Recipe {id} does not exist')
|
||||||
|
|
||||||
|
recipe.name = name
|
||||||
|
recipe.notes = notes
|
||||||
|
recipe.set_modified_at()
|
||||||
|
|
||||||
|
session.add(recipe)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_entry_by_id(session: Session, recipeId: int, entryId: int) -> Optional[RecipeEntry]:
|
||||||
|
"""Load a specific Recipe Entry."""
|
||||||
|
recipe = recipe_by_id(session, recipeId)
|
||||||
|
if not recipe:
|
||||||
|
raise NotFoundError(f'Recipe {recipeId} not found')
|
||||||
|
|
||||||
|
entry = session.query(RecipeEntry).filter(RecipeEntry.id == entryId).one_or_none()
|
||||||
|
if not entry:
|
||||||
|
raise NotFoundError(f'Recipe Entry {entryId} not found')
|
||||||
|
|
||||||
|
if entry.recipe != recipe:
|
||||||
|
raise DomainError(
|
||||||
|
f'List Entry {entryId} does not belong to List {recipe.name}',
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
return entry
|
||||||
10
sigl/exc.py
10
sigl/exc.py
@@ -33,3 +33,13 @@ class ConfigError(Error):
|
|||||||
"""Class Constructor."""
|
"""Class Constructor."""
|
||||||
super().__init__(*args)
|
super().__init__(*args)
|
||||||
self.config_key = config_key
|
self.config_key = config_key
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Error):
|
||||||
|
"""Exception raised for domain logic errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(Error):
|
||||||
|
"""Exception raised when an object cannot be found."""
|
||||||
|
default_code = 404
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def create_app(app_config=None, app_name=None):
|
|||||||
"""
|
"""
|
||||||
app = Flask(
|
app = Flask(
|
||||||
'sigl',
|
'sigl',
|
||||||
template_folder='templates'
|
static_folder='../static',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load Application Name and Version from pyproject.toml
|
# Load Application Name and Version from pyproject.toml
|
||||||
@@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None):
|
|||||||
init_socketio(app)
|
init_socketio(app)
|
||||||
|
|
||||||
# Initialize Frontend
|
# Initialize Frontend
|
||||||
from .frontend import init_frontend
|
from .views import init_views
|
||||||
init_frontend(app)
|
init_views(app)
|
||||||
|
|
||||||
# Startup Complete
|
# Startup Complete
|
||||||
app.logger.info('{} startup complete'.format(app.config['APP_NAME']))
|
app.logger.info('{} startup complete'.format(app.config['APP_NAME']))
|
||||||
|
|||||||
128
sigl/templates/base.html.j2
Normal file
128
sigl/templates/base.html.j2
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>{% block title %}{% endblock %}</title>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
{% block head_scripts %}{% endblock %}
|
||||||
|
{% if config['ENV'] == 'production' %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='sigl.dist.css') }}">
|
||||||
|
{% else %}
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
{% endif %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
/* Match with TailWind border-gray-200 */
|
||||||
|
--jt-control-border-color: rgb(229 231 235);
|
||||||
|
--jt-control-border-hover-color: rgb(229 231 235);
|
||||||
|
--jt-control-focus-outline-color: #07f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head_styles %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-gray-200">
|
||||||
|
<header>
|
||||||
|
<nav class="bg-gray-800 border-b border-gray-600">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between h-12">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<img class="h-8 w-8" src="https://tailwindui.com/img/logos/workflow-mark-indigo-500.svg" alt="Workflow">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ml-4 flex items-baseline space-x-4">
|
||||||
|
<a href="{{ url_for('lists.home') }}" class="{% if request.blueprint == 'lists' %}text-white border-b border-white mx-2 py-1 text-sm font-medium{% else %}text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium{% endif %}" aria-current="page">Shopping Lists</a>
|
||||||
|
<a href="{{ url_for('products.home') }}" class="{% if request.blueprint == 'products' %}text-white border-b border-white mx-2 py-1 text-sm font-medium{% else %}text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium{% endif %}">Products</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="-mr-2 flex">
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button type="button" class="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" aria-expanded="false">
|
||||||
|
<span class="sr-only">App Settings</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<section class="max-w-3xl mx-auto px-2 bg-white md:border-l md:border-r border-b border-gray-300">
|
||||||
|
{% block header %}{% endblock %}
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
<main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
|
||||||
|
{% block main %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer class="max-w-3xl mx-auto flex flex-col mt-1 mb-24 px-2 text-xs text-gray-600">
|
||||||
|
<p>Sigl | Simple Grocery List | Version {{ config['APP_VERSION'] }}</p>
|
||||||
|
<p>Copyright ©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>
|
||||||
68
sigl/templates/lists/addItem.html.j2
Normal file
68
sigl/templates/lists/addItem.html.j2
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends 'base.html.j2' %}
|
||||||
|
{% block title %}{{ list.name }} | Sigl{% endblock %}
|
||||||
|
{% block header %}
|
||||||
|
<div class="flex justify-between items-center py-1">
|
||||||
|
<div class="font-bold text-gray-800">Add Item to {{ list.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script src="https://unpkg.com/@jadetree/ui/dist/components/autocomplete.iife.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@jadetree/ui/css/index.css" />
|
||||||
|
{% endblock %}
|
||||||
|
{% block main %}
|
||||||
|
<form method="post">
|
||||||
|
<div class="py-2 px-4 flex flex-col">
|
||||||
|
<fieldset class="flex flex-col" x-data="{productName:'',newProduct:false}">
|
||||||
|
<legend class="sr-only">Select Product to Add</legend>
|
||||||
|
<div class="flex flex-col pb-4">
|
||||||
|
<label for="product" class="py-1 text-sm text-gray-700 font-semibold">Product:</label>
|
||||||
|
<jt-autocomplete clearable>
|
||||||
|
<input id="product" name="productName" class="p-1 border border-gray-200 rounded" list="product-list" x-model="productName" @blur="newProduct=!isExistingProduct(productName)" />
|
||||||
|
</jt-autocomplete>
|
||||||
|
<span class="text-sm text-blue-300" x-show="newProduct">New Product</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row align-center justify-start gap-2 pb-4" x-show="newProduct">
|
||||||
|
<input type="checkbox" id="rememberProduct" name="remember" checked />
|
||||||
|
<label for="rememberProduct" class="text-sm text-gray-700 font-semibold">Remember Product</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:justify-between" x-show="newProduct">
|
||||||
|
<div class="w-full flex flex-col pb-4">
|
||||||
|
<label for="productCategory" class="py-1 text-sm text-gray-700 font-semibold">Category:</label>
|
||||||
|
<input type="text" id="productCategory" name="productCategory" class="p-1 border border-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="flex flex-col pb-4">
|
||||||
|
<label for="quantity" class="py-1 text-sm text-gray-700 font-semibold">Quantity:</label>
|
||||||
|
<input type="text" name="quantity" id="quantity" class="p-1 border border-gray-200 rounded" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col pb-4">
|
||||||
|
<label for="notes" class="py-1 text-sm text-gray-700 font-semibold">Notes:</label>
|
||||||
|
<textarea name="notes" id="notes" class="p-1 border border-gray-200 rounded"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex justify-start items-start">
|
||||||
|
<a href="{{ url_for('lists.detail', id=list.id) }}" class="px-2 py-1 text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="px-2 py-1 border rounded text-white bg-blue-600 hover:bg-blue-700">Add Item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<datalist id="product-list">
|
||||||
|
{% for p in products|sort(attribute='category')|sort(attribute='name') %}
|
||||||
|
<option{% if p.category %} data-category="{{ p.category }}"{% endif %}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
{% endblock %}
|
||||||
|
{% block body_scripts %}
|
||||||
|
<script language="javascript">
|
||||||
|
function isExistingProduct(product) {
|
||||||
|
if (!product) return true;
|
||||||
|
const products = Array.from(document.querySelectorAll('#product-list option'))
|
||||||
|
.map((opt) => opt.textContent.toLowerCase().trim());
|
||||||
|
return products.includes(product.toLowerCase().trim());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
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')
|
||||||
309
sigl/views/lists.py
Normal file
309
sigl/views/lists.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""Sigl Shopping List View Blueprint.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
current_app,
|
||||||
|
flash,
|
||||||
|
jsonify,
|
||||||
|
make_response,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
|
from sigl.database import db
|
||||||
|
from sigl.domain.service import (
|
||||||
|
list_addItem,
|
||||||
|
list_by_id,
|
||||||
|
list_create,
|
||||||
|
list_delete,
|
||||||
|
list_deleteCrossedOff,
|
||||||
|
list_deleteItem,
|
||||||
|
list_editItem,
|
||||||
|
list_entry_by_id,
|
||||||
|
list_entry_set_crossedOff,
|
||||||
|
list_stores,
|
||||||
|
list_update,
|
||||||
|
lists_all,
|
||||||
|
products_all,
|
||||||
|
)
|
||||||
|
from sigl.exc import DomainError, Error, NotFoundError
|
||||||
|
|
||||||
|
__all__ = ('bp', )
|
||||||
|
|
||||||
|
|
||||||
|
#: Lists Blueprint
|
||||||
|
bp = Blueprint('lists', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@bp.route('/lists')
|
||||||
|
def home():
|
||||||
|
"""Sigl Home Page / All Shopping Lists View."""
|
||||||
|
lists = lists_all(db.session)
|
||||||
|
return render_template('lists/home.html.j2', lists=lists)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/new', methods=('GET', 'POST'))
|
||||||
|
def create():
|
||||||
|
"""Create Shopping List View."""
|
||||||
|
if request.method == 'POST':
|
||||||
|
list_name = request.form['name'].strip()
|
||||||
|
list_notes = request.form['notes'].strip()
|
||||||
|
if not list_name:
|
||||||
|
flash('Error: List Name is required')
|
||||||
|
return render_template('lists/create.html.j2')
|
||||||
|
|
||||||
|
list = list_create(db.session, list_name, notes=list_notes)
|
||||||
|
return redirect(url_for('lists.detail', id=list.id))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return render_template('lists/create.html.j2')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>')
|
||||||
|
def detail(id):
|
||||||
|
"""Shopping List Detail View."""
|
||||||
|
try:
|
||||||
|
sList = list_by_id(db.session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} not found')
|
||||||
|
|
||||||
|
# Load sorting from request (or session)
|
||||||
|
sSort = session.get(f'sorting-{id}', {})
|
||||||
|
sortBy = request.args.get('sort', sSort.get('sort', 'none'))
|
||||||
|
sortStore = request.args.get('store', sSort.get('store', ''))
|
||||||
|
|
||||||
|
if sortBy not in ('none', 'category', 'store'):
|
||||||
|
flash(f'Invalid sorting mode {sortBy}', 'warning')
|
||||||
|
sortBy = 'category'
|
||||||
|
|
||||||
|
# Store sorting back to the session
|
||||||
|
session[f'sorting-{id}'] = {
|
||||||
|
'sort': sortBy,
|
||||||
|
'store': sortStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
groups = dict()
|
||||||
|
for e in sList.entries:
|
||||||
|
if sortBy == 'category':
|
||||||
|
category = e.product.category or 'Uncategorized'
|
||||||
|
if category not in groups:
|
||||||
|
groups[category] = [{'entry': e}]
|
||||||
|
else:
|
||||||
|
groups[category].append({'entry': e})
|
||||||
|
|
||||||
|
elif sortBy == 'store':
|
||||||
|
aisle = 'Unknown'
|
||||||
|
bin = None
|
||||||
|
locs = e.product.locations
|
||||||
|
for loc in locs:
|
||||||
|
if loc.store.lower() == sortStore.lower():
|
||||||
|
aisle = loc.aisle
|
||||||
|
bin = loc.bin
|
||||||
|
|
||||||
|
if aisle not in groups:
|
||||||
|
groups[aisle] = [{'entry': e, 'bin': bin}]
|
||||||
|
else:
|
||||||
|
groups[aisle].append({'entry': e, 'bin': bin})
|
||||||
|
|
||||||
|
else:
|
||||||
|
category = 'Unsorted'
|
||||||
|
if category not in groups:
|
||||||
|
groups[category] = [{'entry': e}]
|
||||||
|
else:
|
||||||
|
groups[category].append({'entry': e})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'lists/detail.html.j2',
|
||||||
|
list=list_by_id(db.session, id),
|
||||||
|
sortBy=sortBy,
|
||||||
|
sortStore=sortStore,
|
||||||
|
groups=groups,
|
||||||
|
stores=list_stores(db.session, id),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
return redirect(url_for('lists.home'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>/update', methods=('GET', 'POST'))
|
||||||
|
def update(id):
|
||||||
|
"""Update a Shopping List."""
|
||||||
|
try:
|
||||||
|
sList = list_by_id(db.session, id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
list_update(
|
||||||
|
db.session,
|
||||||
|
id,
|
||||||
|
name=request.form.get('name', sList.name).strip(),
|
||||||
|
notes=request.form.get('notes', sList.notes).strip(),
|
||||||
|
)
|
||||||
|
return redirect(url_for('lists.detail', id=id))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'lists/update.html.j2',
|
||||||
|
list=sList,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
return redirect(url_for('lists.detail', id=id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>/delete', methods=('POST', ))
|
||||||
|
def delete(id):
|
||||||
|
"""Delete a Shopping List."""
|
||||||
|
try:
|
||||||
|
list_delete(db.session, id)
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('lists.home'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>/deleteCrossedOff', methods=('POST', ))
|
||||||
|
def deleteCrossedOff(id):
|
||||||
|
"""Delete all Crossed-Off Items on a Shopping List."""
|
||||||
|
try:
|
||||||
|
list_deleteCrossedOff(db.session, id)
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('lists.detail', id=id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>/crossOff', methods=('POST', ))
|
||||||
|
def crossOff(id):
|
||||||
|
"""Cross Off an Item from a Shopping List.
|
||||||
|
|
||||||
|
This view is an API endpoint that expects a JSON body with keys `entryId`
|
||||||
|
and `crossedOff`. The crossed-off state of the entry will be set to the
|
||||||
|
provided state, and a JSON response will contain a single key `ok` set to
|
||||||
|
`true`.
|
||||||
|
|
||||||
|
If an error occurs, the response code will be set to 4xx and the response
|
||||||
|
body will be a JSON object with keys `exceptionClass` and `message` with
|
||||||
|
details of the error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
for k in ('entryId', 'crossedOff'):
|
||||||
|
if k not in data:
|
||||||
|
raise DomainError(f'Missing data key {k}', status_code=422)
|
||||||
|
|
||||||
|
list_entry_set_crossedOff(
|
||||||
|
db.session,
|
||||||
|
id,
|
||||||
|
data['entryId'],
|
||||||
|
bool(data['crossedOff']),
|
||||||
|
)
|
||||||
|
|
||||||
|
return make_response(jsonify({'ok': True}), 200)
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
return make_response(
|
||||||
|
jsonify({
|
||||||
|
'exceptionClass': e.__class__.__name__,
|
||||||
|
'message': str(e),
|
||||||
|
}),
|
||||||
|
e.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:id>/addItem', methods=('GET', 'POST'))
|
||||||
|
def addItem(id):
|
||||||
|
"""Add an Item to a Shopping List."""
|
||||||
|
try:
|
||||||
|
sList = list_by_id(db.session, id)
|
||||||
|
products = products_all(db.session)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if 'productName' not in request.form:
|
||||||
|
flash(
|
||||||
|
'An internal error occurred. Please reload the page and try again',
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
'/lists/addItem.html.j2',
|
||||||
|
list=sList,
|
||||||
|
products=products,
|
||||||
|
)
|
||||||
|
|
||||||
|
productName = request.form.get('productName', '').strip()
|
||||||
|
productCategory = request.form.get('productCategory', '').strip()
|
||||||
|
remember = request.form.get('remember', 'off') == 'on'
|
||||||
|
quantity = request.form.get('quantity', '').strip()
|
||||||
|
notes = request.form.get('notes', '').strip()
|
||||||
|
|
||||||
|
current_app.logger.info(f'Remember Value: {remember}')
|
||||||
|
|
||||||
|
list_addItem(
|
||||||
|
db.session,
|
||||||
|
id,
|
||||||
|
productName=productName,
|
||||||
|
productCategory=productCategory,
|
||||||
|
remember=remember,
|
||||||
|
quantity=quantity,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for('lists.detail', id=id))
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'/lists/addItem.html.j2',
|
||||||
|
list=sList,
|
||||||
|
products=products,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:listId>/editItem/<int:entryId>', methods=('GET', 'POST'))
|
||||||
|
def editItem(listId, entryId):
|
||||||
|
"""Edit an Item on a Shopping List."""
|
||||||
|
try:
|
||||||
|
entry = list_entry_by_id(db.session, listId, entryId)
|
||||||
|
if request.method == 'POST':
|
||||||
|
quantity = request.form.get('quantity', '').strip()
|
||||||
|
notes = request.form.get('notes', '').strip()
|
||||||
|
|
||||||
|
list_editItem(
|
||||||
|
db.session,
|
||||||
|
listId,
|
||||||
|
entryId,
|
||||||
|
quantity=quantity,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for('lists.detail', id=listId))
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'/lists/editItem.html.j2',
|
||||||
|
list=entry.shoppingList,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/lists/<int:listId>/deleteItem/<int:entryId>', methods=('POST', ))
|
||||||
|
def deleteItem(listId, entryId):
|
||||||
|
"""Delete an Item from a Shopping List."""
|
||||||
|
try:
|
||||||
|
list_deleteItem(db.session, listId, entryId)
|
||||||
|
return redirect(url_for('lists.detail', id=listId))
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
flash(str(e), 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('lists.editItem', listId=listId, entryId=entryId))
|
||||||
143
sigl/views/products.py
Normal file
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 flask
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import sigl
|
|
||||||
from sigl.database import db as _db
|
from sigl.database import db as _db
|
||||||
from sigl.factory import create_app
|
from sigl.factory import create_app
|
||||||
|
|
||||||
@@ -129,7 +128,8 @@ def session(request, monkeypatch, app):
|
|||||||
monkeypatch.setattr(_db, 'session', session)
|
monkeypatch.setattr(_db, 'session', session)
|
||||||
|
|
||||||
def teardown():
|
def teardown():
|
||||||
transaction.rollback()
|
if transaction.is_active:
|
||||||
|
transaction.rollback()
|
||||||
connection.close()
|
connection.close()
|
||||||
session.remove()
|
session.remove()
|
||||||
|
|
||||||
|
|||||||
@@ -31,17 +31,17 @@ def test_product_model_init(session):
|
|||||||
def test_product_model_can_add_location(session):
|
def test_product_model_can_add_location(session):
|
||||||
"""Test that a Location can be added to a Product."""
|
"""Test that a Location can be added to a Product."""
|
||||||
p = Product(name='Eggs', category='Dairy')
|
p = Product(name='Eggs', category='Dairy')
|
||||||
l = ProductLocation(product=p, store='Pavilions', aisle='Back Wall')
|
loc = ProductLocation(product=p, store='Pavilions', aisle='Back Wall')
|
||||||
|
|
||||||
session.add(p)
|
session.add(p)
|
||||||
session.add(l)
|
session.add(loc)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
assert l.aisle == 'Back Wall'
|
assert loc.aisle == 'Back Wall'
|
||||||
assert l.bin is None
|
assert loc.bin is None
|
||||||
assert l.product == p
|
assert loc.product == p
|
||||||
|
|
||||||
assert l in p.locations
|
assert loc in p.locations
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -76,3 +76,13 @@ def test_product_model_same_store_fails(session):
|
|||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_model_remembers_by_default(session):
|
||||||
|
"""Test that the Product defaults to remembering."""
|
||||||
|
p = Product(name='Eggs', category='Dairy')
|
||||||
|
session.add(p)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert p.remember is True
|
||||||
|
|||||||
75
tests/test_21_product_service.py
Normal file
75
tests/test_21_product_service.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Test the Product Service Entry Points.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sigl.domain.service import (
|
||||||
|
product_by_id,
|
||||||
|
product_by_name,
|
||||||
|
product_create,
|
||||||
|
products_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always use 'app' fixture so ORM gets initialized
|
||||||
|
pytestmark = pytest.mark.usefixtures('app')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_create_defaults(session):
|
||||||
|
"""Test newly created Products have no Locations."""
|
||||||
|
pc = product_create(session, 'Eggs', category='Dairy')
|
||||||
|
p = product_by_id(session, pc.id)
|
||||||
|
|
||||||
|
assert p.name == 'Eggs'
|
||||||
|
assert p.category == 'Dairy'
|
||||||
|
assert p.remember is True
|
||||||
|
assert not p.locations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_create_without_category(session):
|
||||||
|
"""Test that a Product can be created with a blank Category."""
|
||||||
|
pc = product_create(session, 'Eggs')
|
||||||
|
|
||||||
|
assert pc.id is not None
|
||||||
|
assert pc.name == 'Eggs'
|
||||||
|
assert pc.category == ''
|
||||||
|
assert pc.remember is True
|
||||||
|
assert not pc.locations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_create_forget(session):
|
||||||
|
"""Test newly created Products can have remember as false."""
|
||||||
|
pc = product_create(session, 'Eggs', category='Dairy', remember=False)
|
||||||
|
p = product_by_id(session, pc.id)
|
||||||
|
|
||||||
|
assert p.name == 'Eggs'
|
||||||
|
assert p.category == 'Dairy'
|
||||||
|
assert p.remember is False
|
||||||
|
assert not p.locations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_all_items_skips_non_remembered(session):
|
||||||
|
"""Test that querying all Product items skips non-remembered Products."""
|
||||||
|
p1 = product_create(session, 'Apples')
|
||||||
|
p2 = product_create(session, 'Bananas', remember=False)
|
||||||
|
p3 = product_create(session, 'Carrots')
|
||||||
|
|
||||||
|
products = products_all(session)
|
||||||
|
assert len(products) == 2
|
||||||
|
assert p1 in products
|
||||||
|
assert p3 in products
|
||||||
|
assert p2 not in products
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_lookup_by_name(session):
|
||||||
|
"""Test that a Product can be looked up by Name (case-insensitive)."""
|
||||||
|
p1 = product_create(session, 'Apples')
|
||||||
|
product = product_by_name(session, 'apples')
|
||||||
|
assert product == p1
|
||||||
101
tests/test_22_recipe_service.py
Normal file
101
tests/test_22_recipe_service.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Test the Product Service Entry Points.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sigl.domain.service import (
|
||||||
|
product_create,
|
||||||
|
recipe_addItem,
|
||||||
|
recipe_by_id,
|
||||||
|
recipe_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always use 'app' fixture so ORM gets initialized
|
||||||
|
pytestmark = pytest.mark.usefixtures('app')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_recipe_create_defaults(session):
|
||||||
|
"""Test newly created Recipes are empty."""
|
||||||
|
lc = recipe_create(session, 'Test')
|
||||||
|
recipe = recipe_by_id(session, lc.id)
|
||||||
|
|
||||||
|
assert recipe.name == 'Test'
|
||||||
|
assert not recipe.entries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_recipe_add_product_defaults(session):
|
||||||
|
"""Test adding a Product to a Recipe."""
|
||||||
|
recipe = recipe_create(session, 'Test')
|
||||||
|
entry = recipe_addItem(session, recipe.id, productName='Eggs', productCategory='Dairy')
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(recipe.entries) == 1
|
||||||
|
assert recipe.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_recipe_add_product_by_id(session):
|
||||||
|
"""Test adding an existing Product to a Recipe by Id."""
|
||||||
|
p1 = product_create(session, 'Eggs', category='Dairy')
|
||||||
|
|
||||||
|
recipe = recipe_create(session, 'Test')
|
||||||
|
entry = recipe_addItem(session, recipe.id, productId=p1.id)
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(recipe.entries) == 1
|
||||||
|
assert recipe.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_recipe_add_product_by_name(session):
|
||||||
|
"""Test adding an existing Product to a Recipe by Name."""
|
||||||
|
product_create(session, 'Eggs', category='Dairy')
|
||||||
|
|
||||||
|
recipe = recipe_create(session, 'Test')
|
||||||
|
entry = recipe_addItem(session, recipe.id, productName='eggs')
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(recipe.entries) == 1
|
||||||
|
assert recipe.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_recipe_add_product_no_remember(session):
|
||||||
|
"""Test adding a Product to a Recipe without remembering it."""
|
||||||
|
recipe = recipe_create(session, 'Test')
|
||||||
|
entry = recipe_addItem(
|
||||||
|
session,
|
||||||
|
recipe.id,
|
||||||
|
productName='Eggs',
|
||||||
|
productCategory='Dairy',
|
||||||
|
remember=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is False
|
||||||
|
|
||||||
|
assert len(recipe.entries) == 1
|
||||||
|
assert recipe.entries[0] == entry
|
||||||
144
tests/test_23_list_service.py
Normal file
144
tests/test_23_list_service.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Test the Product Service Entry Points.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sigl.domain.service import (
|
||||||
|
list_addItem,
|
||||||
|
list_by_id,
|
||||||
|
list_create,
|
||||||
|
list_deleteCrossedOff,
|
||||||
|
list_entry_set_crossedOff,
|
||||||
|
product_by_id,
|
||||||
|
product_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always use 'app' fixture so ORM gets initialized
|
||||||
|
pytestmark = pytest.mark.usefixtures('app')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_create_defaults(session):
|
||||||
|
"""Test newly created Lists are empty."""
|
||||||
|
lc = list_create(session, 'Test')
|
||||||
|
list = list_by_id(session, lc.id)
|
||||||
|
|
||||||
|
assert list.name == 'Test'
|
||||||
|
assert not list.entries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_product_defaults(session):
|
||||||
|
"""Test adding a Product to a List."""
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(session, list.id, productName='Eggs', productCategory='Dairy')
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(list.entries) == 1
|
||||||
|
assert list.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_product_by_id(session):
|
||||||
|
"""Test adding an existing Product to a List by Id."""
|
||||||
|
p1 = product_create(session, 'Eggs', category='Dairy')
|
||||||
|
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(session, list.id, productId=p1.id)
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(list.entries) == 1
|
||||||
|
assert list.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_product_by_name(session):
|
||||||
|
"""Test adding an existing Product to a List by Name."""
|
||||||
|
product_create(session, 'Eggs', category='Dairy')
|
||||||
|
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(session, list.id, productName='eggs')
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is True
|
||||||
|
|
||||||
|
assert len(list.entries) == 1
|
||||||
|
assert list.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_product_no_remember(session):
|
||||||
|
"""Test adding a Product to a List without remembering it."""
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(
|
||||||
|
session,
|
||||||
|
list.id,
|
||||||
|
productName='Eggs',
|
||||||
|
productCategory='Dairy',
|
||||||
|
remember=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entry.id is not None
|
||||||
|
assert entry.product is not None
|
||||||
|
assert entry.product.name == 'Eggs'
|
||||||
|
assert entry.product.category == 'Dairy'
|
||||||
|
assert entry.product.remember is False
|
||||||
|
|
||||||
|
assert len(list.entries) == 1
|
||||||
|
assert list.entries[0] == entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_removes_product_with_remember(session):
|
||||||
|
"""Test that checking off and deleting a remembered Product does not delete the Product Entry."""
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(
|
||||||
|
session,
|
||||||
|
list.id,
|
||||||
|
productName='Eggs',
|
||||||
|
productCategory='Dairy',
|
||||||
|
remember=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pid = entry.product.id
|
||||||
|
|
||||||
|
list_entry_set_crossedOff(session, list.id, entry.id, True)
|
||||||
|
list_deleteCrossedOff(session, list.id)
|
||||||
|
|
||||||
|
assert product_by_id(session, pid) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_removes_product_no_remember(session):
|
||||||
|
"""Test that checking off and deleting a non-remembered Product deletes the Product Entry also."""
|
||||||
|
list = list_create(session, 'Test')
|
||||||
|
entry = list_addItem(
|
||||||
|
session,
|
||||||
|
list.id,
|
||||||
|
productName='Eggs',
|
||||||
|
productCategory='Dairy',
|
||||||
|
remember=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pid = entry.product.id
|
||||||
|
|
||||||
|
list_entry_set_crossedOff(session, list.id, entry.id, True)
|
||||||
|
list_deleteCrossedOff(session, list.id)
|
||||||
|
|
||||||
|
assert product_by_id(session, pid) is None
|
||||||
90
tests/test_31_list_recipes.py
Normal file
90
tests/test_31_list_recipes.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Test the List Recipe Service Entry Points.
|
||||||
|
|
||||||
|
Simple Grocery List (Sigl) | sigl.app
|
||||||
|
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sigl.domain.service import (
|
||||||
|
list_addItem,
|
||||||
|
list_addRecipe,
|
||||||
|
list_create,
|
||||||
|
product_create,
|
||||||
|
recipe_addItem,
|
||||||
|
recipe_create,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always use 'app' fixture so ORM gets initialized
|
||||||
|
pytestmark = pytest.mark.usefixtures('app')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_recipe_empty(session):
|
||||||
|
"""Test adding a Recipe to an empty list."""
|
||||||
|
pEggs = product_create(session, 'Eggs')
|
||||||
|
|
||||||
|
recipe = recipe_create(session, 'Test Recipe')
|
||||||
|
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
|
||||||
|
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
|
||||||
|
|
||||||
|
lc = list_create(session, 'Test')
|
||||||
|
lEntries = list_addRecipe(session, lc.id, recipe.id)
|
||||||
|
|
||||||
|
assert(len(lEntries) == 2)
|
||||||
|
assert(len(lc.entries) == 2)
|
||||||
|
|
||||||
|
assert(lc.entries[0].product.name == 'Eggs')
|
||||||
|
assert(lc.entries[0].quantity == '2')
|
||||||
|
assert(lc.entries[0].notes == 'Extra Large')
|
||||||
|
|
||||||
|
assert(lc.entries[1].product.name == 'Milk')
|
||||||
|
assert(lc.entries[1].quantity == '1 cup')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_recipe_merge_quantity(session):
|
||||||
|
"""Test adding a Recipe to a list with existing items, merging quantity."""
|
||||||
|
pEggs = product_create(session, 'Eggs')
|
||||||
|
|
||||||
|
recipe = recipe_create(session, 'Test Recipe')
|
||||||
|
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
|
||||||
|
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
|
||||||
|
|
||||||
|
lc = list_create(session, 'Test')
|
||||||
|
list_addItem(session, lc.id, productId=pEggs.id, quantity='12')
|
||||||
|
lEntries = list_addRecipe(session, lc.id, recipe.id)
|
||||||
|
|
||||||
|
assert(len(lEntries) == 2)
|
||||||
|
assert(len(lc.entries) == 2)
|
||||||
|
|
||||||
|
assert(lc.entries[0].product.name == 'Eggs')
|
||||||
|
assert(lc.entries[0].quantity == '12, 2 (Test Recipe)')
|
||||||
|
assert(lc.entries[0].notes == 'Extra Large')
|
||||||
|
|
||||||
|
assert(lc.entries[1].product.name == 'Milk')
|
||||||
|
assert(lc.entries[1].quantity == '1 cup')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_add_recipe_merge_notes(session):
|
||||||
|
"""Test adding a Recipe to a list with existing items, merging notes."""
|
||||||
|
pEggs = product_create(session, 'Eggs')
|
||||||
|
|
||||||
|
recipe = recipe_create(session, 'Test Recipe')
|
||||||
|
recipe_addItem(session, recipe.id, productId=pEggs.id, quantity='2', notes='Extra Large')
|
||||||
|
recipe_addItem(session, recipe.id, productName='Milk', quantity='1 cup')
|
||||||
|
|
||||||
|
lc = list_create(session, 'Test')
|
||||||
|
list_addItem(session, lc.id, productId=pEggs.id, notes='Brown, Cage Free')
|
||||||
|
lEntries = list_addRecipe(session, lc.id, recipe.id)
|
||||||
|
|
||||||
|
assert(len(lEntries) == 2)
|
||||||
|
assert(len(lc.entries) == 2)
|
||||||
|
|
||||||
|
assert(lc.entries[0].product.name == 'Eggs')
|
||||||
|
assert(lc.entries[0].quantity == '2')
|
||||||
|
assert(lc.entries[0].notes == 'Brown, Cage Free\nExtra Large')
|
||||||
|
|
||||||
|
assert(lc.entries[1].product.name == 'Milk')
|
||||||
|
assert(lc.entries[1].quantity == '1 cup')
|
||||||
Reference in New Issue
Block a user