diff --git a/sigl/__main__.py b/sigl/__main__.py index dbae0d7..c178edb 100644 --- a/sigl/__main__.py +++ b/sigl/__main__.py @@ -9,4 +9,4 @@ from .socketio import socketio app = create_app() -socketio.run(app) +socketio.run(app, host='0.0.0.0') diff --git a/sigl/domain/service.py b/sigl/domain/service.py new file mode 100644 index 0000000..a1854df --- /dev/null +++ b/sigl/domain/service.py @@ -0,0 +1,84 @@ +"""Sigl Domain Services. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from typing import List, Optional, Union + +from sqlalchemy.orm import Session + +from sigl.exc import NotFoundError +from .models import ListEntry, ShoppingList + + +def lists_all(session: Session) -> List[ShoppingList]: + """Return all Shopping Lists.""" + return session.query(ShoppingList).all() + + +def list_by_id(session: Session, id: int) -> Optional[ShoppingList]: + """Load a specific Shopping List.""" + return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none() + + +def list_create(session: Session, name: str, *, notes=None) -> ShoppingList: + """Create a new Shopping List.""" + sList = ShoppingList(name=name, notes=notes) + session.add(sList) + session.commit() + + return sList + + +def list_delete(session: Session, id: int): + """Delete a Shopping List.""" + sList = list_by_id(session, id) + if not sList: + raise NotFoundError(f'List {id} does not exist') + + session.delete(sList) + session.commit() + + +def list_stores(session: Session, id: int) -> List[str]: + """Get a list of all Stores for the List. + + This helper returns a list of all Stores for which the Products in the + List have locations. + """ + sList = list_by_id(session, id) + if not sList: + raise NotFoundError(f'List {id} does not exist') + + stores = set() + for e in sList.entries: + for loc in (e.product.locations or []): + stores.add(loc.store) + + if '' in stores: + stores.remove('') + if None in stores: + stores.remove(None) + + return list(stores) + + +def list_update( + session: Session, + id: int, + name: Union[str,None], + notes: Union[str,None], +) -> ShoppingList: + """Update the Name and/or Notes of a Shopping List.""" + sList = list_by_id(session, id) + if not sList: + raise NotFoundError(f'List {id} does not exist') + + sList.name = name + sList.notes = notes + + session.add(sList) + session.commit() + + return sList diff --git a/sigl/exc.py b/sigl/exc.py index 40fba76..f754728 100644 --- a/sigl/exc.py +++ b/sigl/exc.py @@ -33,3 +33,13 @@ class ConfigError(Error): """Class Constructor.""" super().__init__(*args) self.config_key = config_key + + +class DomainError(Error): + """Exception raised for domain logic errors.""" + pass + + +class NotFoundError(Error): + """Exception raised when an object cannot be found.""" + pass diff --git a/sigl/factory.py b/sigl/factory.py index 9d42e44..51d747f 100644 --- a/sigl/factory.py +++ b/sigl/factory.py @@ -61,7 +61,7 @@ def create_app(app_config=None, app_name=None): """ app = Flask( 'sigl', - template_folder='templates' + static_folder='../static', ) # Load Application Name and Version from pyproject.toml @@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None): init_socketio(app) # Initialize Frontend - from .frontend import init_frontend - init_frontend(app) + from .views import init_views + init_views(app) # Startup Complete app.logger.info('{} startup complete'.format(app.config['APP_NAME'])) diff --git a/sigl/templates/base.html.j2 b/sigl/templates/base.html.j2 new file mode 100644 index 0000000..0db372f --- /dev/null +++ b/sigl/templates/base.html.j2 @@ -0,0 +1,116 @@ + + + + + + + {% block title %}{% endblock %} + +{% block head_scripts %}{% endblock %} +{% if config['ENV'] == 'production' %} + +{% else %} + +{% endif %} +{% block head_styles %}{% endblock %} + + +
+ +
+{% block header %}{% endblock %} +
+
+
+{% block main %}{% endblock %} +
+{% block body_scripts %}{% endblock %} +
+ +
+ + + \ No newline at end of file diff --git a/sigl/templates/lists/create.html.j2 b/sigl/templates/lists/create.html.j2 new file mode 100644 index 0000000..3c2ed20 --- /dev/null +++ b/sigl/templates/lists/create.html.j2 @@ -0,0 +1,29 @@ +{% extends 'base.html.j2' %} +{% block title %}Create New List | Sigl{% endblock %} +{% block header %} +
+
Create New List
+
+ + Cancel + +
+
+{% endblock %} +{% block main %} +
+
+
+ + +
+
+ + +
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/sigl/templates/lists/detail.html.j2 b/sigl/templates/lists/detail.html.j2 new file mode 100644 index 0000000..59be874 --- /dev/null +++ b/sigl/templates/lists/detail.html.j2 @@ -0,0 +1,88 @@ +{% extends 'base.html.j2' %} +{% block title %}{{ list.name }} | Sigl{% endblock %} +{% block header %} +
+
{{ list.name }}
+
+ + + + + Add Item + + + + + + + +
+
+{% endblock %} +{% block main %} +
+
+ + +
+
+{% if list.entries|length == 0 %} +
+
No Items
+
+{% else %} +{% if sortBy == 'none' %} + +{% elif sortBy == 'category' %} +{% for hdr, entries in groups.items() %} +
{{ hdr }}
+ +{% endfor %} +{% elif sortBy == 'store' %} +{% for hdr, entries in groups.items() %} +
Aisle {{ hdr }}
+ +{% endfor %} +{% endif %} +{% endif %} +
+ +{% endblock %} \ No newline at end of file diff --git a/sigl/templates/lists/home.html.j2 b/sigl/templates/lists/home.html.j2 new file mode 100644 index 0000000..efdb9b0 --- /dev/null +++ b/sigl/templates/lists/home.html.j2 @@ -0,0 +1,28 @@ +{% extends 'base.html.j2' %} +{% block title %}Home | Sigl{% endblock %} +{% block header %} +
+
All Lists
+ + + + + New List + +
+{% endblock %} +{% block main %} +{% if lists|length == 0 %} +
+
No shopping lists
+
+{% else %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/sigl/views/__init__.py b/sigl/views/__init__.py new file mode 100644 index 0000000..f445d97 --- /dev/null +++ b/sigl/views/__init__.py @@ -0,0 +1,17 @@ +"""Sigl View Blueprints. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from .lists import bp as list_bp + +__all__ = ('init_views', ) + + +def init_views(app): + """Register the View Blueprints with the Application.""" + app.register_blueprint(list_bp) + + # Notify Initialization Complete + app.logger.debug('Views Initialized') diff --git a/sigl/views/lists.py b/sigl/views/lists.py new file mode 100644 index 0000000..a2f3844 --- /dev/null +++ b/sigl/views/lists.py @@ -0,0 +1,116 @@ +"""Sigl Shopping List View Blueprint. + +Simple Grocery List (Sigl) | sigl.app +Copyright (c) 2022 Asymworks, LLC. All Rights Reserved. +""" + +from flask import ( + Blueprint, + abort, flash, redirect, render_template, request, url_for +) + +from sigl.exc import Error +from sigl.database import db +from sigl.domain.service import ( + lists_all, list_by_id, list_create, list_delete, + list_stores, +) + +__all__ = ('bp', ) + + +#: Lists Blueprint +bp = Blueprint('lists', __name__) + + +@bp.route('/') +@bp.route('/lists') +def home(): + """Sigl Home Page / All Shopping Lists View.""" + lists = lists_all(db.session) + return render_template('lists/home.html.j2', lists=lists) + + +@bp.route('/lists/new', methods=('GET', 'POST')) +def create(): + """Create Shopping List View.""" + if request.method == 'POST': + list_name = request.form['name'] + list_notes = request.form['notes'] + if not list_name: + flash('Error: List Name is required') + return render_template('lists/create.html.j2') + + list = list_create(db.session, list_name, notes=list_notes) + return redirect(url_for('lists.detail', id=list.id)) + + else: + return render_template('lists/create.html.j2') + + +@bp.route('/lists/') +def detail(id): + """Shopping List Detail View.""" + try: + sList = list_by_id(db.session, id) + sortBy = request.args.get('sort', 'none') + sortStore = request.args.get('store', '') + + if sortBy not in ('none', 'category', 'store'): + flash(f'Invalid sorting mode {sortBy}', 'warning') + sortBy = 'category' + + groups = dict() + for e in sList.entries: + if sortBy == 'category': + category = e.product.category or 'Uncategorized' + if category not in groups: + groups[category] = [{'entry': e}] + else: + groups[category].append({'entry': e}) + + elif sortBy == 'store': + aisle = 'Unknown' + bin = None + locs = e.product.locations + for l in locs: + if l.store.lower() == sortStore.lower(): + aisle = l.aisle + bin = l.bin + + if aisle not in groups: + groups[aisle] = [{'entry': e, 'bin': bin}] + else: + groups[aisle].append({'entry': e, 'bin': bin}) + + else: + category = 'Unsorted' + if category not in groups: + groups[category] = [{'entry': e}] + else: + groups[category].append({'entry': e}) + + flash('An error occurred during the processing of this request', 'error') + return render_template( + 'lists/detail.html.j2', + list=list_by_id(db.session, id), + sortBy=sortBy, + sortStore=sortStore, + groups=groups, + stores=list_stores(db.session, id), + ) + + except Error as e: + flash(str(e), 'error') + return redirect(url_for('lists.home')) + + +@bp.route('/lists//delete', methods=('POST', )) +def delete(id): + """Delete a Shopping List.""" + try: + list_delete(db.session, id) + except Error as e: + flash(str(e), 'error') + + return redirect(url_for('lists.home'))