Add Cross-Off Logic
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -42,4 +42,4 @@ class DomainError(Error):
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""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>
|
||||
{% 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' %}
|
||||
<ul>
|
||||
{% for e in list.entries %}
|
||||
<li>{{ e.product.name }}</li>
|
||||
{{ listEntry(e, last=list.last) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elif sortBy == 'category' %}
|
||||
{% 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>
|
||||
{% for e in entries %}
|
||||
<li>{{ e.entry.product.name }}</li>
|
||||
{{ listEntry(e.entry, last=loop.last and outer_loop.last) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% elif sortBy == 'store' %}
|
||||
{% 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>
|
||||
{% for e in entries %}
|
||||
<li>{{ e.entry.product.name }}{% if e.bin %} (Bin {{e.bin}}){% endif %}</li>
|
||||
{% for e in entries|sort(attribute='bin') %}
|
||||
{{ listEntry(e.entry, bin=e.bin, last=loop.last and outer_loop.last) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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}` }
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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/<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."""
|
||||
|
||||
Reference in New Issue
Block a user