From 35131c87326edc09d84772d04caeb12d90a52dd1 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 30 Nov 2023 18:23:47 -0600 Subject: [PATCH 001/357] Provide a way to show enum display text for some version diff fields master view must explicitly declare which enums for which fields --- docs/api/diffs.rst | 6 +++ docs/api/views/master.rst | 14 ++++++ docs/api/views/members.rst | 6 +++ docs/index.rst | 2 + tailbone/diffs.py | 94 ++++++++++++++++++++++++++++---------- tailbone/views/master.py | 49 +++++++++++++++++++- tailbone/views/members.py | 27 ++++++++++- 7 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 docs/api/diffs.rst create mode 100644 docs/api/views/members.rst diff --git a/docs/api/diffs.rst b/docs/api/diffs.rst new file mode 100644 index 00000000..fb1bba71 --- /dev/null +++ b/docs/api/diffs.rst @@ -0,0 +1,6 @@ + +``tailbone.diffs`` +================== + +.. automodule:: tailbone.diffs + :members: diff --git a/docs/api/views/master.rst b/docs/api/views/master.rst index 44278e0a..e7de7170 100644 --- a/docs/api/views/master.rst +++ b/docs/api/views/master.rst @@ -81,6 +81,12 @@ override when defining your subclass. override this for certain views, if so that should be done within :meth:`get_help_url()`. + .. attribute:: MasterView.version_diff_factory + + Optional factory to use for version diff objects. By default + this is *not set* but a subclass is free to set it. See also + :meth:`get_version_diff_factory()`. + Methods to Override ------------------- @@ -100,6 +106,14 @@ subclass. .. automethod:: MasterView.get_model_key + .. automethod:: MasterView.get_version_diff_enums + + .. automethod:: MasterView.get_version_diff_factory + + .. automethod:: MasterView.make_version_diff + + .. automethod:: MasterView.title_for_version + Support Methods --------------- diff --git a/docs/api/views/members.rst b/docs/api/views/members.rst new file mode 100644 index 00000000..6a9e9168 --- /dev/null +++ b/docs/api/views/members.rst @@ -0,0 +1,6 @@ + +``tailbone.views.members`` +========================== + +.. automodule:: tailbone.views.members + :members: diff --git a/docs/index.rst b/docs/index.rst index b19d859f..4aa22f3e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Package API: api/api/batch/core api/api/batch/ordering + api/diffs api/forms api/grids api/grids.core @@ -53,6 +54,7 @@ Package API: api/views/batch.vendorcatalog api/views/core api/views/master + api/views/members api/views/purchasing.batch api/views/purchasing.ordering diff --git a/tailbone/diffs.py b/tailbone/diffs.py index cdf35830..98253c57 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -34,35 +34,38 @@ from webhelpers2.html import HTML class Diff(object): """ Core diff class. In sore need of documentation. + + You must provide the old and new data sets, and the set of + relevant fields as well, if they cannot be easily introspected. + + :param old_data: Dict of "old" data values. + + :param new_data: Dict of "old" data values. + + :param fields: Sequence of relevant field names. Note that + both data dicts are expected to have keys which match these + field names. If you do not specify the fields then they + will (hopefully) be introspected from the old or new data + sets; however this will not work if they are both empty. + + :param monospace: If true, this flag will cause the value + columns to be rendered in monospace font. This is assumed + to be helpful when comparing "raw" data values which are + shown as e.g. ``repr(val)``. + + :param enums: Optional dict of enums for use when displaying field + values. If specified, keys should be field names and values + should be enum dicts. """ - def __init__(self, old_data, new_data, columns=None, fields=None, + def __init__(self, old_data, new_data, columns=None, fields=None, enums=None, render_field=None, render_value=None, nature='dirty', monospace=False, extra_row_attrs=None): - """ - Constructor. You must provide the old and new data sets, and - the set of relevant fields as well, if they cannot be easily - introspected. - - :param old_data: Dict of "old" data values. - - :param new_data: Dict of "old" data values. - - :param fields: Sequence of relevant field names. Note that - both data dicts are expected to have keys which match these - field names. If you do not specify the fields then they - will (hopefully) be introspected from the old or new data - sets; however this will not work if they are both empty. - - :param monospace: If true, this flag will cause the value - columns to be rendered in monospace font. This is assumed - to be helpful when comparing "raw" data values which are - shown as e.g. ``repr(val)``. - """ self.old_data = old_data self.new_data = new_data self.columns = columns or ["field name", "old value", "new value"] self.fields = fields or self.make_fields() + self.enums = enums or {} self._render_field = render_field or self.render_field_default self.render_value = render_value or self.render_value_default self.nature = nature @@ -92,7 +95,7 @@ class Diff(object): for the given field. May be an empty string, or a snippet of HTML attribute syntax, e.g.: - .. code-highlight:: none + .. code-block:: none class="diff" foo="bar" @@ -132,7 +135,21 @@ class Diff(object): class VersionDiff(Diff): """ - Special diff class, for use with version history views + Special diff class, for use with version history views. Note that + while based on :class:`Diff`, this class uses a different + signature for the constructor. + + :param version: Reference to a Continuum version record (object). + + :param \*args: Typical usage will not require positional args + beyond the ``version`` param, in which case ``old_data`` and + ``new_data`` params will be auto-determined based on the + ``version``. But if you specify positional args then nothing + automatic is done, they are passed as-is to the parent + :class:`Diff` constructor. + + :param \*\*kwargs: Remaining kwargs are passed as-is to the + :class:`Diff` constructor. """ def __init__(self, version, *args, **kwargs): @@ -176,9 +193,40 @@ class VersionDiff(Diff): if field not in unwanted] def render_version_value(self, field, value, version): + """ + Render the cell value text for the given version/field info. + + Note that this method is used to render both sides of the diff + (before and after values). + + :param field: Name of the field, as string. + + :param value: Raw value for the field, as obtained from ``version``. + + :param version: Reference to the Continuum version object. + + :returns: Rendered text as string, or ``None``. + """ text = HTML.tag('span', c=[repr(value)], style='font-family: monospace;') + # assume the enum display is all we need, if enum exists for the field + if field in self.enums: + + # but skip the enum display if None + display = self.enums[field].get(value) + if display is None and value is None: + return text + + # otherwise show enum display to the right of raw value + display = self.enums[field].get(value, str(value)) + return HTML.tag('span', c=[ + text, + HTML.tag('span', c=[display], + style='margin-left: 2rem; font-style: italic; font-weight: bold;'), + ]) + + # next we look for a relationship and may render the foreign object for prop in self.mapper.relationships: if prop.uselist: continue diff --git a/tailbone/views/master.py b/tailbone/views/master.py index cf001c36..cc2adcaf 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -597,7 +597,6 @@ class MasterView(View): return defaults def configure_row_grid(self, grid): - # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) self.configure_column_customer_key(grid) @@ -1528,6 +1527,15 @@ class MasterView(View): }) def title_for_version(self, version): + """ + Must return the title text for the given version. By default + this will be the :term:`rattail:model title` for the version's + data class. + + :param version: Reference to a Continuum version object. + + :returns: Title text for the version, as string. + """ cls = continuum.parent_class(version.__class__) return cls.get_model_title() @@ -4962,13 +4970,52 @@ class MasterView(View): return diffs.Diff(old_data, new_data, **kwargs) def get_version_diff_factory(self, **kwargs): + """ + Must return the factory to be used when creating version diff + objects. + + By default this returns the + :class:`tailbone.diffs.VersionDiff` class, unless + :attr:`version_diff_factory` is set, in which case that is + returned as-is. + + :returns: A factory which can produce + :class:`~tailbone.diffs.VersionDiff` objects. + """ if hasattr(self, 'version_diff_factory'): return self.version_diff_factory return diffs.VersionDiff + def get_version_diff_enums(self, version): + """ + This can optionally return a dict of field enums, to be passed + to the version diff factory. This method is called as part of + :meth:`make_version_diff()`. + """ + def make_version_diff(self, version, *args, **kwargs): + """ + Make a version diff object, using the factory returned by + :meth:`get_version_diff_factory()`. + + :param version: Reference to a Continuum version object. + + :param title: If specified, must be as a kwarg. Optional + override for the version title text. If not specified, + :meth:`title_for_version()` is called for the title. + + :param \*args: Additional args to pass to the factory. + + :param \*\*kwargs: Additional kwargs to pass to the factory. + + :returns: A :class:`~tailbone.diffs.VersionDiff` object. + """ if 'title' not in kwargs: kwargs['title'] = self.title_for_version(version) + + if 'enums' not in kwargs: + kwargs['enums'] = self.get_version_diff_enums(version) + factory = self.get_version_diff_factory() return factory(version, *args, **kwargs) diff --git a/tailbone/views/members.py b/tailbone/views/members.py index b1bb2a0d..de844eb7 100644 --- a/tailbone/views/members.py +++ b/tailbone/views/members.py @@ -27,6 +27,7 @@ Member Views from collections import OrderedDict import sqlalchemy as sa +import sqlalchemy_continuum as continuum from rattail.db import model from rattail.db.model import MembershipType, Member, MemberEquityPayment @@ -71,6 +72,7 @@ class MembershipTypeView(MasterView): ] def configure_grid(self, g): + """ """ super().configure_grid(g) g.set_sort_defaults('number') @@ -79,6 +81,7 @@ class MembershipTypeView(MasterView): g.set_link('name') def get_row_data(self, memtype): + """ """ model = self.model return self.Session.query(model.Member)\ .filter(model.Member.membership_type == memtype) @@ -102,7 +105,7 @@ class MemberView(MasterView): """ Master view for the Member class. """ - model_class = model.Member + model_class = Member is_contact = True touchable = True has_versions = True @@ -169,6 +172,7 @@ class MemberView(MasterView): return app.get_people_handler().get_quickie_search_placeholder() def configure_grid(self, g): + """ """ super().configure_grid(g) route_prefix = self.get_route_prefix() model = self.model @@ -263,13 +267,16 @@ class MemberView(MasterView): default=False) def grid_extra_class(self, member, i): + """ """ if not member.active: return 'warning' if member.equity_current is False: return 'notice' def configure_form(self, f): + """ """ super().configure_form(f) + model = self.model member = f.model_instance # date fields @@ -342,6 +349,7 @@ class MemberView(MasterView): return app.render_currency(total) def template_kwargs_view(self, **kwargs): + """ """ kwargs = super().template_kwargs_view(**kwargs) app = self.get_rattail_app() member = kwargs['instance'] @@ -360,10 +368,12 @@ class MemberView(MasterView): return kwargs def render_default_email(self, member, field): + """ """ if member.emails: return member.emails[0].address def render_default_phone(self, member, field): + """ """ if member.phones: return member.phones[0].number @@ -376,6 +386,7 @@ class MemberView(MasterView): return tags.link_to(text, url) def get_row_data(self, member): + """ """ model = self.model return self.Session.query(model.MemberEquityPayment)\ .filter(model.MemberEquityPayment.member == member) @@ -395,6 +406,7 @@ class MemberView(MasterView): uuid=payment.uuid) def configure_get_simple_settings(self): + """ """ return [ # General @@ -417,7 +429,7 @@ class MemberEquityPaymentView(MasterView): """ Master view for the MemberEquityPayment class. """ - model_class = model.MemberEquityPayment + model_class = MemberEquityPayment route_prefix = 'member_equity_payments' url_prefix = '/member-equity-payments' supports_grid_totals = True @@ -450,6 +462,7 @@ class MemberEquityPaymentView(MasterView): ] def query(self, session): + """ """ query = super().query(session) model = self.model @@ -458,6 +471,7 @@ class MemberEquityPaymentView(MasterView): return query def configure_grid(self, g): + """ """ super().configure_grid(g) model = self.model @@ -502,6 +516,7 @@ class MemberEquityPaymentView(MasterView): return {'totals_display': app.render_currency(total)} def configure_form(self, f): + """ """ super().configure_form(f) model = self.model payment = f.model_instance @@ -543,6 +558,14 @@ class MemberEquityPaymentView(MasterView): # status_code f.set_enum('status_code', model.MemberEquityPayment.STATUS) + def get_version_diff_enums(self, version): + """ """ + model = self.model + cls = continuum.parent_class(version.__class__) + + if cls is model.MemberEquityPayment: + return {'status_code': model.MemberEquityPayment.STATUS} + def defaults(config, **kwargs): base = globals() From faeb2cb7e29a9b70b5a2b28009a3aa75d14a7d01 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 30 Nov 2023 18:25:01 -0600 Subject: [PATCH 002/357] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index cd356554..45e5cc1b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.84 (2023-11-30) +------------------- + +* Provide a way to show enum display text for some version diff fields. + + 0.9.83 (2023-11-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 31167701..a44d3ed3 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.83' +__version__ = '0.9.84' From 3e4bbf7092fa0937d32ba5e8659a99a0a6d45242 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 1 Dec 2023 19:50:07 -0600 Subject: [PATCH 003/357] Use clientele handler to populate customer dropdown widget --- docs/api/forms.widgets.rst | 6 +++++ docs/index.rst | 1 + tailbone/forms/widgets.py | 52 +++++++++++++++++++++++++------------- 3 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 docs/api/forms.widgets.rst diff --git a/docs/api/forms.widgets.rst b/docs/api/forms.widgets.rst new file mode 100644 index 00000000..33316903 --- /dev/null +++ b/docs/api/forms.widgets.rst @@ -0,0 +1,6 @@ + +``tailbone.forms.widgets`` +========================== + +.. automodule:: tailbone.forms.widgets + :members: diff --git a/docs/index.rst b/docs/index.rst index 4aa22f3e..351e910d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,6 +46,7 @@ Package API: api/api/batch/ordering api/diffs api/forms + api/forms.widgets api/grids api/grids.core api/progress diff --git a/tailbone/forms/widgets.py b/tailbone/forms/widgets.py index 0b8d3dc9..db57f4f0 100644 --- a/tailbone/forms/widgets.py +++ b/tailbone/forms/widgets.py @@ -40,6 +40,7 @@ class ReadonlyWidget(dfwidget.HiddenWidget): readonly = True def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' # TODO: is this hacky? @@ -77,15 +78,17 @@ class PercentInputWidget(dfwidget.TextInputWidget): autocomplete = 'off' def serialize(self, field, cstruct, **kw): + """ """ if cstruct not in (colander.null, None): # convert "traditional" value to "human-friendly" value = decimal.Decimal(cstruct) * 100 value = value.quantize(decimal.Decimal('0.001')) cstruct = str(value) - return super(PercentInputWidget, self).serialize(field, cstruct, **kw) + return super().serialize(field, cstruct, **kw) def deserialize(self, field, pstruct): - pstruct = super(PercentInputWidget, self).deserialize(field, pstruct) + """ """ + pstruct = super().deserialize(field, pstruct) if pstruct is colander.null: return colander.null # convert "human-friendly" value to "traditional" @@ -108,6 +111,7 @@ class CasesUnitsWidget(dfwidget.Widget): one_amount_only = False def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -118,6 +122,7 @@ class CasesUnitsWidget(dfwidget.Widget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ from tailbone.forms.types import ProductQuantity if pstruct is colander.null: @@ -166,7 +171,7 @@ class CustomSelectWidget(dfwidget.SelectWidget): self.extra_template_values.update(kw) def get_template_values(self, field, cstruct, kw): - values = super(CustomSelectWidget, self).get_template_values(field, cstruct, kw) + values = super().get_template_values(field, cstruct, kw) if hasattr(self, 'extra_template_values'): values.update(self.extra_template_values) return values @@ -209,6 +214,7 @@ class JQueryDateWidget(dfwidget.DateInputWidget): ) def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = '' readonly = kw.get('readonly', self.readonly) @@ -243,12 +249,14 @@ class FalafelDateTimeWidget(dfwidget.DateTimeInputWidget): template = 'datetime_falafel' def serialize(self, field, cstruct, **kw): + """ """ readonly = kw.get('readonly', self.readonly) values = self.get_template_values(field, cstruct, kw) template = self.readonly_template if readonly else self.template return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null return pstruct @@ -261,6 +269,7 @@ class FalafelTimeWidget(dfwidget.TimeInputWidget): template = 'time_falafel' def deserialize(self, field, pstruct): + """ """ if pstruct == '': return colander.null return pstruct @@ -288,6 +297,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget): options = None def serialize(self, field, cstruct, **kw): + """ """ if 'delay' in kw or getattr(self, 'delay', None): raise ValueError( 'AutocompleteWidget does not support *delay* parameter ' @@ -324,6 +334,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): requirements = () def serialize(self, field, cstruct, **kw): + """ """ if cstruct in (colander.null, None): cstruct = [] @@ -339,6 +350,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return field.renderer(template, **values) def deserialize(self, field, pstruct): + """ """ if pstruct is colander.null: return colander.null @@ -359,6 +371,7 @@ class MultiFileUploadWidget(dfwidget.FileUploadWidget): return files_data def deserialize_upload(self, upload): + """ """ # nb. this logic was copied from parent class and adapted # to allow for multiple files. needs some more love. @@ -428,11 +441,13 @@ def make_customer_widget(request, **kwargs): class CustomerAutocompleteWidget(JQueryAutocompleteWidget): """ - Autocomplete widget for a Customer reference field. + Autocomplete widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request model = self.request.rattail_config.get_model() @@ -452,7 +467,7 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch customer to provide button label, if we have a value if cstruct: model = self.request.rattail_config.get_model() @@ -460,18 +475,21 @@ class CustomerAutocompleteWidget(JQueryAutocompleteWidget): if customer: self.field_display = str(customer) - return super(CustomerAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) class CustomerDropdownWidget(dfwidget.SelectWidget): """ - Dropdown widget for a Customer reference field. + Dropdown widget for a + :class:`~rattail:rattail.db.model.customers.Customer` reference + field. """ def __init__(self, request, *args, **kwargs): - super(CustomerDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request + app = self.request.rattail_config.get_app() # must figure out dropdown values, if they weren't given if 'values' not in kwargs: @@ -483,10 +501,8 @@ class CustomerDropdownWidget(dfwidget.SelectWidget): customers = customers() else: # default customer list - model = self.request.rattail_config.get_model() - customers = Session.query(model.Customer)\ - .order_by(model.Customer.name)\ - .all() + customers = app.get_clientele_handler()\ + .get_all_customers(Session()) # convert customer list to option values self.values = [(c.uuid, c.name) @@ -517,7 +533,7 @@ class DepartmentWidget(dfwidget.SelectWidget): values.insert(0, ('', "(none)")) kwargs['values'] = values - super(DepartmentWidget, self).__init__(**kwargs) + super().__init__(**kwargs) def make_vendor_widget(request, **kwargs): @@ -548,7 +564,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorAutocompleteWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request model = self.request.rattail_config.get_model() @@ -568,7 +584,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): # self.input_callback = input_handler def serialize(self, field, cstruct, **kw): - + """ """ # fetch vendor to provide button label, if we have a value if cstruct: model = self.request.rattail_config.get_model() @@ -576,7 +592,7 @@ class VendorAutocompleteWidget(JQueryAutocompleteWidget): if vendor: self.field_display = str(vendor) - return super(VendorAutocompleteWidget, self).serialize( + return super().serialize( field, cstruct, **kw) @@ -586,7 +602,7 @@ class VendorDropdownWidget(dfwidget.SelectWidget): """ def __init__(self, request, *args, **kwargs): - super(VendorDropdownWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.request = request # must figure out dropdown values, if they weren't given From d154986128a353e03bec4a0d18c55128bee43020 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 1 Dec 2023 21:57:20 -0600 Subject: [PATCH 004/357] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 45e5cc1b..7cffe70a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.85 (2023-12-01) +------------------- + +* Use clientele handler to populate customer dropdown widget. + + 0.9.84 (2023-11-30) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index a44d3ed3..66aab6b4 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.84' +__version__ = '0.9.85' From 91e7001963148766997a7d349e7d9c40cfca90ce Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 4 Dec 2023 10:15:12 -0600 Subject: [PATCH 005/357] Overhaul tox config for more python versions --- setup.cfg | 6 ++++++ tox.ini | 31 +++++++++++++++++-------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/setup.cfg b/setup.cfg index 85501357..67541d96 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,12 @@ classifiers = Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP Topic :: Office/Business Topic :: Software Development :: Libraries :: Python Modules diff --git a/tox.ini b/tox.ini index 8681465d..ea833b39 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,28 @@ [tox] -envlist = py36, py37, py39 +envlist = py36, py37, py38, py39, py310, py311 + +# TODO: can remove this when we drop py36 support +# nb. need this for testing older python versions +# https://tox.wiki/en/latest/faq.html#testing-end-of-life-python-versions +requires = virtualenv<20.22.0 [testenv] -commands = - pip install --upgrade pip - pip install --upgrade setuptools wheel - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest {posargs} +deps = rattail-tempmon +extras = tests +commands = pytest {posargs} + +[testenv:py37] +# nb. Chameleon 4.3 requires Python 3.9+ +deps = Chameleon<4.3 [testenv:coverage] basepython = python3 -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[tests] rattail[bouncer,db] rattail-tempmon - pytest --cov=tailbone --cov-report=html +extras = tests +commands = pytest --cov=tailbone --cov-report=html [testenv:docs] basepython = python3 changedir = docs -commands = - pip install --upgrade pip - pip install --upgrade --upgrade-strategy eager Tailbone[docs] rattail[bouncer,db] rattail-tempmon - sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs +extras = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees -W -T . {envtmpdir}/docs From 98fc82acfd0fdad8e0c6f30bc90993f85007931a Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 11 Dec 2023 13:50:02 -0600 Subject: [PATCH 006/357] Use `ltrim(rtrim())` instead of just `trim()` in grid filters apparently this is needed for older SQL Server compatibility, per https://stackoverflow.com/questions/54340470/trim-is-not-a-recognized-built-in-function-name --- tailbone/grids/filters.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tailbone/grids/filters.py b/tailbone/grids/filters.py index 2585433e..f70670b6 100644 --- a/tailbone/grids/filters.py +++ b/tailbone/grids/filters.py @@ -313,7 +313,7 @@ class AlchemyGridFilter(GridFilter): def __init__(self, *args, **kwargs): self.column = kwargs.pop('column') - super(AlchemyGridFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter_equal(self, query, value): """ @@ -538,17 +538,18 @@ class AlchemyStringFilter(AlchemyGridFilter): return query.filter(sa.or_(*conditions)) def filter_is_empty(self, query, value): - return query.filter(sa.func.trim(self.column) == self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value('')) def filter_is_not_empty(self, query, value): - return query.filter(sa.func.trim(self.column) != self.encode_value('')) + return query.filter(sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value('')) def filter_is_empty_or_null(self, query, value): return query.filter( sa.or_( - sa.func.trim(self.column) == self.encode_value(''), + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''), self.column == None)) + class AlchemyEmptyStringFilter(AlchemyStringFilter): """ String filter with special logic to treat empty string values as NULL @@ -558,13 +559,13 @@ class AlchemyEmptyStringFilter(AlchemyStringFilter): return query.filter( sa.or_( self.column == None, - sa.func.trim(self.column) == self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) == self.encode_value(''))) def filter_is_not_null(self, query, value): return query.filter( sa.and_( self.column != None, - sa.func.trim(self.column) != self.encode_value(''))) + sa.func.ltrim(sa.func.rtrim(self.column)) != self.encode_value(''))) class AlchemyByteStringFilter(AlchemyStringFilter): @@ -576,7 +577,7 @@ class AlchemyByteStringFilter(AlchemyStringFilter): value_encoding = 'utf-8' def get_value(self, value=UNSPECIFIED): - value = super(AlchemyByteStringFilter, self).get_value(value) + value = super().get_value(value) if isinstance(value, str): value = value.encode(self.value_encoding) return value @@ -637,32 +638,32 @@ class AlchemyNumericFilter(AlchemyGridFilter): def filter_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_equal(query, value) + return super().filter_equal(query, value) def filter_not_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_not_equal(query, value) + return super().filter_not_equal(query, value) def filter_greater_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_than(query, value) + return super().filter_greater_than(query, value) def filter_greater_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_greater_equal(query, value) + return super().filter_greater_equal(query, value) def filter_less_than(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_than(query, value) + return super().filter_less_than(query, value) def filter_less_equal(self, query, value): if self.value_invalid(value): return query - return super(AlchemyNumericFilter, self).filter_less_equal(query, value) + return super().filter_less_equal(query, value) class AlchemyIntegerFilter(AlchemyNumericFilter): @@ -1193,7 +1194,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_contains(query, value) + return super().filter_contains(query, value) def filter_does_not_contain(self, query, value): """ @@ -1201,7 +1202,7 @@ class AlchemyPhoneNumberFilter(AlchemyStringFilter): 'NOT ILIKE' query with those parts. """ value = self.parse_value(value) - return super(AlchemyPhoneNumberFilter, self).filter_does_not_contain(query, value) + return super().filter_does_not_contain(query, value) class GridFilterSet(OrderedDict): @@ -1245,7 +1246,7 @@ class GridFiltersForm(forms.Form): node = colander.SchemaNode(colander.String(), name=key) schema.add(node) kwargs['schema'] = schema - super(GridFiltersForm, self).__init__(**kwargs) + super().__init__(**kwargs) def iter_filters(self): return self.filters.values() From b6618c8ee5e48ada18e429b4ae85a674e91e18cb Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 12 Dec 2023 11:46:28 -0600 Subject: [PATCH 007/357] Update changelog --- CHANGES.rst | 6 ++++++ tailbone/_version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7cffe70a..40e3a0d1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,12 @@ CHANGELOG ========= +0.9.86 (2023-12-12) +------------------- + +* Use ``ltrim(rtrim())`` instead of just ``trim()`` in grid filters. + + 0.9.85 (2023-12-01) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 66aab6b4..689b5c2b 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.85' +__version__ = '0.9.86' From 90630fe8523a9de739de4ec73076b69a24914d4b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 13 Dec 2023 12:05:42 -0600 Subject: [PATCH 008/357] Auto-disable submit button for login form not sure why i had explicitly disabled that before..? --- tailbone/views/auth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index f8d71d34..7c4d26f0 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -101,8 +101,6 @@ class AuthenticationView(View): form = forms.Form(schema=UserLogin(), request=self.request) form.save_label = "Login" - form.auto_disable_save = False - form.auto_disable = False # TODO: deprecate / remove this form.show_reset = True form.show_cancel = False if form.validate(): From 90e35ee3dbcb35335437ae530c8a731305fec366 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 19 Dec 2023 12:49:33 -0600 Subject: [PATCH 009/357] Hide single invoice file field for multi-invoice receiving batch --- tailbone/views/purchasing/receiving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 33f3cc53..8cf38aaf 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -533,6 +533,7 @@ class ReceivingBatchView(PurchasingBatchView): f.insert_before('invoice_file', 'invoice_files') f.set_renderer('invoice_files', self.render_invoice_files) f.set_readonly('invoice_files', True) + f.remove('invoice_file') # invoice totals f.set_label('invoice_total', "Invoice Total (Orig.)") From 3bdc7175a3fb52bf3c28f1d88c81cd647ee634b2 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 20 Dec 2023 11:56:24 -0600 Subject: [PATCH 010/357] Use common logic to render invoice total for receiving and avoid error if total is none --- tailbone/views/purchasing/receiving.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 8cf38aaf..22fbc133 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -1857,6 +1857,7 @@ class ReceivingBatchView(PurchasingBatchView): """ AJAX view for updating various cost fields in a data row. """ + app = self.get_rattail_app() model = self.model batch = self.get_instance() data = dict(get_form_data(self.request)) @@ -1891,10 +1892,10 @@ class ReceivingBatchView(PurchasingBatchView): 'catalog_cost_confirmed': row.catalog_cost_confirmed, 'invoice_unit_cost': self.render_simple_unit_cost(row, 'invoice_unit_cost'), 'invoice_cost_confirmed': row.invoice_cost_confirmed, - 'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(row.invoice_total_calculated), }, 'batch': { - 'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated), + 'invoice_total_calculated': app.render_currency(batch.invoice_total_calculated), }, } From a40add8f413d44e3d34ca67204af441269dbda8f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 22 Dec 2023 11:50:05 -0600 Subject: [PATCH 011/357] Expose default custorder discount for Departments --- tailbone/views/departments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tailbone/views/departments.py b/tailbone/views/departments.py index 3d462b16..a062b183 100644 --- a/tailbone/views/departments.py +++ b/tailbone/views/departments.py @@ -59,6 +59,7 @@ class DepartmentView(MasterView): 'tax', 'food_stampable', 'exempt_from_gross_sales', + 'default_custorder_discount', 'allow_product_deletions', 'employees', ] @@ -114,6 +115,9 @@ class DepartmentView(MasterView): # TODO: make this editable f.set_readonly('tax') + # default_custorder_discount + f.set_type('default_custorder_discount', 'percent') + def render_employees(self, department, field): route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() From 25c48a97c55a0e8ea5909fe2b34dc0fb8407d1e4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Dec 2023 20:17:05 -0600 Subject: [PATCH 012/357] Update changelog --- CHANGES.rst | 12 ++++++++++++ tailbone/_version.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 40e3a0d1..174c06ae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,18 @@ CHANGELOG ========= +0.9.87 (2023-12-26) +------------------- + +* Auto-disable submit button for login form. + +* Hide single invoice file field for multi-invoice receiving batch. + +* Use common logic to render invoice total for receiving. + +* Expose default custorder discount for Departments. + + 0.9.86 (2023-12-12) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 689b5c2b..5c813a10 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.86' +__version__ = '0.9.87' From 0b7d2f5aede8f5f6123326f87b78b04247632b70 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Mar 2024 11:47:37 -0500 Subject: [PATCH 013/357] Fix how metadata/bind is used for importer batch table per changes coming in SQLAlchemy 2.0 --- tailbone/views/batch/importer.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index f0b76bf6..a5916448 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -26,7 +26,7 @@ Views for importer batches import sqlalchemy as sa -from rattail.db import model +from rattail.db.model import ImporterBatch import colander @@ -37,7 +37,7 @@ class ImporterBatchView(BatchMasterView): """ Master view for importer batches. """ - model_class = model.ImporterBatch + model_class = ImporterBatch default_handler_spec = 'rattail.batch.importer:ImporterBatchHandler' route_prefix = 'batch.importer' url_prefix = '/batches/importer' @@ -91,7 +91,7 @@ class ImporterBatchView(BatchMasterView): ] def configure_form(self, f): - super(ImporterBatchView, self).configure_form(f) + super().configure_form(f) # readonly fields f.set_readonly('import_handler_spec') @@ -110,21 +110,21 @@ class ImporterBatchView(BatchMasterView): self.make_row_table(batch.row_table) kwargs['rows'] = self.Session.query(self.current_row_table).all() kwargs.setdefault('status_enum', self.enum.IMPORTER_BATCH_ROW_STATUS) - breakdown = super(ImporterBatchView, self).make_status_breakdown( - batch, **kwargs) + breakdown = super().make_status_breakdown(batch, **kwargs) return breakdown def delete_instance(self, batch): self.make_row_table(batch.row_table) if self.current_row_table is not None: self.current_row_table.drop() - super(ImporterBatchView, self).delete_instance(batch) + super().delete_instance(batch) def make_row_table(self, name): if not hasattr(self, 'current_row_table'): - metadata = sa.MetaData(schema='batch', bind=self.Session.bind) + metadata = sa.MetaData(schema='batch') try: - self.current_row_table = sa.Table(name, metadata, autoload=True) + self.current_row_table = sa.Table(name, metadata, + autoload_with=self.Session.bind) except sa.exc.NoSuchTableError: self.current_row_table = None @@ -136,7 +136,7 @@ class ImporterBatchView(BatchMasterView): return self.enum.IMPORTER_BATCH_ROW_STATUS def configure_row_grid(self, g): - super(ImporterBatchView, self).configure_row_grid(g) + super().configure_row_grid(g) def make_filter(field, **kwargs): column = getattr(self.current_row_table.c, field) @@ -190,7 +190,7 @@ class ImporterBatchView(BatchMasterView): def get_parent(self, row): uuid = self.current_row_table.name - return self.Session.get(model.ImporterBatch, uuid) + return self.Session.get(ImporterBatch, uuid) def get_row_instance_title(self, row): if row.object_str: @@ -242,7 +242,7 @@ class ImporterBatchView(BatchMasterView): kwargs.setdefault('schema', colander.Schema()) kwargs.setdefault('cancel_url', None) - return super(ImporterBatchView, self).make_row_form(instance=row, **kwargs) + return super().make_row_form(instance=row, **kwargs) def configure_row_form(self, f): """ @@ -291,7 +291,7 @@ class ImporterBatchView(BatchMasterView): ] def get_row_xlsx_row(self, row, fields): - xlrow = super(ImporterBatchView, self).get_row_xlsx_row(row, fields) + xlrow = super().get_row_xlsx_row(row, fields) xlrow['status'] = self.enum.IMPORTER_BATCH_ROW_STATUS[row.status_code] From 27fce173cefef545e19bd834171d1c685d853fb8 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Mar 2024 11:48:52 -0500 Subject: [PATCH 014/357] Fix how row grid values are fetched, for row proxy objects per changes coming in SQLAlchemy 2.0 --- tailbone/grids/core.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 7a0d00e3..41964648 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2023 Lance Edgar +# Copyright © 2010-2024 Lance Edgar # # This file is part of Rattail. # @@ -33,7 +33,6 @@ from sqlalchemy import orm from rattail.db.types import GPCType from rattail.util import prettify, pretty_boolean, pretty_quantity -from rattail.time import localtime import webhelpers2_grid from pyramid.renderers import render @@ -478,6 +477,11 @@ class Grid(object): :returns: The value, or ``None`` if no value was found. """ + # TODO: this seems a little hacky, is there a better way? + # nb. this may only be relevant for import/export batch view? + if isinstance(obj, sa.engine.Row): + return obj._mapping[column_name] + try: return obj[column_name] except KeyError: @@ -503,7 +507,8 @@ class Grid(object): value = self.obtain_value(obj, column_name) if value is None: return "" - value = localtime(self.request.rattail_config, value) + app = self.request.rattail_config.get_app() + value = app.localtime(value) return raw_datetime(self.request.rattail_config, value) def render_enum(self, obj, column_name): @@ -1724,7 +1729,7 @@ class CustomWebhelpersGrid(webhelpers2_grid.Grid): self.renderers = kwargs.pop('renderers', {}) self.linked_columns = kwargs.pop('linked_columns', []) self.extra_record_class = kwargs.pop('extra_record_class', None) - super(CustomWebhelpersGrid, self).__init__(itemlist, columns, **kwargs) + super().__init__(itemlist, columns, **kwargs) def generate_header_link(self, column_number, column, label_text): From 4363b7c5d738d951483a42947a1c38557dd50646 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 26 Mar 2024 12:53:20 -0500 Subject: [PATCH 015/357] Update changelog --- CHANGES.rst | 7 +++++++ tailbone/_version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 174c06ae..4e96d2e1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,13 @@ CHANGELOG ========= +0.9.88 (2024-03-26) +------------------- + +* Update some SQLAlchemy logic per upcoming 2.0 changes. + + + 0.9.87 (2023-12-26) ------------------- diff --git a/tailbone/_version.py b/tailbone/_version.py index 5c813a10..86e8f57c 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.87' +__version__ = '0.9.88' From dfdb7a9b59e8c10a551e7003cebca50a50438846 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Mar 2024 13:11:03 -0500 Subject: [PATCH 016/357] Fix bulk-delete rows for import/export batch per changes in SQLAlchemy 1.4 --- CHANGES.rst | 7 ++++++- tailbone/views/batch/importer.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4e96d2e1..1717910f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,13 +2,18 @@ CHANGELOG ========= +Unreleased +---------- + +* Fix bulk-delete rows for import/export batch. + + 0.9.88 (2024-03-26) ------------------- * Update some SQLAlchemy logic per upcoming 2.0 changes. - 0.9.87 (2023-12-26) ------------------- diff --git a/tailbone/views/batch/importer.py b/tailbone/views/batch/importer.py index a5916448..962093da 100644 --- a/tailbone/views/batch/importer.py +++ b/tailbone/views/batch/importer.py @@ -277,7 +277,7 @@ class ImporterBatchView(BatchMasterView): query = self.get_effective_row_data(sort=False) batch.rowcount -= query.count() delete_query = self.current_row_table.delete().where(self.current_row_table.c.uuid.in_([row.uuid for row in query])) - delete_query.execute() + self.Session.bind.execute(delete_query) return self.redirect(self.get_action_url('view', batch)) def get_row_xlsx_fields(self): From cdc857065b42fd82eb0b29409e276819231f4b09 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 27 Mar 2024 13:14:23 -0500 Subject: [PATCH 017/357] Update changelog --- CHANGES.rst | 3 +++ tailbone/_version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1717910f..38c3b959 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +0.9.89 (2024-03-27) +------------------- + * Fix bulk-delete rows for import/export batch. diff --git a/tailbone/_version.py b/tailbone/_version.py index 86e8f57c..a8c7fe3a 100644 --- a/tailbone/_version.py +++ b/tailbone/_version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8; -*- -__version__ = '0.9.88' +__version__ = '0.9.89' From 1889f7d2697c2741c90594ce55d8fb7c96daa2fa Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 1 Apr 2024 18:05:27 -0500 Subject: [PATCH 018/357] Add basic CRUD for Person "preferred first name" only shown if config flag says so --- CHANGES.rst | 3 + .../templates/people/view_profile_buefy.mako | 37 ++++++++-- tailbone/views/people.py | 68 +++++++++++++++---- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 38c3b959..1d8d63d3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ CHANGELOG Unreleased ---------- +* Add basic CRUD for Person "preferred first name". + + 0.9.89 (2024-03-27) ------------------- diff --git a/tailbone/templates/people/view_profile_buefy.mako b/tailbone/templates/people/view_profile_buefy.mako index 4b1e089c..81243464 100644 --- a/tailbone/templates/people/view_profile_buefy.mako +++ b/tailbone/templates/people/view_profile_buefy.mako @@ -91,6 +91,12 @@ {{ person.first_name }} + % if use_preferred_first_name: + + {{ person.preferred_first_name }} + + % endif + {{ person.middle_name }} @@ -118,11 +124,25 @@