Add Product Views

This commit is contained in:
2022-07-14 14:14:50 -07:00
parent 0eafe8786d
commit 60aa886635
11 changed files with 528 additions and 18 deletions

View File

@@ -4,12 +4,13 @@ Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from nis import cat
from typing import List, Optional, Union
from sqlalchemy.orm import Session
from sigl.exc import DomainError, NotFoundError
from .models import ListEntry, Product, ShoppingList
from .models import ListEntry, Product, ProductLocation, ShoppingList
def list_addItem(
@@ -132,12 +133,16 @@ def list_editItem(
return entry
def list_stores(session: Session, id: int) -> List[str]:
def list_stores(session: Session, id: Optional[int]) -> List[str]:
"""Get a list of all Stores for the List.
This helper returns a list of all Stores for which the Products in the
List have locations.
List have locations. If the List ID is `None`, all stores for which any
Product has locations are returned.
"""
if id is None:
return list(set([l.store for l in session.query(ProductLocation).all()]))
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
@@ -216,3 +221,92 @@ def products_all(session: Session) -> List[Product]:
def product_by_id(session: Session, id: int) -> Optional[Product]:
"""Load a specific Product."""
return session.query(Product).filter(Product.id == id).one_or_none()
def product_create(
session: Session,
name: str,
*,
category: Optional[str],
notes: Optional[str],
) -> Product:
"""Create a new Product."""
product = Product(name=name, category=category, notes=notes)
session.add(product)
session.commit()
return product
def product_delete(session: Session, id: int):
"""Delete a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
session.delete(product)
session.commit()
def product_update(
session: Session,
id: int,
name: str,
category: Optional[str],
notes: Optional[str],
) -> Product:
"""Update a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
product.name = name
product.category = category
product.notes = notes
product.set_modified_at()
session.add(product)
session.commit()
return product
def product_addLocation(
session: Session,
id: int,
store: str,
*,
aisle: Optional[str],
bin: Optional[str]
) -> ProductLocation:
"""Add a Store Location to a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
for loc in product.locations:
if loc.store.lower() == store.lower():
raise DomainError(f'A location already exists for store {loc.store}')
loc = ProductLocation(product=product, store=store, aisle=aisle, bin=bin)
session.add(loc)
session.commit()
return loc
def product_removeLocation(
session: Session,
id: int,
store: str
):
"""Remove a Store Location from a Product."""
product = product_by_id(session, id)
if not product:
raise NotFoundError(f'Product {id} does not exist')
for loc in product.locations:
if loc.store.lower() == store.lower():
session.delete(loc)
session.commit()

View File

@@ -25,8 +25,8 @@
</div>
<div>
<div class="ml-4 flex items-baseline space-x-4">
<a href="{{ url_for('lists.home') }}" class="text-white border-b border-white mx-2 py-1 text-sm font-medium" aria-current="page">Shopping Lists</a>
<a href="#" class="text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium">Products</a>
<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>

View File

@@ -24,7 +24,7 @@
<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 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()">
<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>

View File

@@ -14,7 +14,7 @@
{% block main %}
{% if lists|length == 0 %}
<div class="py-2 border-b border-gray-300">
<div class="ml-4 text-sm">No shopping lists</div>
<div class="ml-4">No shopping lists</div>
</div>
{% else %}
<ul class="w-full">

View File

@@ -21,7 +21,7 @@
<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 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()">
<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>

View File

@@ -0,0 +1,35 @@
{% extends 'base.html.j2' %}
{% block title %}Create New Product | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col justify-start items-start sm:flex-row sm:justify-between sm:items-center sm:py-1">
<div class="text-sm w-full text-gray-800 py-1 border-b sm:border-none">Create New Product</span></span></div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="category" class="py-1 text-xs text-gray-700 font-semibold">Product Category:</label>
<input type="text" name="category" id="category" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea>
</div>
<div class="flex items-center justify-between w-full">
<div class="flex justify-start items-start">
<a href="{{ url_for('products.home') }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Create</button>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,150 @@
{% extends 'base.html.j2' %}
{% block title %}{{ product.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex flex-col justify-start items-start sm:flex-row sm:justify-between sm:items-center sm:py-1">
<div class="text-sm w-full text-gray-800 py-1 border-b sm:border-none">Edit <span class="font-bold ">{{ product.name }}</span></span></div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Product Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" value="{{ product.name }}" />
</div>
<div class="flex flex-col pb-4">
<label for="category" class="py-1 text-xs text-gray-700 font-semibold">Product Category:</label>
<input type="text" name="category" id="category" class="p-1 text-sm border border-gray-200 rounded" value="{{ product.category }}" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded">{{ product.notes or '' }}</textarea>
</div>
<div class="pb-4">
<fieldset>
<legend class="text-sm font-semibold">Product Locations</legend>
<table class="w-full table-fixed mx-2">
<thead>
<tr class="text-sm">
<th class="w-2/5 text-left">Store</th>
<th class="w-1/4 text-left">Aisle</th>
<th class="w-1/4 text-left">Bin</th>
<th></th>
</tr>
</thead>
<tbody>
{% for loc in product.locations %}
<tr>
<td>{{ loc.store }}</td>
<td>{{ loc.aisle }}</td>
<td>{{ loc.bin or '' }}</td>
<td>
<button type="button" class="pt-1 text-red-600 hover:text-red-700" onclick='removeLocation("{{ loc.store }}")'>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td><input type="text" id="locStore" class="w-full border rounded text" /></td>
<td><input type="text" id="locAisle" class="w-full border rounded text" /></td>
<td><input type="text" id="locBin" class="w-full border rounded text" /></td>
<td>
<button type="button" class="pt-1 text-green-600 hover:text-green-700" onclick="addLocation()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</td>
</tr>
</tfoot>
</table>
</fieldset>
</div>
<div class="flex items-center justify-between w-full">
<div class="flex items-center">
<a href="{{ url_for('products.home') }}" class="px-2 py-1 text-sm text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
<button type="button" id="delete-item-btn" class="flex ml-2 px-2 py-1 text-sm text-white bg-red-600 hover:bg-red-700 border rounded flex justify-between items-center" onclick="deleteItem()">
<svg xmlns="http://www.w3.org/2000/svg" class="pr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete Product
</button>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Update Item</button>
</div>
</div>
</div>
</form>
<form action="{{ url_for('products.delete', id=product.id) }}" method="post" id="delete-item-form"></form>
<form action="{{ url_for('products.addLocation', id=product.id) }}" method="post" id="add-loc-form">
<input type="hidden" name="store" id="addLocationStore" />
<input type="hidden" name="aisle" id="addLocationAisle" />
<input type="hidden" name="bin" id="addLocationBin" />
</form>
<form action="{{ url_for('products.removeLocation', id=product.id) }}" method="post" id="remove-loc-form">
<input type="hidden" name="store" id="removeLocationStore" />
</form>
<script language="javascript">
function deleteItem() {
const delForm = document.getElementById('delete-item-form');
console.log(delForm);
if (delForm && confirm('Are you sure you want to delete product "{{ product.name }}"? This cannot be undone.')) {
console.log(delForm.action);
delForm.submit();
}
}
function addLocation() {
const form = document.getElementById('add-loc-form');
const fStore = document.getElementById('addLocationStore');
const fAisle = document.getElementById('addLocationAisle');
const fBin = document.getElementById('addLocationBin');
const iStore = document.getElementById('locStore');
const iAisle = document.getElementById('locAisle');
const iBin = document.getElementById('locBin');
if (!form || !fStore || !fAisle || !fBin || !iStore || !iAisle || !iBin) {
document.dispatchEvent(
new CustomEvent('notice', {
detail: {
type: 'error',
text: 'An internal error occurred when adding the product location.',
},
})
);
}
fStore.value = iStore.value;
fAisle.value = iAisle.value;
fBin.value = iBin.value;
form.submit();
}
function removeLocation(store) {
const form = document.getElementById('remove-loc-form');
const fStore = document.getElementById('removeLocationStore');
if (!form || !fStore) {
document.dispatchEvent(
new CustomEvent('notice', {
detail: {
type: 'error',
text: 'An internal error occurred when removing the product location.',
},
})
);
}
fStore.value = store;
form.submit();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,82 @@
{% extends 'base.html.j2' %}
{% block title %}Products | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg grow mr-2 border-b text-gray-800">
<input type="search" id="search" class="w-full outline-none focus:outline-none" placeholder="Search Products" oninput="filterList()" />
</div>
<a href="{{ url_for('products.create') }}" class="shrink-0 px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
New Product
</a>
</div>
{% endblock %}
{% block main %}
{% if products|length == 0 %}
<div class="py-2 border-b border-gray-300">
<div class="ml-4">No Products</div>
</div>
{% else %}
{% for hdr in groups.keys()|sort %}
{% set outer_loop = loop %}
<div class="sigl-category text-sm text-gray-600 font-bold py-1 px-4 bg-gray-50 border-b border-gray-300">{{ hdr }}</div>
<ul class="sigl-list">
{% for e in groups[hdr]|sort(attribute='name') %}
<li data-value="{{ e.name|lower }}" class="sigl-list-item block w-full py-2{% if not (loop.last and outer_loop.last) %} border-b border-gray-300{% endif %}">
<a href="{{ url_for('products.detail', id=e.id) }}"><div class="px-4">{{ e.name }}</div></a>
</li>
{% endfor %}
</ul>
{% endfor %}
<div id="sigl-no-products-found" class="hidden py-2 border-b border-gray-300">
<div class="ml-4">No Products Found</div>
</div>
{% endif %}
<script language="javascript">
function filterList() {
let nshown = 0;
const searchEl = document.getElementById('search');
const searchText = searchEl && searchEl.value.toLowerCase() || '';
document.querySelectorAll('.sigl-list-item').forEach(function (itm) {
const itmValue = itm.dataset.value;
if (searchText === '') {
console.log(`Showing ${itmValue}`)
itm.classList.remove('hidden');
nshown += 1;
} else {
if (itmValue.includes(searchText)) {
console.log(`Hiding ${itmValue}`)
itm.classList.remove('hidden');
nshown += 1;
} else {
console.log(`Showing ${itmValue}`)
itm.classList.add('hidden');
}
}
});
document.querySelectorAll('.sigl-category').forEach(function (itm) {
const ul = itm.nextElementSibling;
const lis = [].slice.call(ul.children);
const nShowing = lis
.map((itm) => itm.classList.contains('hidden') ? 0 : 1)
.reduce((p, i) => p + i, 0);
if (nShowing === 0) {
itm.classList.add('hidden');
} else {
itm.classList.remove('hidden');
}
});
const noneFound = document.getElementById('sigl-no-products-found');
if (nshown === 0) {
noneFound.classList.remove('hidden');
} else {
noneFound.classList.add('hidden');
}
}
</script>
{% endblock %}

View File

@@ -5,6 +5,7 @@ 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', )
@@ -12,6 +13,7 @@ __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')

View File

@@ -37,8 +37,8 @@ def home():
def create():
"""Create Shopping List View."""
if request.method == 'POST':
list_name = request.form['name']
list_notes = request.form['notes']
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')
@@ -119,8 +119,8 @@ def update(id):
list_update(
db.session,
id,
name=request.form.get('name', sList.name),
notes=request.form.get('notes', sList.notes),
name=request.form.get('name', sList.name).strip(),
notes=request.form.get('notes', sList.notes).strip(),
)
return redirect(url_for('lists.detail', id=id))
@@ -213,10 +213,10 @@ def addItem(id):
)
productId = request.form['product']
productName = request.form.get('productName', None)
productCategory = request.form.get('productCategory', None)
quantity = request.form.get('quantity', None)
notes = request.form.get('notes', None)
productName = request.form.get('productName', '').strip()
productCategory = request.form.get('productCategory', '').strip()
quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip()
if productId == 'new' or productId == '':
productId = None
@@ -249,8 +249,8 @@ def editItem(listId, entryId):
try:
entry = list_entry_by_id(db.session, listId, entryId)
if request.method == 'POST':
quantity = request.form.get('quantity', None)
notes = request.form.get('notes', None)
quantity = request.form.get('quantity', '').strip()
notes = request.form.get('notes', '').strip()
list_editItem(
db.session,

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

@@ -0,0 +1,147 @@
"""Sigl Products View Blueprint.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import (
Blueprint,
flash, redirect, render_template, request, url_for
)
from sigl.exc import Error, NotFoundError
from sigl.database import db
from sigl.domain.service import (
list_stores,
product_update,
products_all,
product_by_id,
product_create,
product_delete,
product_update,
product_addLocation,
product_removeLocation,
)
__all__ = ('bp', )
#: Lists Blueprint
bp = Blueprint('products', __name__)
@bp.route('/products')
def home():
"""All Products View."""
products = products_all(db.session)
groups = dict()
for product in products:
cat = 'No Category'
if product.category:
cat = product.category
if cat not in groups:
groups[cat] = [product]
else:
groups[cat].append(product)
return render_template('products/home.html.j2', products=products, groups=groups)
@bp.route('/products/new', methods=('GET', 'POST'))
def create():
"""Create a new Product."""
if request.method == 'POST':
product_name = request.form['name'].strip()
product_category = request.form['category'].strip()
product_notes = request.form['notes'].strip()
if not product_name:
flash('Error: Product Name is required')
return render_template('products/create.html.j2')
product = product_create(
db.session,
product_name,
category=product_category,
notes=product_notes,
)
return redirect(url_for('products.detail', id=product.id))
return render_template('products/create.html.j2')
@bp.route('/products/<int:id>', methods=('GET', 'POST'))
def detail(id):
"""Product Detail/Editing View."""
try:
product = product_by_id(db.session, id);
if not product:
raise NotFoundError(f'Product {id} not found')
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.home'))
if request.method == 'POST':
try:
name = request.form['name'].strip()
category = request.form['category'].strip()
notes = request.form['notes'].strip()
product_update(
db.session,
id,
name,
category,
notes,
)
return redirect(url_for('products.home'))
except Error as e:
flash(str(e), 'error')
return render_template(
'products/detail.html.j2',
product=product,
stores=list_stores(db.session, None),
)
@bp.route('/products/<int:id>/delete', methods=('POST', ))
def delete(id):
"""Delete a Product."""
try:
product_delete(db.session, id)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.home'))
@bp.route('/products/<int:id>/addLocation', methods=('POST', ))
def addLocation(id):
"""Add a Location to a Product."""
store = request.form['store'].strip()
aisle = request.form['aisle'].strip()
bin = request.form['bin'].strip()
try:
product_addLocation(db.session, id, store, aisle=aisle, bin=bin)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.detail', id=id))
@bp.route('/products/<int:id>/removeLocation', methods=('POST', ))
def removeLocation(id):
"""Remove a Location from a Product."""
store = request.form['store'].strip()
print(request.form)
try:
product_removeLocation(db.session, id, store)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('products.detail', id=id))