Add Cross-Off Logic

This commit is contained in:
2022-07-14 08:48:02 -07:00
parent 5e9aacdb8c
commit 17041c9c8b
4 changed files with 136 additions and 10 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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."""