diff --git a/CHANGELOG.md b/CHANGELOG.md index c974b3a6..5840f59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,170 +5,6 @@ All notable changes to Tailbone will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.22.7 (2025-02-19) - -### Fix - -- stop using old config for logo image url on login page -- fix warning msg for deprecated Grid param - -## v0.22.6 (2025-02-01) - -### Fix - -- register vue3 form component for products -> make batch - -## v0.22.5 (2024-12-16) - -### Fix - -- whoops this is latest rattail -- require newer rattail lib -- require newer wuttaweb -- let caller request safe HTML literal for rendered grid table - -## v0.22.4 (2024-11-22) - -### Fix - -- avoid error in product search for duplicated key -- use vmodel for confirm password widget input - -## v0.22.3 (2024-11-19) - -### Fix - -- avoid error for trainwreck query when not a customer - -## v0.22.2 (2024-11-18) - -### Fix - -- use local/custom enum for continuum operations -- add basic master view for Product Costs -- show continuum operation type when viewing version history -- always define `app` attr for ViewSupplement -- avoid deprecated import - -## v0.22.1 (2024-11-02) - -### Fix - -- fix submit button for running problem report -- avoid deprecated grid method - -## v0.22.0 (2024-10-22) - -### Feat - -- add support for new ordering batch from parsed file - -### Fix - -- avoid deprecated method to suggest username - -## v0.21.11 (2024-10-03) - -### Fix - -- custom method for adding grid action -- become/stop root should redirect to previous url - -## v0.21.10 (2024-09-15) - -### Fix - -- update project repo links, kallithea -> forgejo -- use better icon for submit button on login page -- wrap notes text for batch view -- expose datasync consumer batch size via configure page - -## v0.21.9 (2024-08-28) - -### Fix - -- render custom attrs in form component tag - -## v0.21.8 (2024-08-28) - -### Fix - -- ignore session kwarg for `MasterView.make_row_grid()` - -## v0.21.7 (2024-08-28) - -### Fix - -- avoid error when form value cannot be obtained - -## v0.21.6 (2024-08-28) - -### Fix - -- avoid error when grid value cannot be obtained - -## v0.21.5 (2024-08-28) - -### Fix - -- set empty string for "-new-" file configure option - -## v0.21.4 (2024-08-26) - -### Fix - -- handle differing email profile keys for appinfo/configure - -## v0.21.3 (2024-08-26) - -### Fix - -- show non-standard config values for app info configure email - -## v0.21.2 (2024-08-26) - -### Fix - -- refactor waterpark base template to use wutta feedback component -- fix input/output file upload feature for configure pages, per oruga -- tweak how grid data translates to Vue template context -- merge filters into main grid template -- add basic wutta view for users -- some fixes for wutta people view -- various fixes for waterpark theme -- avoid deprecated `component` form kwarg - -## v0.21.1 (2024-08-22) - -### Fix - -- misc. bugfixes per recent changes - -## v0.21.0 (2024-08-22) - -### Feat - -- move "most" filtering logic for grid class to wuttaweb -- inherit from wuttaweb templates for home, login pages -- inherit from wuttaweb for AppInfoView, appinfo/configure template -- add "has output file templates" config option for master view - -### Fix - -- change grid reset-view param name to match wuttaweb -- move "searchable columns" grid feature to wuttaweb -- use wuttaweb to get/render csrf token -- inherit from wuttaweb for appinfo/index template -- prefer wuttaweb config for "home redirect to login" feature -- fix master/index template rendering for waterpark theme -- fix spacing for navbar logo/title in waterpark theme - -## v0.20.1 (2024-08-20) - -### Fix - -- fix default filter verbs logic for workorder status - ## v0.20.0 (2024-08-20) ### Feat diff --git a/README.md b/README.rst similarity index 56% rename from README.md rename to README.rst index 74c007f6..0cffc62d 100644 --- a/README.md +++ b/README.rst @@ -1,8 +1,10 @@ -# Tailbone +Tailbone +======== Tailbone is an extensible web application based on Rattail. It provides a "back-office network environment" (BONE) for use in managing retail data. -Please see Rattail's [home page](http://rattailproject.org/) for more -information. +Please see Rattail's `home page`_ for more information. + +.. _home page: http://rattailproject.org/ diff --git a/docs/conf.py b/docs/conf.py index ade4c92a..52e384f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,10 +27,10 @@ templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { - 'rattail': ('https://docs.wuttaproject.org/rattail/', None), + 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), - 'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None), - 'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None), + 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), + 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), } # allow todo entries to show up diff --git a/pyproject.toml b/pyproject.toml index a7214a8e..150544ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,9 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.7" +version = "0.20.0" description = "Backoffice Web Application for Rattail" -readme = "README.md" +readme = "README.rst" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] license = {text = "GNU GPL v3+"} classifiers = [ @@ -53,13 +53,13 @@ dependencies = [ "pyramid_mako", "pyramid_retry", "pyramid_tm", - "rattail[db,bouncer]>=0.20.1", + "rattail[db,bouncer]>=0.18.1", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.21.0", + "WuttaWeb>=0.11.0", "zope.sqlalchemy>=1.5", ] @@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension" [project.urls] Homepage = "https://rattailproject.org" -Repository = "https://forgejo.wuttaproject.org/rattail/tailbone" -Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues" -Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md" +Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone" +Issues = "https://redmine.rattailproject.org/projects/tailbone/issues" +Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md" [tool.commitizen] diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py index b23bff55..daa4290f 100644 --- a/tailbone/api/batch/receiving.py +++ b/tailbone/api/batch/receiving.py @@ -29,7 +29,8 @@ import logging import humanize import sqlalchemy as sa -from rattail.db.model import PurchaseBatch, PurchaseBatchRow +from rattail.db import model +from rattail.util import pretty_quantity from cornice import Service from deform import widget as dfwidget @@ -44,7 +45,7 @@ log = logging.getLogger(__name__) class ReceivingBatchViews(APIBatchView): - model_class = PurchaseBatch + model_class = model.PurchaseBatch default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receivingbatchviews' permission_prefix = 'receiving' @@ -54,8 +55,7 @@ class ReceivingBatchViews(APIBatchView): supports_execute = True def base_query(self): - model = self.app.model - query = super().base_query() + query = super(ReceivingBatchViews, self).base_query() query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING) return query @@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView): # assume "receive from PO" if given a PO key if data.get('purchase_key'): - data['workflow'] = 'from_po' + data['receiving_workflow'] = 'from_po' return super().create_object(data) @@ -120,7 +120,6 @@ class ReceivingBatchViews(APIBatchView): return self._get(obj=batch) def eligible_purchases(self): - model = self.app.model uuid = self.request.params.get('vendor_uuid') vendor = self.Session.get(model.Vendor, uuid) if uuid else None if not vendor: @@ -177,7 +176,7 @@ class ReceivingBatchViews(APIBatchView): class ReceivingBatchRowViews(APIBatchRowView): - model_class = PurchaseBatchRow + model_class = model.PurchaseBatchRow default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler' route_prefix = 'receiving.rows' permission_prefix = 'receiving' @@ -186,8 +185,7 @@ class ReceivingBatchRowViews(APIBatchRowView): supports_quick_entry = True def make_filter_spec(self): - model = self.app.model - filters = super().make_filter_spec() + filters = super(ReceivingBatchRowViews, self).make_filter_spec() if filters: # must translate certain convenience filters @@ -298,11 +296,11 @@ class ReceivingBatchRowViews(APIBatchRowView): return filters def normalize(self, row): - data = super().normalize(row) - model = self.app.model + data = super(ReceivingBatchRowViews, self).normalize(row) batch = row.batch - prodder = self.app.get_products_handler() + app = self.get_rattail_app() + prodder = app.get_products_handler() data['product_uuid'] = row.product_uuid data['item_id'] = row.item_id @@ -377,7 +375,7 @@ class ReceivingBatchRowViews(APIBatchRowView): if accounted_for: # some product accounted for; button should receive "remainder" only if remainder: - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive Remainder ({} {})".format( remainder, data['unit_uom']) @@ -388,7 +386,7 @@ class ReceivingBatchRowViews(APIBatchRowView): else: # nothing yet accounted for, button should receive "all" if not remainder: log.warning("quick receive remainder is empty for row %s", row.uuid) - remainder = self.app.render_quantity(remainder) + remainder = pretty_quantity(remainder) data['quick_receive_quantity'] = remainder data['quick_receive_text'] = "Receive ALL ({} {})".format( remainder, data['unit_uom']) @@ -416,7 +414,7 @@ class ReceivingBatchRowViews(APIBatchRowView): data['received_alert'] = None if self.batch_handler.get_units_confirmed(row): msg = "You have already received some of this product; last update was {}.".format( - humanize.naturaltime(self.app.make_utc() - row.modified)) + humanize.naturaltime(app.make_utc() - row.modified)) data['received_alert'] = msg return data @@ -425,8 +423,6 @@ class ReceivingBatchRowViews(APIBatchRowView): """ View which handles "receiving" against a particular batch row. """ - model = self.app.model - # first do basic input validation schema = ReceiveRow().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) diff --git a/tailbone/api/master.py b/tailbone/api/master.py index 551d6428..2d17339e 100644 --- a/tailbone/api/master.py +++ b/tailbone/api/master.py @@ -26,6 +26,7 @@ Tailbone Web API - Master View import json +from rattail.config import parse_bool from rattail.db.util import get_fieldnames from cornice import resource, Service @@ -184,7 +185,7 @@ class APIMasterView(APIView): if sortcol: spec = { 'field': sortcol.field_name, - 'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc', + 'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc', } if sortcol.model_name: spec['model'] = sortcol.model_name diff --git a/tailbone/app.py b/tailbone/app.py index d2d0c5ef..b7262866 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -62,17 +62,6 @@ def make_rattail_config(settings): # nb. this is for compaibility with wuttaweb settings['wutta_config'] = rattail_config - # must import all sqlalchemy models before things get rolling, - # otherwise can have errors about continuum TransactionMeta class - # not yet mapped, when relevant pages are first requested... - # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models - # hat tip to https://stackoverflow.com/a/59241485 - if getattr(rattail_config, 'tempmon_engine', None): - from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession - tempmon_session = TempmonSession() - tempmon_session.query(tempmon_model.Appliance).first() - tempmon_session.close() - # configure database sessions if hasattr(rattail_config, 'appdb_engine'): tailbone.db.Session.configure(bind=rattail_config.appdb_engine) diff --git a/tailbone/diffs.py b/tailbone/diffs.py index 2e582b15..98253c57 100644 --- a/tailbone/diffs.py +++ b/tailbone/diffs.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -270,21 +270,9 @@ class VersionDiff(Diff): for field in self.fields: values[field] = {'before': self.render_old_value(field), 'after': self.render_new_value(field)} - - operation = None - if self.version.operation_type == continuum.Operation.INSERT: - operation = 'INSERT' - elif self.version.operation_type == continuum.Operation.UPDATE: - operation = 'UPDATE' - elif self.version.operation_type == continuum.Operation.DELETE: - operation = 'DELETE' - else: - operation = self.version.operation_type - return { 'key': id(self.version), 'model_title': self.title, - 'operation': operation, 'diff_class': self.nature, 'fields': self.fields, 'values': values, diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 4024557b..059b212a 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -401,8 +401,6 @@ class Form(object): self.edit_help_url = edit_help_url self.route_prefix = route_prefix - self.button_icon_submit = kwargs.get('button_icon_submit', 'save') - def __iter__(self): return iter(self.fields) @@ -1039,9 +1037,9 @@ class Form(object): def render_vue_tag(self, **kwargs): """ """ - return self.render_vuejs_component(**kwargs) + return self.render_vuejs_component() - def render_vuejs_component(self, **kwargs): + def render_vuejs_component(self): """ Render the Vue.js component HTML for the form. @@ -1052,11 +1050,10 @@ class Form(object): """ - kw = dict(self.vuejs_component_kwargs) - kw.update(kwargs) + kwargs = dict(self.vuejs_component_kwargs) if self.can_edit_help: - kw.setdefault(':configure-fields-help', 'configureFieldsHelp') - return HTML.tag(self.vue_tagname, **kw) + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.vue_tagname, **kwargs) def set_json_data(self, key, value): """ @@ -1383,11 +1380,7 @@ class Form(object): return getattr(record, field_name) except AttributeError: pass - - try: - return record[field_name] - except TypeError: - pass + return record[field_name] # TODO: is this always safe to do? elif self.defaults and field_name in self.defaults: diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 56b97b86..eada1041 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -24,10 +24,9 @@ Core Grid Classes """ -import inspect -import logging -import warnings from urllib.parse import urlencode +import warnings +import logging import sqlalchemy as sa from sqlalchemy import orm @@ -197,7 +196,11 @@ class Grid(WuttaGrid): raw_renderers={}, extra_row_class=None, url='#', + joiners={}, + filterable=False, + filters={}, use_byte_string_filters=False, + searchable={}, checkboxes=False, checked=None, check_handler=None, @@ -235,7 +238,7 @@ class Grid(WuttaGrid): if 'pageable' in kwargs: warnings.warn("pageable param is deprecated for Grid(); " - "please use paginated param instead", + "please use vue_tagname param instead", DeprecationWarning, stacklevel=2) kwargs.setdefault('paginated', kwargs.pop('pageable')) @@ -251,21 +254,10 @@ class Grid(WuttaGrid): DeprecationWarning, stacklevel=2) kwargs.setdefault('page', kwargs.pop('default_page')) - if 'searchable' in kwargs: - warnings.warn("searchable param is deprecated for Grid(); " - "please use searchable_columns param instead", - DeprecationWarning, stacklevel=2) - kwargs.setdefault('searchable_columns', kwargs.pop('searchable')) - # TODO: this should not be needed once all templates correctly # reference grid.vue_component etc. kwargs.setdefault('vue_tagname', 'tailbone-grid') - # nb. these must be set before super init, as they are - # referenced when constructing filters - self.assume_local_times = assume_local_times - self.use_byte_string_filters = use_byte_string_filters - kwargs['key'] = key kwargs['data'] = data super().__init__(request, **kwargs) @@ -283,11 +275,19 @@ class Grid(WuttaGrid): self.width = width self.enums = enums or {} + self.assume_local_times = assume_local_times self.renderers = self.make_default_renderers(self.renderers) self.raw_renderers = raw_renderers or {} self.invisible = invisible or [] self.extra_row_class = extra_row_class self.url = url + self.joiners = joiners or {} + + self.filterable = filterable + self.use_byte_string_filters = use_byte_string_filters + self.filters = self.make_filters(filters) + + self.searchable = searchable or {} self.checkboxes = checkboxes self.checked = checked @@ -443,14 +443,10 @@ class Grid(WuttaGrid): self.remove(oldfield) def set_joiner(self, key, joiner): - """ """ if joiner is None: - warnings.warn("specifying None is deprecated for Grid.set_joiner(); " - "please use Grid.remove_joiner() instead", - DeprecationWarning, stacklevel=2) - self.remove_joiner(key) + self.joiners.pop(key, None) else: - super().set_joiner(key, joiner) + self.joiners[key] = joiner def set_sorter(self, key, *args, **kwargs): """ """ @@ -478,19 +474,41 @@ class Grid(WuttaGrid): self.sorters[key] = self.make_sorter(*args, **kwargs) def set_filter(self, key, *args, **kwargs): - """ """ - if len(args) == 1: - if args[0] is None: - warnings.warn("specifying None is deprecated for Grid.set_filter(); " - "please use Grid.remove_filter() instead", - DeprecationWarning, stacklevel=2) - self.remove_filter(key) - return + if len(args) == 1 and args[0] is None: + self.remove_filter(key) + else: + if 'label' not in kwargs and key in self.labels: + kwargs['label'] = self.labels[key] + self.filters[key] = self.make_filter(key, *args, **kwargs) - # TODO: our make_filter() signature differs from upstream, - # so must call it explicitly instead of delegating to super - kwargs.setdefault('label', self.get_label(key)) - self.filters[key] = self.make_filter(key, *args, **kwargs) + def set_searchable(self, key, searchable=True): + if searchable: + self.searchable[key] = True + else: + self.searchable.pop(key, None) + + def is_searchable(self, key): + return self.searchable.get(key, False) + + def remove_filter(self, key): + self.filters.pop(key, None) + + def set_label(self, key, label, column_only=False): + """ + Set/override the label for a column. + + This overrides + :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add + the following params: + + :param column_only: Boolean indicating whether the label + should be applied *only* to the column header (if + ``True``), vs. applying also to the filter (if ``False``). + """ + super().set_label(key, label) + + if not column_only and key in self.filters: + self.filters[key].label = label def set_click_handler(self, key, handler): if handler: @@ -575,11 +593,7 @@ class Grid(WuttaGrid): return getattr(obj, column_name) except AttributeError: pass - - try: - return obj[column_name] - except TypeError: - pass + return obj[column_name] def render_currency(self, obj, column_name): value = self.obtain_value(obj, column_name) @@ -694,14 +708,6 @@ class Grid(WuttaGrid): def actions_column_format(self, column_number, row_number, item): return HTML.td(self.render_actions(item, row_number), class_='actions') - # TODO: upstream should handle this.. - def make_backend_filters(self, filters=None): - """ """ - final = self.get_default_filters() - if filters: - final.update(filters) - return final - def get_default_filters(self): """ Returns the default set of filters provided by the grid. @@ -726,6 +732,16 @@ class Grid(WuttaGrid): filters[prop.key] = self.make_filter(prop.key, column) return filters + def make_filters(self, filters=None): + """ + Returns an initial set of filters which will be available to the grid. + The grid itself may or may not provide some default filters, and the + ``filters`` kwarg may contain additions and/or overrides. + """ + if filters: + return filters + return self.get_default_filters() + def make_filter(self, key, column, **kwargs): """ Make a filter suitable for use with the given column. @@ -863,13 +879,9 @@ class Grid(WuttaGrid): settings['page'] = self.page if self.filterable: for filtr in self.iter_filters(): - defaults = self.filter_defaults.get(filtr.key, {}) - settings[f'filter.{filtr.key}.active'] = defaults.get('active', - filtr.default_active) - settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', - filtr.default_verb) - settings[f'filter.{filtr.key}.value'] = defaults.get('value', - filtr.default_value) + settings['filter.{}.active'.format(filtr.key)] = filtr.default_active + settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb + settings['filter.{}.value'.format(filtr.key)] = filtr.default_value # If user has default settings on file, apply those first. if self.user_has_defaults(): @@ -877,13 +889,13 @@ class Grid(WuttaGrid): # If request contains instruction to reset to default filters, then we # can skip the rest of the request/session checks. - if self.request.GET.get('reset-view'): + if self.request.GET.get('reset-to-default-filters') == 'true': pass # If request has filter settings, grab those, then grab sort/pager # settings from request or session. - elif self.request_has_settings('filter'): - self.update_filter_settings(settings, src='request') + elif self.filterable and self.request_has_settings('filter'): + self.update_filter_settings(settings, 'request') if self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') else: @@ -895,7 +907,7 @@ class Grid(WuttaGrid): # settings from request or session. elif self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') - self.update_filter_settings(settings, src='session') + self.update_filter_settings(settings, 'session') self.update_page_settings(settings) # NOTE: These next two are functionally equivalent, but are kept @@ -905,12 +917,12 @@ class Grid(WuttaGrid): # grab those, then grab filter/sort settings from session. elif self.request_has_settings('page'): self.update_page_settings(settings) - self.update_filter_settings(settings, src='session') + self.update_filter_settings(settings, 'session') self.update_sort_settings(settings, src='session') # If request has no settings, grab all from session. elif self.session_has_settings(): - self.update_filter_settings(settings, src='session') + self.update_filter_settings(settings, 'session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) @@ -1050,11 +1062,18 @@ class Grid(WuttaGrid): merge('page', int) def request_has_settings(self, type_): - """ """ - if super().request_has_settings(type_): - return True + """ + Determine if the current request (GET query string) contains any + filter/sort settings for the grid. + """ + if type_ == 'filter': + for filtr in self.iter_filters(): + if filtr.key in self.request.GET: + return True + if 'filter' in self.request.GET: # user may be applying empty filters + return True - if type_ == 'sort': + elif type_ == 'sort': # TODO: remove this eventually, but some links in the wild # may still include these params, so leave it for now @@ -1062,6 +1081,14 @@ class Grid(WuttaGrid): if key in self.request.GET: return True + if 'sort1key' in self.request.GET: + return True + + elif type_ == 'page': + for key in ['pagesize', 'page']: + if key in self.request.GET: + return True + return False def session_has_settings(self): @@ -1077,6 +1104,72 @@ class Grid(WuttaGrid): return any([key.startswith(f'{prefix}.filter') for key in self.request.session]) + def update_filter_settings(self, settings, source): + """ + Updates a settings dictionary according to filter settings data found + in either the GET query string, or session storage. + + :param settings: Dictionary of initial settings, which is to be updated. + + :param source: String identifying the source to consult for settings + data. Must be one of: ``('request', 'session')``. + """ + if not self.filterable: + return + + for filtr in self.iter_filters(): + prefix = 'filter.{}'.format(filtr.key) + + if source == 'request': + # consider filter active if query string contains a value for it + settings['{}.active'.format(prefix)] = filtr.key in self.request.GET + settings['{}.verb'.format(prefix)] = self.get_setting( + settings, f'{filtr.key}.verb', src='request', default='') + settings['{}.value'.format(prefix)] = self.get_setting( + settings, filtr.key, src='request', default='') + + else: # source = session + settings['{}.active'.format(prefix)] = self.get_setting( + settings, f'{prefix}.active', src='session', + normalize=lambda v: str(v).lower() == 'true', default=False) + settings['{}.verb'.format(prefix)] = self.get_setting( + settings, f'{prefix}.verb', src='session', default='') + settings['{}.value'.format(prefix)] = self.get_setting( + settings, f'{prefix}.value', src='session', default='') + + def update_page_settings(self, settings): + """ + Updates a settings dictionary according to pager settings data found in + either the GET query string, or session storage. + + Note that due to how the actual pager functions, the effective settings + will often come from *both* the request and session. This is so that + e.g. the page size will remain constant (coming from the session) while + the user jumps between pages (which only provides the single setting). + + :param settings: Dictionary of initial settings, which is to be updated. + """ + if not self.paginated: + return + + pagesize = self.request.GET.get('pagesize') + if pagesize is not None: + if pagesize.isdigit(): + settings['pagesize'] = int(pagesize) + else: + pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key)) + if pagesize is not None: + settings['pagesize'] = pagesize + + page = self.request.GET.get('page') + if page is not None: + if page.isdigit(): + settings['page'] = int(page) + else: + page = self.request.session.get('grid.{}.page'.format(self.key)) + if page is not None: + settings['page'] = int(page) + def persist_settings(self, settings, dest='session'): """ """ if dest not in ('defaults', 'session'): @@ -1164,12 +1257,89 @@ class Grid(WuttaGrid): return data - def make_visible_data(self): + def sort_data(self, data, sorters=None): """ """ - warnings.warn("grid.make_visible_data() method is deprecated; " - "please use grid.get_visible_data() instead", - DeprecationWarning, stacklevel=2) - return self.get_visible_data() + if sorters is None: + sorters = self.active_sorters + if not sorters: + return data + + # nb. when data is a query, we want to apply sorters in the + # requested order, so the final query has order_by() in the + # correct "as-is" sequence. however when data is a list we + # must do the opposite, applying in the reverse order, so the + # final list has the most "important" sort(s) applied last. + if not isinstance(data, orm.Query): + sorters = reversed(sorters) + + for sorter in sorters: + sortkey = sorter['key'] + sortdir = sorter['dir'] + + # cannot sort unless we have a sorter callable + sortfunc = self.sorters.get(sortkey) + if not sortfunc: + return data + + # join appropriate model if needed + if sortkey in self.joiners and sortkey not in self.joined: + data = self.joiners[sortkey](data) + self.joined.add(sortkey) + + # invoke the sorter + data = sortfunc(data, sortdir) + + return data + + def paginate_data(self, data): + """ + Paginate the given data set according to current settings, and return + the result. + """ + # we of course assume our current page is correct, at first + pager = self.make_pager(data) + + # if pager has detected that our current page is outside the valid + # range, we must re-orient ourself around the "new" (valid) page + if pager.page != self.page: + self.page = pager.page + self.request.session['grid.{}.page'.format(self.key)] = self.page + pager = self.make_pager(data) + + return pager + + def make_pager(self, data): + + # TODO: this seems hacky..normally we expect `data` to be a + # query of course, but in some cases it may be a list instead. + # if so then we can't use ORM pager + if isinstance(data, list): + import paginate + return paginate.Page(data, + items_per_page=self.pagesize, + page=self.page) + + return SqlalchemyOrmPage(data, + items_per_page=self.pagesize, + page=self.page, + url_maker=URLMaker(self.request)) + + def make_visible_data(self): + """ + Apply various settings to the raw data set, to produce a final data + set. This will page / sort / filter as necessary, according to the + grid's defaults and the current request etc. + """ + self.joined = set() + data = self.data + if self.filterable: + data = self.filter_data(data) + if self.sortable: + data = self.sort_data(data) + if self.paginated: + self.pager = self.paginate_data(data) + data = self.pager + return data def render_vue_tag(self, master=None, **kwargs): """ """ @@ -1192,7 +1362,7 @@ class Grid(WuttaGrid): includes the context menu items and grid tools. """ if 'grid_columns' not in kwargs: - kwargs['grid_columns'] = self.get_vue_columns() + kwargs['grid_columns'] = self.get_table_columns() if 'grid_data' not in kwargs: kwargs['grid_data'] = self.get_table_data() @@ -1215,7 +1385,6 @@ class Grid(WuttaGrid): return HTML.literal(html) def render_buefy(self, **kwargs): - """ """ warnings.warn("Grid.render_buefy() is deprecated; " "please use Grid.render_complete() instead", DeprecationWarning, stacklevel=2) @@ -1223,7 +1392,6 @@ class Grid(WuttaGrid): def render_table_element(self, template='/grids/b-table.mako', data_prop='gridData', empty_labels=False, - literal=False, **kwargs): """ This is intended for ad-hoc "small" grids with static data. Renders @@ -1235,15 +1403,12 @@ class Grid(WuttaGrid): context['data_prop'] = data_prop context['empty_labels'] = empty_labels if 'grid_columns' not in context: - context['grid_columns'] = self.get_vue_columns() + context['grid_columns'] = self.get_table_columns() context.setdefault('paginated', False) if context['paginated']: context.setdefault('per_page', 20) context['view_click_handler'] = self.get_view_click_handler() - result = render(template, context) - if literal: - result = HTML.literal(result) - return result + return render(template, context) def get_view_click_handler(self): """ """ @@ -1252,7 +1417,7 @@ class Grid(WuttaGrid): view = None for action in self.actions: if action.key == 'view': - return getattr(action, 'click_handler', None) + return action.click_handler def set_filters_sequence(self, filters, only=False): """ @@ -1326,6 +1491,28 @@ class Grid(WuttaGrid): return data + def render_filters(self, template='/grids/filters.mako', **kwargs): + """ + Render the filters to a Unicode string, using the specified template. + Additional kwargs are passed along as context to the template. + """ + # Provide default data to filters form, so renderer can do some of the + # work for us. + data = {} + for filtr in self.iter_active_filters(): + data['{}.active'.format(filtr.key)] = filtr.active + data['{}.verb'.format(filtr.key)] = filtr.verb + data[filtr.key] = filtr.value + + form = gridfilters.GridFiltersForm(self.filters, + request=self.request, + defaults=data) + + kwargs['request'] = self.request + kwargs['grid'] = self + kwargs['form'] = form + return render(template, kwargs) + def render_actions(self, row, i): # pragma: no cover """ """ warnings.warn("grid.render_actions() is deprecated!", @@ -1387,19 +1574,22 @@ class Grid(WuttaGrid): def get_vue_columns(self): """ """ - columns = super().get_vue_columns() - - for column in columns: - column['visible'] = column['field'] not in self.invisible - - return columns + return self.get_table_columns() def get_table_columns(self): - """ """ - warnings.warn("grid.get_table_columns() method is deprecated; " - "please use grid.get_vue_columns() instead", - DeprecationWarning, stacklevel=2) - return self.get_vue_columns() + """ + Return a list of dicts representing all grid columns. Meant + for use with the client-side JS table. + """ + columns = [] + for name in self.columns: + columns.append({ + 'field': name, + 'label': self.get_label(name), + 'sortable': self.is_sortable(name), + 'visible': name not in self.invisible, + }) + return columns def get_uuid_for_row(self, rowobj): @@ -1411,10 +1601,6 @@ class Grid(WuttaGrid): if hasattr(rowobj, 'uuid'): return rowobj.uuid - def get_vue_context(self): - """ """ - return self.get_table_data() - def get_vue_data(self): """ """ table_data = self.get_table_data() @@ -1429,7 +1615,7 @@ class Grid(WuttaGrid): return self._table_data # filter / sort / paginate to get "visible" data - raw_data = self.get_visible_data() + raw_data = self.make_visible_data() data = [] status_map = {} checked = [] @@ -1470,22 +1656,10 @@ class Grid(WuttaGrid): # leverage configured rendering logic where applicable; # otherwise use "raw" data value as string - value = self.obtain_value(rowobj, name) if self.renderers and name in self.renderers: - renderer = self.renderers[name] - - # TODO: legacy renderer callables require 2 args, - # but wuttaweb callables require 3 args - sig = inspect.signature(renderer) - required = [param for param in sig.parameters.values() - if param.default == param.empty] - - if len(required) == 2: - # TODO: legacy renderer - value = renderer(rowobj, name) - else: # the future - value = renderer(rowobj, name, value) - + value = self.renderers[name](rowobj, name) + else: + value = self.obtain_value(rowobj, name) if value is None: value = "" @@ -1518,8 +1692,6 @@ class Grid(WuttaGrid): results = { 'data': data, - 'row_classes': status_map, - # TODO: deprecate / remove this 'row_status_map': status_map, } @@ -1548,11 +1720,6 @@ class Grid(WuttaGrid): self._table_data = results return self._table_data - # TODO: remove this when we use upstream GridAction - def add_action(self, key, **kwargs): - """ """ - self.actions.append(GridAction(self.request, key, **kwargs)) - def set_action_urls(self, row, rowobj, i): """ Pre-generate all action URLs for the given data row. Meant for use diff --git a/tailbone/helpers.py b/tailbone/helpers.py index 50b38c30..23988423 100644 --- a/tailbone/helpers.py +++ b/tailbone/helpers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2024 Lance Edgar +# Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # @@ -24,9 +24,6 @@ Template Context Helpers """ -# start off with all from wuttaweb -from wuttaweb.helpers import * - import os import datetime from decimal import Decimal @@ -36,7 +33,12 @@ from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal from rattail.db.util import maxlen -from tailbone.util import (pretty_datetime, raw_datetime, +from webhelpers2.html import * +from webhelpers2.html.tags import * + +from wuttaweb.util import get_liburl +from tailbone.util import (csrf_token, get_csrf_token, + pretty_datetime, raw_datetime, render_markdown, route_exists) diff --git a/tailbone/menus.py b/tailbone/menus.py index 09d6f3f0..abd0b58b 100644 --- a/tailbone/menus.py +++ b/tailbone/menus.py @@ -394,11 +394,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'products', 'perm': 'products.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, { 'title': "Departments", 'route': 'departments', @@ -456,11 +451,6 @@ class TailboneMenuHandler(WuttaMenuHandler): 'route': 'vendors', 'perm': 'vendors.list', }, - { - 'title': "Product Costs", - 'route': 'product_costs', - 'perm': 'product_costs.list', - }, {'type': 'sep'}, { 'title': "Ordering", @@ -713,7 +703,7 @@ class TailboneMenuHandler(WuttaMenuHandler): }, {'type': 'sep'}, { - 'title': "App Info", + 'title': "App Details", 'route': 'appinfo', 'perm': 'appinfo.list', }, diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako index 9d866cea..4794f00b 100644 --- a/tailbone/templates/appinfo/configure.mako +++ b/tailbone/templates/appinfo/configure.mako @@ -1,2 +1,247 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/configure.mako" /> +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

