Add Cross-Off Logic
This commit is contained in:
@@ -132,6 +132,34 @@ def list_update(
|
|||||||
return sList
|
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]:
|
def products_all(session: Session) -> List[Product]:
|
||||||
"""Return all Products."""
|
"""Return all Products."""
|
||||||
return session.query(Product).all()
|
return session.query(Product).all()
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ class DomainError(Error):
|
|||||||
|
|
||||||
class NotFoundError(Error):
|
class NotFoundError(Error):
|
||||||
"""Exception raised when an object cannot be found."""
|
"""Exception raised when an object cannot be found."""
|
||||||
pass
|
default_code = 404
|
||||||
|
|||||||
@@ -41,27 +41,55 @@
|
|||||||
<div class="ml-4 text-sm">No Items</div>
|
<div class="ml-4 text-sm">No Items</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% macro listEntry(entry, bin=None, last=False) -%}
|
||||||
|
<li
|
||||||
|
id="item-{{ entry.id }}"
|
||||||
|
x-data='{crossedOff:{{ entry.crossedOff|tojson }}}'
|
||||||
|
class="flex items-center justify-between 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-start flex-grow" @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 @click.prevent :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>
|
||||||
|
<a href="#" class="py-1 text-sm text-gray-800 hover:text-blue-700">
|
||||||
|
<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="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>
|
||||||
|
</li>
|
||||||
|
{%- endmacro %}
|
||||||
{% if sortBy == 'none' %}
|
{% if sortBy == 'none' %}
|
||||||
<ul>
|
<ul>
|
||||||
{% for e in list.entries %}
|
{% for e in list.entries %}
|
||||||
<li>{{ e.product.name }}</li>
|
{{ listEntry(e, last=list.last) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% elif sortBy == 'category' %}
|
{% elif sortBy == 'category' %}
|
||||||
{% for hdr, entries in groups.items() %}
|
{% for hdr, entries in groups.items() %}
|
||||||
<div class="text-sm text-gray-600 font-bold py-1 px-2">{{ hdr }}</div>
|
{% 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>
|
<ul>
|
||||||
{% for e in entries %}
|
{% for e in entries %}
|
||||||
<li>{{ e.entry.product.name }}</li>
|
{{ listEntry(e.entry, last=loop.last and outer_loop.last) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif sortBy == 'store' %}
|
{% elif sortBy == 'store' %}
|
||||||
{% for hdr, entries in groups.items() %}
|
{% for hdr, entries in groups.items() %}
|
||||||
<div class="text-sm text-gray-600 font-bold py-1 px-2">Aisle {{ hdr }}</div>
|
{% 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>
|
<ul>
|
||||||
{% for e in entries %}
|
{% for e in entries|sort(attribute='bin') %}
|
||||||
<li>{{ e.entry.product.name }}{% if e.bin %} (Bin {{e.bin}}){% endif %}</li>
|
{{ listEntry(e.entry, bin=e.bin, last=loop.last and outer_loop.last) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -84,5 +112,34 @@ function deleteList() {
|
|||||||
form.submit();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,14 +6,14 @@ Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
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.database import db
|
||||||
from sigl.domain.service import (
|
from sigl.domain.service import (
|
||||||
lists_all, list_by_id, list_create, list_delete,
|
lists_all, list_by_id, list_create, list_delete,
|
||||||
list_addItem, list_stores,
|
list_addItem, list_stores, list_entry_set_crossedOff,
|
||||||
products_all,
|
products_all,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +54,9 @@ def detail(id):
|
|||||||
"""Shopping List Detail View."""
|
"""Shopping List Detail View."""
|
||||||
try:
|
try:
|
||||||
sList = list_by_id(db.session, id)
|
sList = list_by_id(db.session, id)
|
||||||
|
if not sList:
|
||||||
|
raise NotFoundError(f'List {id} not found')
|
||||||
|
|
||||||
sortBy = request.args.get('sort', 'none')
|
sortBy = request.args.get('sort', 'none')
|
||||||
sortStore = request.args.get('store', '')
|
sortStore = request.args.get('store', '')
|
||||||
|
|
||||||
@@ -116,6 +119,44 @@ def delete(id):
|
|||||||
return redirect(url_for('lists.home'))
|
return redirect(url_for('lists.home'))
|
||||||
|
|
||||||
|
|
||||||
|
@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'))
|
@bp.route('/lists/<int:id>/addItem', methods=('GET', 'POST'))
|
||||||
def addItem(id):
|
def addItem(id):
|
||||||
"""Add an Item to a Shopping List."""
|
"""Add an Item to a Shopping List."""
|
||||||
|
|||||||
Reference in New Issue
Block a user