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

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>`:
* :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

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):
""" """
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
config.include('sideshow.web.views.stores')
config.include('sideshow.web.views.customers')
config.include('sideshow.web.views.products')
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'))