Basics

+
+ + + + + + + + + + ## TODO: should be a dropdown, app handler defines choices + + + + + + + + + + + + + + Production Mode + + + +
+
+ + + Running from Source + + +
+
+ + + + +
+
+ +
+ +

Display

+
+ + + + + + + + + + +
+ +

Grids

+
+ + + + + + + + + + +
+ +

Web Libraries

+
+ + <${b}-table :data="weblibs"> + + <${b}-table-column field="title" + label="Name" + v-slot="props"> + {{ props.row.title }} + + + <${b}-table-column field="configured_version" + label="Version" + v-slot="props"> + {{ props.row.configured_version || props.row.default_version }} + + + <${b}-table-column field="configured_url" + label="URL Override" + v-slot="props"> + {{ props.row.configured_url }} + + + <${b}-table-column field="live_url" + label="Effective (Live) URL" + v-slot="props"> + + save settings and refresh page to see new URL + + + {{ props.row.live_url }} + + + + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + + % if request.use_oruga: + + % else: + + % endif + Edit + + + + + + % for weblib in weblibs: + ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})} + ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})} + % endfor + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="editWebLibraryShowDialog" + % else: + :active.sync="editWebLibraryShowDialog" + % endif + > + + + +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako index faaea935..75032c1f 100644 --- a/tailbone/templates/appinfo/index.mako +++ b/tailbone/templates/appinfo/index.mako @@ -1,7 +1,8 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/appinfo/index.mako" /> +<%inherit file="/master/index.mako" /> <%def name="page_content()"> +
- ${parent.page_content()} + <${b}-collapse class="panel" open> + + + +
+
+ <${b}-table :data="configFiles"> + + <${b}-table-column field="priority" + label="Priority" + v-slot="props"> + {{ props.row.priority }} + + + <${b}-table-column field="path" + label="File Path" + v-slot="props"> + {{ props.row.path }} + + + +
+
+ + + <${b}-collapse class="panel" + :open="false"> + + + +
+
+ ${grid.render_vue_tag()} +
+
+ + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8228f823..eb950011 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -632,23 +632,9 @@ % endif
@@ -686,7 +668,7 @@ text="Edit This"> % endif - % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'): + % if getattr(master, 'cloneable', False) and master.has_perm('clone'): diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako index b6376448..00cfdfe9 100644 --- a/tailbone/templates/base_meta.mako +++ b/tailbone/templates/base_meta.mako @@ -1,7 +1,10 @@ ## -*- coding: utf-8; -*- -<%inherit file="wuttaweb:templates/base_meta.mako" /> -<%def name="app_title()">${app.get_node_title()} +<%def name="app_title()">${rattail_app.get_node_title()} + +<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()} + +<%def name="extra_styles()"> <%def name="favicon()"> @@ -10,3 +13,9 @@ <%def name="header_logo()"> ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")} + +<%def name="footer()"> +

+ powered by ${h.link_to("Rattail", url('about'))} +

+ diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako index e6b128fc..272aadce 100644 --- a/tailbone/templates/configure.mako +++ b/tailbone/templates/configure.mako @@ -92,7 +92,7 @@ - +