From 17041c9c8be35ef4f0884bb44b97d6f2f9df3272 Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Thu, 14 Jul 2022 08:48:02 -0700 Subject: [PATCH] Add Cross-Off Logic --- sigl/domain/service.py | 28 ++++++++++++ sigl/exc.py | 2 +- sigl/templates/lists/detail.html.j2 | 69 ++++++++++++++++++++++++++--- sigl/views/lists.py | 47 ++++++++++++++++++-- 4 files changed, 136 insertions(+), 10 deletions(-) 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' %} {% elif sortBy == 'category' %} {% for hdr, entries in groups.items() %} -
    {{ hdr }}
    +{% set outer_loop = loop %} +
    {{ hdr }}
    {% endfor %} {% elif sortBy == 'store' %} {% for hdr, entries in groups.items() %} -
    Aisle {{ hdr }}
    +{% set outer_loop = loop %} +
    Aisle {{ hdr }}
    {% 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."""