feat: add basic model, views for Stores
This commit is contained in:
		
							parent
							
								
									76075f146c
								
							
						
					
					
						commit
						3ef84ff706
					
				
					 11 changed files with 348 additions and 1 deletions
				
			
		
							
								
								
									
										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.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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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>`:
 | 
			
		||||
 | 
			
		||||
* :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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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):
 | 
			
		||||
        """ """
 | 
			
		||||
        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
 | 
			
		||||
    config.include('sideshow.web.views.stores')
 | 
			
		||||
    config.include('sideshow.web.views.customers')
 | 
			
		||||
    config.include('sideshow.web.views.products')
 | 
			
		||||
    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…
	
	Add table
		Add a link
		
	
		Reference in a new issue