diff --git a/sigl/domain/service.py b/sigl/domain/service.py
index 17a0feb..7e165fa 100644
--- a/sigl/domain/service.py
+++ b/sigl/domain/service.py
@@ -132,6 +132,34 @@ def list_update(
return sList
+def list_entry_by_id(session: Session, id: int) -> Optional[ListEntry]:
+ """Load a specific Shopping List Entry."""
+ return session.query(ListEntry).filter(ListEntry.id == id).one_or_none()
+
+
+def list_entry_set_crossedOff(session: Session, listId: int, entryId: int, crossedOff: bool) -> ListEntry:
+ """Set the Crossed-Off state of a List Entry."""
+ sList = list_by_id(session, listId)
+ if not sList:
+ raise NotFoundError(f'List {listId} not found')
+
+ entry = list_entry_by_id(session, entryId)
+ 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,
+ )
+
+ entry.crossedOff = crossedOff
+ session.add(entry)
+ session.commit()
+
+ return entry
+
+
def products_all(session: Session) -> List[Product]:
"""Return all Products."""
return session.query(Product).all()
diff --git a/sigl/exc.py b/sigl/exc.py
index f754728..104725e 100644
--- a/sigl/exc.py
+++ b/sigl/exc.py
@@ -42,4 +42,4 @@ class DomainError(Error):
class NotFoundError(Error):
"""Exception raised when an object cannot be found."""
- pass
+ default_code = 404
diff --git a/sigl/templates/lists/detail.html.j2 b/sigl/templates/lists/detail.html.j2
index 86f26ad..f35b30f 100644
--- a/sigl/templates/lists/detail.html.j2
+++ b/sigl/templates/lists/detail.html.j2
@@ -41,27 +41,55 @@
No Items
{% else %}
+{% macro listEntry(entry, bin=None, last=False) -%}
+
+
+
+
{{ entry.product.name }}{% if entry.quantity %} | {{ entry.quantity }}{% endif %}
+
+
+
{% if bin %}Bin {{ bin }}{% endif %}
+
+
+
+
+
+{%- endmacro %}
{% if sortBy == 'none' %}
{% for e in list.entries %}
- - {{ e.product.name }}
+ {{ listEntry(e, last=list.last) }}
{% endfor %}
{% elif sortBy == 'category' %}
{% for hdr, entries in groups.items() %}
-{{ hdr }}
+{% set outer_loop = loop %}
+{{ hdr }}
{% for e in entries %}
- - {{ e.entry.product.name }}
+ {{ listEntry(e.entry, last=loop.last and outer_loop.last) }}
{% endfor %}
{% endfor %}
{% elif sortBy == 'store' %}
{% for hdr, entries in groups.items() %}
-Aisle {{ hdr }}
+{% set outer_loop = loop %}
+Aisle {{ hdr }}
-{% for e in entries %}
- - {{ e.entry.product.name }}{% if e.bin %} (Bin {{e.bin}}){% endif %}
+{% for e in entries|sort(attribute='bin') %}
+ {{ listEntry(e.entry, bin=e.bin, last=loop.last and outer_loop.last) }}
{% endfor %}
{% endfor %}
@@ -84,5 +112,34 @@ function deleteList() {
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}` }
+ }
+ )
+ );
+ }
+ });
+}
{% endblock %}
\ No newline at end of file
diff --git a/sigl/views/lists.py b/sigl/views/lists.py
index d1201af..8550a25 100644
--- a/sigl/views/lists.py
+++ b/sigl/views/lists.py
@@ -6,14 +6,14 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
from flask import (
Blueprint,
- flash, redirect, render_template, request, url_for
+ flash, jsonify, make_response, redirect, render_template, request, url_for
)
-from sigl.exc import Error
+from sigl.exc import DomainError, Error, NotFoundError
from sigl.database import db
from sigl.domain.service import (
lists_all, list_by_id, list_create, list_delete,
- list_addItem, list_stores,
+ list_addItem, list_stores, list_entry_set_crossedOff,
products_all,
)
@@ -54,6 +54,9 @@ def detail(id):
"""Shopping List Detail View."""
try:
sList = list_by_id(db.session, id)
+ if not sList:
+ raise NotFoundError(f'List {id} not found')
+
sortBy = request.args.get('sort', 'none')
sortStore = request.args.get('store', '')
@@ -116,6 +119,44 @@ def delete(id):
return redirect(url_for('lists.home'))
+@bp.route('/lists//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//addItem', methods=('GET', 'POST'))
def addItem(id):
"""Add an Item to a Shopping List."""