diff --git a/docs/api/sideshow.db.model.stores.rst b/docs/api/sideshow.db.model.stores.rst new file mode 100644 index 0000000..b114a9b --- /dev/null +++ b/docs/api/sideshow.db.model.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.stores`` +============================ + +.. automodule:: sideshow.db.model.stores + :members: diff --git a/docs/api/sideshow.web.views.stores.rst b/docs/api/sideshow.web.views.stores.rst new file mode 100644 index 0000000..896a0d7 --- /dev/null +++ b/docs/api/sideshow.web.views.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.stores`` +============================= + +.. automodule:: sideshow.web.views.stores + :members: diff --git a/docs/index.rst b/docs/index.rst index 29882dd..643578b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,6 +43,7 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.db.model.customers api/sideshow.db.model.orders api/sideshow.db.model.products + api/sideshow.db.model.stores api/sideshow.enum api/sideshow.orders api/sideshow.web @@ -58,3 +59,4 @@ For an online demo see https://demo.wuttaproject.org/ api/sideshow.web.views.customers api/sideshow.web.views.orders api/sideshow.web.views.products + api/sideshow.web.views.stores diff --git a/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py new file mode 100644 index 0000000..79e6242 --- /dev/null +++ b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py @@ -0,0 +1,39 @@ +"""add stores + +Revision ID: a4273360d379 +Revises: 7a6df83afbd4 +Create Date: 2025-01-27 17:48:20.638664 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = 'a4273360d379' +down_revision: Union[str, None] = '7a6df83afbd4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # sideshow_store + op.create_table('sideshow_store', + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('store_id', sa.String(length=10), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('archived', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_store')), + sa.UniqueConstraint('store_id', name=op.f('uq_sideshow_store_store_id')), + sa.UniqueConstraint('name', name=op.f('uq_sideshow_store_name')) + ) + + +def downgrade() -> None: + + # sideshow_store + op.drop_table('sideshow_store') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index f53dd27..056ccfc 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -30,6 +30,7 @@ This namespace exposes everything from Primary :term:`data models `: +* :class:`~sideshow.db.model.stores.Store` * :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.OrderItem` * :class:`~sideshow.db.model.orders.OrderItemEvent` @@ -48,6 +49,7 @@ And the :term:`batch` models: from wuttjamaican.db.model import * # sideshow models +from .stores import Store from .customers import LocalCustomer, PendingCustomer from .products import LocalProduct, PendingProduct from .orders import Order, OrderItem, OrderItemEvent diff --git a/src/sideshow/db/model/stores.py b/src/sideshow/db/model/stores.py new file mode 100644 index 0000000..b1956c1 --- /dev/null +++ b/src/sideshow/db/model/stores.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Data models for Stores +""" + +import sqlalchemy as sa + +from wuttjamaican.db import model + + +class Store(model.Base): + """ + Represents a physical location for the business. + """ + __tablename__ = 'sideshow_store' + + uuid = model.uuid_column() + + store_id = sa.Column(sa.String(length=10), nullable=False, unique=True, doc=""" + Unique ID for the store. + """) + + name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc=""" + Display name for the store (must be unique!). + """) + + archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc=""" + Indicates the store has been "retired" essentially, and mostly + hidden from view. + """) + + def __str__(self): + return self.name or "" diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 1641c72..9da61c0 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler): def make_admin_menu(self, request, **kwargs): """ """ kwargs['include_people'] = True - return super().make_admin_menu(request, **kwargs) + menu = super().make_admin_menu(request, **kwargs) + + menu['items'].insert(0, { + 'title': "Stores", + 'route': 'stores', + 'perm': 'stores.list', + }) + + return menu diff --git a/src/sideshow/web/views/__init__.py b/src/sideshow/web/views/__init__.py index 13a468c..e5a14ac 100644 --- a/src/sideshow/web/views/__init__.py +++ b/src/sideshow/web/views/__init__.py @@ -35,6 +35,7 @@ def includeme(config): }) # sideshow views + config.include('sideshow.web.views.stores') config.include('sideshow.web.views.customers') config.include('sideshow.web.views.products') config.include('sideshow.web.views.orders') diff --git a/src/sideshow/web/views/stores.py b/src/sideshow/web/views/stores.py new file mode 100644 index 0000000..0eaf41d --- /dev/null +++ b/src/sideshow/web/views/stores.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Sideshow -- Case/Special Order Tracker +# Copyright © 2024-2025 Lance Edgar +# +# This file is part of Sideshow. +# +# Sideshow is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Sideshow is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Sideshow. If not, see . +# +################################################################################ +""" +Views for Stores +""" + +from wuttaweb.views import MasterView + +from sideshow.db.model import Store + + +class StoreView(MasterView): + """ + Master view for + :class:`~sideshow.db.model.stores.Store`; route prefix + is ``stores``. + + Notable URLs provided by this class: + + * ``/stores/`` + * ``/stores/new`` + * ``/stores/XXX`` + * ``/stores/XXX/edit`` + * ``/stores/XXX/delete`` + """ + model_class = Store + + labels = { + 'store_id': "Store ID", + } + + filter_defaults = { + 'archived': {'active': True, 'verb': 'is_false'}, + } + + sort_defaults = 'store_id' + + def configure_grid(self, g): + """ """ + super().configure_grid(g) + + # links + g.set_link('store_id') + g.set_link('name') + + def grid_row_class(self, store, data, i): + """ """ + if store.archived: + return 'has-background-warning' + + def configure_form(self, f): + """ """ + super().configure_form(f) + + # store_id + f.set_validator('store_id', self.unique_store_id) + + # name + f.set_validator('name', self.unique_name) + + def unique_store_id(self, node, value): + """ """ + model = self.app.model + session = self.Session() + + query = session.query(model.Store)\ + .filter(model.Store.store_id == value) + + if self.editing: + uuid = self.request.matchdict['uuid'] + query = query.filter(model.Store.uuid != uuid) + + if query.count(): + node.raise_invalid("Store ID must be unique") + + def unique_name(self, node, value): + """ """ + model = self.app.model + session = self.Session() + + query = session.query(model.Store)\ + .filter(model.Store.name == value) + + if self.editing: + uuid = self.request.matchdict['uuid'] + query = query.filter(model.Store.uuid != uuid) + + if query.count(): + node.raise_invalid("Name must be unique") + + +def defaults(config, **kwargs): + base = globals() + + StoreView = kwargs.get('StoreView', base['StoreView']) + StoreView.defaults(config) + + +def includeme(config): + defaults(config) diff --git a/tests/db/model/test_stores.py b/tests/db/model/test_stores.py new file mode 100644 index 0000000..3c0b5d0 --- /dev/null +++ b/tests/db/model/test_stores.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.testing import DataTestCase + +from sideshow.db.model import stores as mod + + +class TestPendingCustomer(DataTestCase): + + def test_str(self): + store = mod.Store() + self.assertEqual(str(store), "") + + store.name = "Acme Goods" + self.assertEqual(str(store), "Acme Goods") diff --git a/tests/web/views/test_stores.py b/tests/web/views/test_stores.py new file mode 100644 index 0000000..ab69171 --- /dev/null +++ b/tests/web/views/test_stores.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch + +import colander + +from sideshow.testing import WebTestCase +from sideshow.web.views import stores as mod + + +class TestIncludeme(WebTestCase): + + def test_coverage(self): + mod.includeme(self.pyramid_config) + + +class TestStoreView(WebTestCase): + + def make_view(self): + return mod.StoreView(self.request) + + def test_configure_grid(self): + model = self.app.model + view = self.make_view() + grid = view.make_grid(model_class=model.Store) + self.assertNotIn('store_id', grid.linked_columns) + self.assertNotIn('name', grid.linked_columns) + view.configure_grid(grid) + self.assertIn('store_id', grid.linked_columns) + self.assertIn('name', grid.linked_columns) + + def test_grid_row_class(self): + model = self.app.model + view = self.make_view() + + store = model.Store() + self.assertFalse(store.archived) + self.assertIsNone(view.grid_row_class(store, {}, 0)) + + store = model.Store(archived=True) + self.assertTrue(store.archived) + self.assertEqual(view.grid_row_class(store, {}, 0), 'has-background-warning') + + def test_configure_form(self): + model = self.app.model + view = self.make_view() + + # unique validators are set + form = view.make_form(model_class=model.Store) + self.assertNotIn('store_id', form.validators) + self.assertNotIn('name', form.validators) + view.configure_form(form) + self.assertIn('store_id', form.validators) + self.assertIn('name', form.validators) + + def test_unique_store_id(self): + model = self.app.model + view = self.make_view() + + store = model.Store(store_id='001', name='whatever') + self.session.add(store) + self.session.commit() + + with patch.object(view, 'Session', return_value=self.session): + + # invalid if same store_id in data + node = colander.SchemaNode(colander.String(), name='store_id') + self.assertRaises(colander.Invalid, view.unique_store_id, node, '001') + + # but not if store_id belongs to current store + with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}): + with patch.object(view, 'editing', new=True): + node = colander.SchemaNode(colander.String(), name='store_id') + self.assertIsNone(view.unique_store_id(node, '001')) + + def test_unique_name(self): + model = self.app.model + view = self.make_view() + + store = model.Store(store_id='001', name='Acme Goods') + self.session.add(store) + self.session.commit() + + with patch.object(view, 'Session', return_value=self.session): + + # invalid if same name in data + node = colander.SchemaNode(colander.String(), name='name') + self.assertRaises(colander.Invalid, view.unique_name, node, 'Acme Goods') + + # but not if name belongs to current store + with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}): + with patch.object(view, 'editing', new=True): + node = colander.SchemaNode(colander.String(), name='name') + self.assertIsNone(view.unique_name(node, 'Acme Goods'))