feat: add basic model, views for Stores
This commit is contained in:
parent
76075f146c
commit
3ef84ff706
6
docs/api/sideshow.db.model.stores.rst
Normal file
6
docs/api/sideshow.db.model.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.db.model.stores``
|
||||||
|
============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.db.model.stores
|
||||||
|
:members:
|
6
docs/api/sideshow.web.views.stores.rst
Normal file
6
docs/api/sideshow.web.views.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``sideshow.web.views.stores``
|
||||||
|
=============================
|
||||||
|
|
||||||
|
.. automodule:: sideshow.web.views.stores
|
||||||
|
:members:
|
|
@ -43,6 +43,7 @@ For an online demo see https://demo.wuttaproject.org/
|
||||||
api/sideshow.db.model.customers
|
api/sideshow.db.model.customers
|
||||||
api/sideshow.db.model.orders
|
api/sideshow.db.model.orders
|
||||||
api/sideshow.db.model.products
|
api/sideshow.db.model.products
|
||||||
|
api/sideshow.db.model.stores
|
||||||
api/sideshow.enum
|
api/sideshow.enum
|
||||||
api/sideshow.orders
|
api/sideshow.orders
|
||||||
api/sideshow.web
|
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.customers
|
||||||
api/sideshow.web.views.orders
|
api/sideshow.web.views.orders
|
||||||
api/sideshow.web.views.products
|
api/sideshow.web.views.products
|
||||||
|
api/sideshow.web.views.stores
|
||||||
|
|
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal file
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal file
|
@ -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')
|
|
@ -30,6 +30,7 @@ This namespace exposes everything from
|
||||||
|
|
||||||
Primary :term:`data models <data model>`:
|
Primary :term:`data models <data model>`:
|
||||||
|
|
||||||
|
* :class:`~sideshow.db.model.stores.Store`
|
||||||
* :class:`~sideshow.db.model.orders.Order`
|
* :class:`~sideshow.db.model.orders.Order`
|
||||||
* :class:`~sideshow.db.model.orders.OrderItem`
|
* :class:`~sideshow.db.model.orders.OrderItem`
|
||||||
* :class:`~sideshow.db.model.orders.OrderItemEvent`
|
* :class:`~sideshow.db.model.orders.OrderItemEvent`
|
||||||
|
@ -48,6 +49,7 @@ And the :term:`batch` models:
|
||||||
from wuttjamaican.db.model import *
|
from wuttjamaican.db.model import *
|
||||||
|
|
||||||
# sideshow models
|
# sideshow models
|
||||||
|
from .stores import Store
|
||||||
from .customers import LocalCustomer, PendingCustomer
|
from .customers import LocalCustomer, PendingCustomer
|
||||||
from .products import LocalProduct, PendingProduct
|
from .products import LocalProduct, PendingProduct
|
||||||
from .orders import Order, OrderItem, OrderItemEvent
|
from .orders import Order, OrderItem, OrderItemEvent
|
||||||
|
|
54
src/sideshow/db/model/stores.py
Normal file
54
src/sideshow/db/model/stores.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
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 ""
|
|
@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler):
|
||||||
def make_admin_menu(self, request, **kwargs):
|
def make_admin_menu(self, request, **kwargs):
|
||||||
""" """
|
""" """
|
||||||
kwargs['include_people'] = True
|
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
|
||||||
|
|
|
@ -35,6 +35,7 @@ def includeme(config):
|
||||||
})
|
})
|
||||||
|
|
||||||
# sideshow views
|
# sideshow views
|
||||||
|
config.include('sideshow.web.views.stores')
|
||||||
config.include('sideshow.web.views.customers')
|
config.include('sideshow.web.views.customers')
|
||||||
config.include('sideshow.web.views.products')
|
config.include('sideshow.web.views.products')
|
||||||
config.include('sideshow.web.views.orders')
|
config.include('sideshow.web.views.orders')
|
||||||
|
|
120
src/sideshow/web/views/stores.py
Normal file
120
src/sideshow/web/views/stores.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
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)
|
15
tests/db/model/test_stores.py
Normal file
15
tests/db/model/test_stores.py
Normal file
|
@ -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")
|
94
tests/web/views/test_stores.py
Normal file
94
tests/web/views/test_stores.py
Normal file
|
@ -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'))
|
Loading…
Reference in a new issue