From 1ee398e8fba379c8f4ecc2fd2f39d698c2782727 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 21 Feb 2025 18:13:57 -0600 Subject: [PATCH] feat: add basic support to "resolve" a pending product --- src/sideshow/batch/neworder.py | 62 +++---- .../fd8a2527bd30_add_ignored_status.py | 61 +++++++ src/sideshow/db/model/products.py | 3 +- src/sideshow/enum.py | 2 + src/sideshow/orders.py | 75 ++++++++ src/sideshow/testing.py | 4 +- src/sideshow/web/templates/base.mako | 8 + .../web/templates/order-items/view.mako | 18 +- src/sideshow/web/templates/orders/create.mako | 13 +- .../web/templates/pending/products/view.mako | 166 ++++++++++++++++++ .../web/templates/sideshow-components.mako | 53 ++++++ src/sideshow/web/views/orders.py | 2 + src/sideshow/web/views/products.py | 131 ++++++++++++-- tests/batch/test_neworder.py | 16 +- tests/test_orders.py | 69 ++++++++ tests/web/views/test_orders.py | 5 +- tests/web/views/test_products.py | 161 ++++++++++++++++- 17 files changed, 780 insertions(+), 69 deletions(-) create mode 100644 src/sideshow/db/alembic/versions/fd8a2527bd30_add_ignored_status.py create mode 100644 src/sideshow/web/templates/base.mako create mode 100644 src/sideshow/web/templates/pending/products/view.mako create mode 100644 src/sideshow/web/templates/sideshow-components.mako diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index 1c10a4e..959869b 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -1056,7 +1056,7 @@ class NewOrderBatchHandler(BatchHandler): By default, this will call: * :meth:`make_local_customer()` - * :meth:`make_local_products()` + * :meth:`process_pending_products()` * :meth:`make_new_order()` And will return the new @@ -1068,7 +1068,7 @@ class NewOrderBatchHandler(BatchHandler): """ rows = self.get_effective_rows(batch) self.make_local_customer(batch) - self.make_local_products(batch, rows) + self.process_pending_products(batch, rows) order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs) return order @@ -1115,46 +1115,48 @@ class NewOrderBatchHandler(BatchHandler): session.delete(pending) session.flush() - def make_local_products(self, batch, rows): + def process_pending_products(self, batch, rows): """ - If applicable, this converts all :term:`pending products - ` into :term:`local products `. + Process any :term:`pending products ` which + are present in the batch. This is called automatically from :meth:`execute()`. - This logic will happen only if :meth:`use_local_products()` - returns true, and the batch has pending instead of local items - (so far). + If :term:`local products ` are used, this will + convert the pending products to local products. - For each affected row, it will create a new - :class:`~sideshow.db.model.products.LocalProduct` record and - populate it from the row - :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`. - The latter is then deleted. + If :term:`external products ` are used, this + will update the pending product records' status to indicate + they are ready to be resolved. """ - if not self.use_local_products(): - return - + enum = self.app.enum model = self.app.model session = self.app.get_session(batch) - inspector = sa.inspect(model.LocalProduct) - for row in rows: - if row.local_product or not row.pending_product: - continue + if self.use_local_products(): + inspector = sa.inspect(model.LocalProduct) + for row in rows: - pending = row.pending_product - local = model.LocalProduct() + if row.local_product or not row.pending_product: + continue - for prop in inspector.column_attrs: - if hasattr(pending, prop.key): - setattr(local, prop.key, getattr(pending, prop.key)) - session.add(local) + pending = row.pending_product + local = model.LocalProduct() - row.local_product = local - row.pending_product = None - session.delete(pending) + for prop in inspector.column_attrs: + if hasattr(pending, prop.key): + setattr(local, prop.key, getattr(pending, prop.key)) + session.add(local) + + row.local_product = local + row.pending_product = None + session.delete(pending) + + else: # external products; pending should be marked 'ready' + for row in rows: + pending = row.pending_product + if pending: + pending.status = enum.PendingProductStatus.READY session.flush() diff --git a/src/sideshow/db/alembic/versions/fd8a2527bd30_add_ignored_status.py b/src/sideshow/db/alembic/versions/fd8a2527bd30_add_ignored_status.py new file mode 100644 index 0000000..63bb061 --- /dev/null +++ b/src/sideshow/db/alembic/versions/fd8a2527bd30_add_ignored_status.py @@ -0,0 +1,61 @@ +"""add ignored status + +Revision ID: fd8a2527bd30 +Revises: 13af2ffbc0e0 +Create Date: 2025-02-20 12:08:27.374172 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = 'fd8a2527bd30' +down_revision: Union[str, None] = '13af2ffbc0e0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # pendingcustomerstatus + op.sync_enum_values( + enum_schema='public', + enum_name='pendingcustomerstatus', + new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'], + affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')], + enum_values_to_rename=[], + ) + + # pendingproductstatus + op.sync_enum_values( + enum_schema='public', + enum_name='pendingproductstatus', + new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'], + affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')], + enum_values_to_rename=[], + ) + + +def downgrade() -> None: + + # pendingproductstatus + op.sync_enum_values( + enum_schema='public', + enum_name='pendingproductstatus', + new_values=['PENDING', 'READY', 'RESOLVED'], + affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')], + enum_values_to_rename=[], + ) + + # pendingcustomerstatus + op.sync_enum_values( + enum_schema='public', + enum_name='pendingcustomerstatus', + new_values=['PENDING', 'READY', 'RESOLVED'], + affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')], + enum_values_to_rename=[], + ) diff --git a/src/sideshow/db/model/products.py b/src/sideshow/db/model/products.py index 70c166d..7c088d9 100644 --- a/src/sideshow/db/model/products.py +++ b/src/sideshow/db/model/products.py @@ -185,7 +185,8 @@ class PendingProduct(ProductMixin, model.Base): uuid = model.uuid_column() product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" - ID of the true product associated with this record, if applicable. + ID of the :term:`external product` associated with this record, if + applicable/known. """) status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc=""" diff --git a/src/sideshow/enum.py b/src/sideshow/enum.py index a761d02..4031824 100644 --- a/src/sideshow/enum.py +++ b/src/sideshow/enum.py @@ -91,6 +91,7 @@ class PendingCustomerStatus(Enum): PENDING = 'pending' READY = 'ready' RESOLVED = 'resolved' + IGNORED = 'ignored' class PendingProductStatus(Enum): @@ -101,6 +102,7 @@ class PendingProductStatus(Enum): PENDING = 'pending' READY = 'ready' RESOLVED = 'resolved' + IGNORED = 'ignored' ######################################## diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py index 868cada..6f4dd49 100644 --- a/src/sideshow/orders.py +++ b/src/sideshow/orders.py @@ -105,6 +105,81 @@ class OrderHandler(GenericHandler): enum.ORDER_ITEM_STATUS_INACTIVE): return 'warning' + def resolve_pending_product(self, pending_product, product_info, user, note=None): + """ + Resolve a :term:`pending product`, to reflect the given + product info. + + At a high level this does 2 things: + + * update the ``pending_product`` + * find and update any related :term:`order item(s) ` + + The first step just sets + :attr:`~sideshow.db.model.products.PendingProduct.product_id` + from the provided info, and gives it the "resolved" status. + Note that it does *not* update the pending product record + further, so it will not fully "match" the product info. + + The second step will fetch all + :class:`~sideshow.db.model.orders.OrderItem` records which + reference the ``pending_product`` **and** which do not yet + have a ``product_id`` value. For each, it then updates the + order item to contain all data from ``product_info``. And + finally, it adds an event to the item history, indicating who + resolved and when. (If ``note`` is specified, a *second* + event is added for that.) + + :param pending_product: + :class:`~sideshow.db.model.products.PendingProduct` to be + resolved. + + :param product_info: Dict of product info, as obtained from + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`. + + :param user: + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who + is performing the action. + + :param note: Optional note to be added to event history for + related order item(s). + """ + enum = self.app.enum + model = self.app.model + session = self.app.get_session(pending_product) + + if pending_product.status != enum.PendingProductStatus.READY: + raise ValueError("pending product does not have 'ready' status") + + info = product_info + pending_product.product_id = info['product_id'] + pending_product.status = enum.PendingProductStatus.RESOLVED + + items = session.query(model.OrderItem)\ + .filter(model.OrderItem.pending_product == pending_product)\ + .filter(model.OrderItem.product_id == None)\ + .all() + + for item in items: + item.product_id = info['product_id'] + item.product_scancode = info['scancode'] + item.product_brand = info['brand_name'] + item.product_description = info['description'] + item.product_size = info['size'] + item.product_weighed = info['weighed'] + item.department_id = info['department_id'] + item.department_name = info['department_name'] + item.special_order = info['special_order'] + item.vendor_name = info['vendor_name'] + item.vendor_item_code = info['vendor_item_code'] + item.case_size = info['case_size'] + item.unit_cost = info['unit_cost'] + item.unit_price_reg = info['unit_price_reg'] + + item.add_event(enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED, user) + if note: + item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note) + def process_placement(self, items, user, vendor_name=None, po_number=None, note=None): """ Process the "placement" step for the given order items. diff --git a/src/sideshow/testing.py b/src/sideshow/testing.py index ee3cd64..150c978 100644 --- a/src/sideshow/testing.py +++ b/src/sideshow/testing.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -33,4 +33,6 @@ class WebTestCase(base.WebTestCase): config = super().make_config(**kwargs) config.setdefault('wutta.model_spec', 'sideshow.db.model') config.setdefault('wutta.enum_spec', 'sideshow.enum') + config.setdefault(f'{config.appname}.batch.neworder.handler.default_spec', + 'sideshow.batch.neworder:NewOrderBatchHandler') return config diff --git a/src/sideshow/web/templates/base.mako b/src/sideshow/web/templates/base.mako new file mode 100644 index 0000000..384b1de --- /dev/null +++ b/src/sideshow/web/templates/base.mako @@ -0,0 +1,8 @@ +## -*- coding: utf-8; -*- +<%inherit file="wuttaweb:templates/base.mako" /> +<%namespace file="/sideshow-components.mako" import="make_sideshow_components" /> + +<%def name="render_vue_templates()"> + ${make_sideshow_components()} + ${parent.render_vue_templates()} + diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index e7d85ce..3edf055 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -207,7 +207,7 @@
- ${item.product_id} + ${item.product_id or ''} % if not item.product_id and item.local_product: @@ -220,34 +220,34 @@ % endif - ${item.product_scancode} + ${item.product_scancode or ''} - ${item.product_brand} + ${item.product_brand or ''} - ${item.product_description} + ${item.product_description or ''} - ${item.product_size} + ${item.product_size or ''} ${app.render_boolean(item.product_weighed)} - ${item.department_id} + ${item.department_id or ''} - ${item.department_name} + ${item.department_name or ''} ${app.render_boolean(item.special_order)} - ${item.vendor_name} + ${item.vendor_name or ''} - ${item.vendor_item_code} + ${item.vendor_item_code or ''}
diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 5dd39b7..fbf545a 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -523,12 +523,10 @@
- +
@@ -1696,8 +1694,7 @@ % endif this.editItemShowDialog = true this.$nextTick(() => { - // this.$refs.productLookup.focus() - this.$refs.productAutocomplete.focus() + this.$refs.productLookup.focus() }) }, diff --git a/src/sideshow/web/templates/pending/products/view.mako b/src/sideshow/web/templates/pending/products/view.mako new file mode 100644 index 0000000..b5fe097 --- /dev/null +++ b/src/sideshow/web/templates/pending/products/view.mako @@ -0,0 +1,166 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="tool_panels()"> + ${parent.tool_panels()} + + + + ${instance.status.value} + + + % if instance.status.name == 'READY' and master.has_perm('resolve') and not use_local_products: + + Resolve Product + + +
+
+ ${h.form(master.get_action_url('resolve', instance), **{'@submit': 'resolveSubmitting = true'})} + ${h.csrf_token(request)} + +
+ +
+ +

+ Please identify the corresponding External Product. +

+

+ All related orders etc. will be updated accordingly. +

+ + + ${instance.scancode or ''} + + + ${instance.brand_name or ''} + + + ${instance.description or ''} + + + ${instance.size or ''} + + + + + ${instance.vendor_name or ''} + + + ${instance.vendor_item_code or ''} + + +
+ +
+ +
+ + ${h.hidden('product_id', **{':value': 'resolveProductID'})} +
+
+
+ +
+ +
+
+ + Cancel + + + {{ resolveSubmitting ? "Working, please wait..." : "Resolve" }} + +
+
+ + ${h.end_form()} +
+
+
+ % endif + + % if instance.status.name == 'READY' and master.has_perm('ignore') and not use_local_products: + + Ignore Product + + + + + % endif +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/src/sideshow/web/templates/sideshow-components.mako b/src/sideshow/web/templates/sideshow-components.mako new file mode 100644 index 0000000..bc51dd8 --- /dev/null +++ b/src/sideshow/web/templates/sideshow-components.mako @@ -0,0 +1,53 @@ + +<%def name="make_sideshow_components()"> + ${self.make_sideshow_product_lookup_component()} + + +<%def name="make_sideshow_product_lookup_component()"> + + + diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index 76399ff..c18cbd7 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -793,6 +793,8 @@ class OrderView(MasterView): 'department_id': row.department_id, 'department_name': row.department_name, 'special_order': row.special_order, + 'vendor_name': row.vendor_name, + 'vendor_item_code': row.vendor_item_code, 'case_size': float(row.case_size) if row.case_size is not None else None, 'order_qty': float(row.order_qty), 'order_uom': row.order_uom, diff --git a/src/sideshow/web/views/products.py b/src/sideshow/web/views/products.py index 010bb66..d9ce266 100644 --- a/src/sideshow/web/views/products.py +++ b/src/sideshow/web/views/products.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -27,6 +27,7 @@ Views for Products from wuttaweb.views import MasterView from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity +from sideshow.enum import PendingProductStatus from sideshow.db.model import LocalProduct, PendingProduct @@ -254,7 +255,12 @@ class PendingProductView(MasterView): 'created_by', ] - sort_defaults = 'scancode' + sort_defaults = ('created', 'desc') + + filter_defaults = { + 'status': {'active': True, + 'value': PendingProductStatus.READY.name}, + } form_fields = [ 'product_id', @@ -271,7 +277,6 @@ class PendingProductView(MasterView): 'unit_price_reg', 'special_order', 'notes', - 'status', 'created', 'created_by', 'orders', @@ -291,7 +296,7 @@ class PendingProductView(MasterView): g.set_renderer('unit_price_reg', 'currency') # status - g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus) + g.set_enum('status', enum.PendingProductStatus) # links g.set_link('scancode') @@ -299,6 +304,12 @@ class PendingProductView(MasterView): g.set_link('description') g.set_link('size') + def grid_row_class(self, product, data, i): + """ """ + enum = self.app.enum + if product.status == enum.PendingProductStatus.IGNORED: + return 'has-background-warning' + def configure_form(self, f): """ """ super().configure_form(f) @@ -317,13 +328,6 @@ class PendingProductView(MasterView): # notes f.set_widget('notes', 'notes') - # status - if self.creating: - f.remove('status') - else: - f.set_node('status', WuttaEnum(self.request, enum.PendingProductStatus)) - f.set_readonly('status') - # created if self.creating: f.remove('created') @@ -417,6 +421,19 @@ class PendingProductView(MasterView): return grid + def get_template_context(self, context): + """ """ + enum = self.app.enum + + if self.viewing: + product = context['instance'] + if (product.status == enum.PendingProductStatus.READY + and self.has_any_perm('resolve', 'ignore')): + handler = self.app.get_batch_handler('neworder') + context['use_local_products'] = handler.use_local_products() + + return context + def delete_instance(self, product): """ """ @@ -431,6 +448,98 @@ class PendingProductView(MasterView): # go ahead and delete per usual super().delete_instance(product) + def resolve(self): + """ + View to "resolve" a :term:`pending product` with the real + :term:`external product`. + + This view requires POST, with ``product_id`` referencing the + desired external product. + + It will call + :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()` + to fetch product info, then with that it calls + :meth:`~sideshow.orders.OrderHandler.resolve_pending_product()` + to update related :term:`order items ` etc. + + See also :meth:`ignore()`. + """ + enum = self.app.enum + session = self.Session() + product = self.get_instance() + + if product.status != enum.PendingProductStatus.READY: + self.request.session.flash("pending product does not have 'ready' status!", 'error') + return self.redirect(self.get_action_url('view', product)) + + product_id = self.request.POST.get('product_id') + if not product_id: + self.request.session.flash("must specify valid product_id", 'error') + return self.redirect(self.get_action_url('view', product)) + + batch_handler = self.app.get_batch_handler('neworder') + order_handler = self.app.get_order_handler() + + info = batch_handler.get_product_info_external(session, product_id) + order_handler.resolve_pending_product(product, info, self.request.user) + + return self.redirect(self.get_action_url('view', product)) + + def ignore(self): + """ + View to "ignore" a :term:`pending product` so the user is no + longer prompted to resolve it. + + This view requires POST; it merely sets the product status to + "ignored". + + See also :meth:`resolve()`. + """ + enum = self.app.enum + product = self.get_instance() + + if product.status != enum.PendingProductStatus.READY: + self.request.session.flash("pending product does not have 'ready' status!", 'error') + return self.redirect(self.get_action_url('view', product)) + + product.status = enum.PendingProductStatus.IGNORED + return self.redirect(self.get_action_url('view', product)) + + @classmethod + def defaults(cls, config): + """ """ + cls._defaults(config) + cls._pending_product_defaults(config) + + @classmethod + def _pending_product_defaults(cls, config): + route_prefix = cls.get_route_prefix() + permission_prefix = cls.get_permission_prefix() + instance_url_prefix = cls.get_instance_url_prefix() + model_title = cls.get_model_title() + + # resolve + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.resolve', + f"Resolve {model_title}") + config.add_route(f'{route_prefix}.resolve', + f'{instance_url_prefix}/resolve', + request_method='POST') + config.add_view(cls, attr='resolve', + route_name=f'{route_prefix}.resolve', + permission=f'{permission_prefix}.resolve') + + # ignore + config.add_wutta_permission(permission_prefix, + f'{permission_prefix}.ignore', + f"Ignore {model_title}") + config.add_route(f'{route_prefix}.ignore', + f'{instance_url_prefix}/ignore', + request_method='POST') + config.add_view(cls, attr='ignore', + route_name=f'{route_prefix}.ignore', + permission=f'{permission_prefix}.ignore') + def defaults(config, **kwargs): base = globals() diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index c6dca19..85ffa79 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -1219,7 +1219,7 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(local.full_name, "Chuck Norris") self.assertEqual(local.phone_number, '555-1234') - def test_make_local_products(self): + def test_process_pending_products(self): model = self.app.model enum = self.app.enum handler = self.make_handler() @@ -1253,7 +1253,7 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(self.session.query(model.LocalProduct).count(), 1) self.assertIsNotNone(row2.pending_product) self.assertIsNone(row2.local_product) - handler.make_local_products(batch, batch.rows) + handler.process_pending_products(batch, batch.rows) self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertIsNone(row2.pending_product) @@ -1266,7 +1266,7 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99')) # trying again does nothing - handler.make_local_products(batch, batch.rows) + handler.process_pending_products(batch, batch.rows) self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertIsNone(row2.pending_product) @@ -1291,24 +1291,26 @@ class TestNewOrderBatchHandler(DataTestCase): ), 1, enum.ORDER_UOM_UNIT) self.session.flush() - # should do nothing if local products disabled + # should update status if using external products with patch.object(handler, 'use_local_products', return_value=False): self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertIsNotNone(row.pending_product) + self.assertEqual(row.pending_product.status, enum.PendingProductStatus.PENDING) self.assertIsNone(row.local_product) - handler.make_local_products(batch, batch.rows) + handler.process_pending_products(batch, batch.rows) self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertIsNotNone(row.pending_product) + self.assertEqual(row.pending_product.status, enum.PendingProductStatus.READY) self.assertIsNone(row.local_product) - # but things happen by default, since local products enabled + # but if using local products (the default), pending is converted to local self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertIsNotNone(row.pending_product) self.assertIsNone(row.local_product) - handler.make_local_products(batch, batch.rows) + handler.process_pending_products(batch, batch.rows) self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.LocalProduct).count(), 3) self.assertIsNone(row.pending_product) diff --git a/tests/test_orders.py b/tests/test_orders.py index 6e5609e..6dca146 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -70,6 +70,75 @@ class TestOrderHandler(DataTestCase): self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning') self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning') + def test_resolve_pending_product(self): + model = self.app.model + enum = self.app.enum + handler = self.make_handler() + + # sample data + user = model.User(username='barney') + self.session.add(user) + pending = model.PendingProduct(description='vinegar', unit_price_reg=5.99, + status=enum.PendingProductStatus.PENDING, + created_by=user) + self.session.add(pending) + order = model.Order(order_id=100, customer_name="Fred Flintstone", created_by=user) + item = model.OrderItem(pending_product=pending, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + self.session.flush() + + info = { + 'product_id': '07430500132', + 'scancode': '07430500132', + 'brand_name': "Bragg's", + 'description': "Apple Cider Vinegar", + 'size': "32oz", + 'weighed': False, + 'department_id': None, + 'department_name': None, + 'special_order': False, + 'vendor_name': None, + 'vendor_item_code': None, + 'case_size': 12, + 'unit_cost': 2.99, + 'unit_price_reg': 5.99, + } + + # first try fails b/c pending status + self.assertEqual(len(item.events), 0) + self.assertRaises(ValueError, handler.resolve_pending_product, pending, info, user) + + # resolves okay if ready status + pending.status = enum.PendingProductStatus.READY + handler.resolve_pending_product(pending, info, user) + self.assertEqual(len(item.events), 1) + self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED) + self.assertIsNone(item.events[0].note) + + # more sample data + pending2 = model.PendingProduct(description='vinegar', unit_price_reg=5.99, + status=enum.PendingProductStatus.READY, + created_by=user) + self.session.add(pending2) + order2 = model.Order(order_id=101, customer_name="Wilma Flintstone", created_by=user) + item2 = model.OrderItem(pending_product=pending2, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order2.items.append(item2) + self.session.add(order2) + self.session.flush() + + # resolve with extra note + handler.resolve_pending_product(pending2, info, user, note='hello world') + self.assertEqual(len(item2.events), 2) + self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED) + self.assertIsNone(item2.events[0].note) + self.assertEqual(item2.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED) + self.assertEqual(item2.events[1].note, "hello world") + def test_process_placement(self): model = self.app.model enum = self.app.enum diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 1af1a69..affb2d2 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -1144,6 +1144,8 @@ class TestOrderView(WebTestCase): row.department_id = 1 row.department_name = "Bricks & Mortar" row.special_order = False + row.vendor_name = 'Acme Distributors' + row.vendor_item_code = '1234' row.case_size = None row.unit_cost = decimal.Decimal('599.99') row.unit_price_reg = decimal.Decimal('999.99') @@ -1159,7 +1161,8 @@ class TestOrderView(WebTestCase): self.assertEqual(data['product_scancode'], '012345') self.assertEqual(data['product_full_description'], 'Acme Bricks 1 ton') self.assertIsNone(data['case_size']) - self.assertNotIn('vendor_name', data) # TODO + self.assertEqual(data['vendor_name'], 'Acme Distributors') + self.assertEqual(data['vendor_item_code'], '1234') self.assertEqual(data['order_qty'], 1) self.assertEqual(data['order_uom'], 'EA') self.assertEqual(data['order_qty_display'], '1 Units') diff --git a/tests/web/views/test_products.py b/tests/web/views/test_products.py index c9782dd..44f6d6c 100644 --- a/tests/web/views/test_products.py +++ b/tests/web/views/test_products.py @@ -132,6 +132,19 @@ class TestPendingProductView(WebTestCase): self.assertIn('brand_name', grid.linked_columns) self.assertIn('description', grid.linked_columns) + def test_grid_row_class(self): + enum = self.app.enum + model = self.app.model + view = self.make_view() + product = model.PendingProduct() + + # null by default + self.assertIsNone(view.grid_row_class(product, {}, 1)) + + # warning for ignored + product.status = enum.PendingProductStatus.IGNORED + self.assertEqual(view.grid_row_class(product, {}, 1), 'has-background-warning') + def test_configure_form(self): model = self.app.model enum = self.app.enum @@ -141,7 +154,6 @@ class TestPendingProductView(WebTestCase): with patch.object(view, 'creating', new=True): form = view.make_form(model_class=model.PendingProduct) view.configure_form(form) - self.assertNotIn('status', form) self.assertNotIn('created', form) self.assertNotIn('created_by', form) @@ -216,6 +228,39 @@ class TestPendingProductView(WebTestCase): self.assertEqual(len(grid.actions), 1) self.assertEqual(grid.actions[0].key, 'view') + def test_get_template_context(self): + enum = self.app.enum + model = self.app.model + view = self.make_view() + product = model.PendingProduct(status=enum.PendingProductStatus.PENDING) + orig_context = {'instance': product} + + # local setting omitted by default + context = view.get_template_context(orig_context) + self.assertNotIn('use_local_products', context) + + # still omitted even though 'viewing' + with patch.object(view, 'viewing', new=True): + context = view.get_template_context(orig_context) + self.assertNotIn('use_local_products', context) + + # still omitted even though correct status + product.status = enum.PendingProductStatus.READY + context = view.get_template_context(orig_context) + self.assertNotIn('use_local_products', context) + + # no longer omitted if user has perm + with patch.object(self.request, 'is_root', new=True): + context = view.get_template_context(orig_context) + self.assertIn('use_local_products', context) + # nb. true by default + self.assertTrue(context['use_local_products']) + + # accurately reflects config + self.config.setdefault('sideshow.orders.use_local_products', 'false') + context = view.get_template_context(orig_context) + self.assertFalse(context['use_local_products']) + def test_delete_instance(self): self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}') model = self.app.model @@ -259,3 +304,117 @@ class TestPendingProductView(WebTestCase): view.delete_instance(product) self.session.flush() self.assertEqual(self.session.query(model.PendingProduct).count(), 0) + + def test_resolve(self): + self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}') + model = self.app.model + enum = self.app.enum + view = self.make_view() + + # sample data + user = model.User(username='barney') + self.session.add(user) + product = model.PendingProduct(status=enum.PendingProductStatus.PENDING, + created_by=user) + self.session.add(product) + self.session.flush() + + info = { + 'product_id': '07430500132', + 'scancode': '07430500132', + 'brand_name': "Bragg's", + 'description': "Apple Cider Vinegar", + 'size': "32oz", + 'weighed': False, + 'department_id': None, + 'department_name': None, + 'special_order': False, + 'vendor_name': None, + 'vendor_item_code': None, + 'case_size': 12, + 'unit_cost': 2.99, + 'unit_price_reg': 5.99, + } + + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'user', new=user): + with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}): + + # flash error if wrong status + result = view.resolve() + self.assertIsInstance(result, HTTPFound) + self.assertTrue(self.request.session.peek_flash('error')) + self.assertEqual(self.request.session.pop_flash('error'), + ["pending product does not have 'ready' status!"]) + + # flash error if product_id not specified + product.status = enum.PendingProductStatus.READY + result = view.resolve() + self.assertIsInstance(result, HTTPFound) + self.assertTrue(self.request.session.peek_flash('error')) + self.assertEqual(self.request.session.pop_flash('error'), + ["must specify valid product_id"]) + + # more sample data + order = model.Order(order_id=100, created_by=user, + customer_name="Fred Flintstone") + item = model.OrderItem(pending_product=product, + order_qty=1, order_uom=enum.ORDER_UOM_UNIT, + status_code=enum.ORDER_ITEM_STATUS_READY) + order.items.append(item) + self.session.add(order) + + # product + order items updated + self.assertIsNone(product.product_id) + self.assertEqual(product.status, enum.PendingProductStatus.READY) + self.assertIsNone(item.product_id) + batch_handler = NewOrderBatchHandler(self.config) + with patch.object(batch_handler, 'get_product_info_external', + return_value=info): + with patch.object(self.app, 'get_batch_handler', + return_value=batch_handler): + with patch.object(self.request, 'POST', + new={'product_id': '07430500132'}): + with patch.object(batch_handler, 'get_product_info_external', + return_value=info): + result = view.resolve() + self.assertIsInstance(result, HTTPFound) + self.assertFalse(self.request.session.peek_flash('error')) + self.assertEqual(product.product_id, '07430500132') + self.assertEqual(product.status, enum.PendingProductStatus.RESOLVED) + self.assertEqual(item.product_id, '07430500132') + + def test_ignore(self): + self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}') + model = self.app.model + enum = self.app.enum + view = self.make_view() + + # sample data + user = model.User(username='barney') + self.session.add(user) + product = model.PendingProduct(status=enum.PendingProductStatus.PENDING, + created_by=user) + self.session.add(product) + self.session.flush() + + with patch.object(view, 'Session', return_value=self.session): + with patch.object(self.request, 'user', new=user): + with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}): + + # flash error if wrong status + result = view.ignore() + self.assertIsInstance(result, HTTPFound) + self.assertTrue(self.request.session.peek_flash('error')) + self.assertEqual(self.request.session.pop_flash('error'), + ["pending product does not have 'ready' status!"]) + + # product updated + product.status = enum.PendingProductStatus.READY + self.assertIsNone(product.product_id) + self.assertEqual(product.status, enum.PendingProductStatus.READY) + result = view.ignore() + self.assertIsInstance(result, HTTPFound) + self.assertFalse(self.request.session.peek_flash('error')) + self.assertIsNone(product.product_id) + self.assertEqual(product.status, enum.PendingProductStatus.IGNORED)