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 %}
+
+{% 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 %}
+
+{% endblock %}
+{% block main %}
+
+
+
+
+
+
+{% if list.entries|length == 0 %}
+
+{% else %}
+{% if sortBy == 'none' %}
+
+{% for e in list.entries %}
+ - {{ e.product.name }}
+{% endfor %}
+
+{% elif sortBy == 'category' %}
+{% for hdr, entries in groups.items() %}
+{{ hdr }}
+
+{% for e in entries %}
+ - {{ e.entry.product.name }}
+{% endfor %}
+
+{% endfor %}
+{% elif sortBy == 'store' %}
+{% for hdr, entries in groups.items() %}
+Aisle {{ hdr }}
+
+{% for e in entries %}
+ - {{ e.entry.product.name }}{% if e.bin %} (Bin {{e.bin}}){% endif %}
+{% endfor %}
+
+{% 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 %}
+
+{% endblock %}
+{% block main %}
+{% if lists|length == 0 %}
+
+{% 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'))