feat: add basic model, views for Stores

This commit is contained in:
Lance Edgar 2025-01-27 18:15:07 -06:00
parent 76075f146c
commit 3ef84ff706
11 changed files with 348 additions and 1 deletions

View file

@ -0,0 +1,6 @@
``sideshow.db.model.stores``
============================
.. automodule:: sideshow.db.model.stores
:members:

View file

@ -0,0 +1,6 @@
``sideshow.web.views.stores``
=============================
.. automodule:: sideshow.web.views.stores
:members:

View file

@ -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

View 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')

View file

@ -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

View 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 ""

View file

@ -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

View file

@ -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')

View 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)

View 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")

View 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'))