diff --git a/CHANGELOG.md b/CHANGELOG.md index c974b3a6..a31b80ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,90 +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 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..350803dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,9 @@ build-backend = "hatchling.build" [project] name = "Tailbone" -version = "0.22.7" +version = "0.21.8" 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.5", "sa-filters", "simplejson", "transaction", "waitress", "WebHelpers2", - "WuttaWeb>=0.21.0", + "WuttaWeb>=0.14.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..b5020975 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): <tailbone-form :configure-fields-help="configureFieldsHelp"> </tailbone-form> """ - 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): """ diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index 56b97b86..c6257d4b 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -235,7 +235,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')) @@ -1223,7 +1223,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 @@ -1240,10 +1239,7 @@ class Grid(WuttaGrid): 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): """ """ @@ -1548,11 +1544,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/menus.py b/tailbone/menus.py index 09d6f3f0..3ddee095 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", diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako index 8228f823..86b1ba1d 100644 --- a/tailbone/templates/base.mako +++ b/tailbone/templates/base.mako @@ -632,23 +632,9 @@ % endif <div class="navbar-dropdown"> % if request.is_root: - ${h.form(url('stop_root'), ref='stopBeingRootForm')} - ${h.csrf_token(request)} - <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="$refs.stopBeingRootForm.submit()" - class="navbar-item root-user"> - Stop being root - </a> - ${h.end_form()} + ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')} % elif request.is_admin: - ${h.form(url('become_root'), ref='startBeingRootForm')} - ${h.csrf_token(request)} - <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="$refs.startBeingRootForm.submit()" - class="navbar-item root-user"> - Become root - </a> - ${h.end_form()} + ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')} % endif % if messaging_enabled: ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')} diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako index 2e444fb5..3651d0c4 100644 --- a/tailbone/templates/datasync/configure.mako +++ b/tailbone/templates/datasync/configure.mako @@ -83,8 +83,8 @@ </b-notification> <b-field> - <b-checkbox name="rattail.datasync.use_profile_settings" - v-model="simpleSettings['rattail.datasync.use_profile_settings']" + <b-checkbox name="use_profile_settings" + v-model="useProfileSettings" native-value="true" @input="settingsNeedSaved = true"> Use these Settings to configure watchers and consumers @@ -99,7 +99,7 @@ </div> <div class="level-right"> <div class="level-item" - v-show="simpleSettings['rattail.datasync.use_profile_settings']"> + v-show="useProfileSettings"> <b-button type="is-primary" @click="newProfile()" icon-pack="fas" @@ -162,7 +162,7 @@ </${b}-table-column> <${b}-table-column label="Actions" v-slot="props" - v-if="simpleSettings['rattail.datasync.use_profile_settings']"> + v-if="useProfileSettings"> <a href="#" class="grid-action" @click.prevent="editProfile(props.row)"> @@ -580,27 +580,18 @@ <b-field label="Supervisor Process Name" message="This should be the complete name, including group - e.g. poser:poser_datasync" expanded> - <b-input name="rattail.datasync.supervisor_process_name" - v-model="simpleSettings['rattail.datasync.supervisor_process_name']" + <b-input name="supervisor_process_name" + v-model="supervisorProcessName" @input="settingsNeedSaved = true" expanded> </b-input> </b-field> - <b-field label="Consumer Batch Size" - message="Max number of changes to be consumed at once." - expanded> - <numeric-input name="rattail.datasync.batch_size_limit" - v-model="simpleSettings['rattail.datasync.batch_size_limit']" - @input="settingsNeedSaved = true" /> - </b-field> - - <h3 class="is-size-3">Legacy</h3> <b-field label="Restart Command" message="This will run as '${system_user}' system user - please configure sudoers as needed. Typical command is like: sudo supervisorctl restart poser:poser_datasync" expanded> - <b-input name="tailbone.datasync.restart" - v-model="simpleSettings['tailbone.datasync.restart']" + <b-input name="restart_command" + v-model="restartCommand" @input="settingsNeedSaved = true" expanded> </b-input> @@ -615,6 +606,7 @@ ThisPageData.showConfigFilesNote = false ThisPageData.profilesData = ${json.dumps(profiles_data)|n} ThisPageData.showDisabledProfiles = false + ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n} ThisPageData.editProfileShowDialog = false ThisPageData.editingProfile = null @@ -639,6 +631,9 @@ ThisPageData.editingConsumerRunas = null ThisPageData.editingConsumerEnabled = true + ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n} + ThisPageData.restartCommand = ${json.dumps(restart_command)|n} + ThisPage.computed.updateConsumerDisabled = function() { if (!this.editingConsumerKey) { return true diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt index 2121f01d..f78c0b85 100644 --- a/tailbone/templates/deform/checked_password.pt +++ b/tailbone/templates/deform/checked_password.pt @@ -1,7 +1,6 @@ <div i18n:domain="deform" tal:omit-tag="" tal:define="oid oid|field.oid; name name|field.name; - vmodel vmodel|'field_model_' + name; css_class css_class|field.widget.css_class; style style|field.widget.style;"> @@ -9,7 +8,7 @@ ${field.start_mapping()} <b-input type="password" name="${name}" - v-model="${vmodel}" + value="${field.widget.redisplay and cstruct or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; attributes|field.widget.attributes|{};" @@ -19,6 +18,7 @@ </b-input> <b-input type="password" name="${name}-confirm" + value="${field.widget.redisplay and confirm or ''}" tal:attributes="class string: form-control ${css_class or ''}; style style; confirm_attributes|field.widget.confirm_attributes|{};" diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako index 2100b460..ea35ab17 100644 --- a/tailbone/templates/forms/deform.mako +++ b/tailbone/templates/forms/deform.mako @@ -59,7 +59,7 @@ native-type="submit" :disabled="${form.vue_component}Submitting" icon-pack="fas" - icon-left="${form.button_icon_submit}"> + icon-left="save"> {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }} </b-button> % else: diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako index 118c028c..0a1f9c62 100644 --- a/tailbone/templates/master/view.mako +++ b/tailbone/templates/master/view.mako @@ -196,7 +196,6 @@ <p class="block has-text-weight-bold"> {{ version.model_title }} - ({{ version.operation }}) </p> <table class="diff monospace is-size-7" diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako deleted file mode 100644 index dc505c42..00000000 --- a/tailbone/templates/ordering/configure.mako +++ /dev/null @@ -1,74 +0,0 @@ -## -*- coding: utf-8; -*- -<%inherit file="/configure.mako" /> - -<%def name="form_content()"> - - <h3 class="block is-size-3">Workflows</h3> - <div class="block" style="padding-left: 2rem;"> - - <p class="block"> - Users can only choose from the workflows enabled below. - </p> - - <b-field> - <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch" - v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']" - native-value="true" - @input="settingsNeedSaved = true"> - From Scratch - </b-checkbox> - </b-field> - - <b-field> - <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file" - v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']" - native-value="true" - @input="settingsNeedSaved = true"> - From Order File - </b-checkbox> - </b-field> - - </div> - - <h3 class="block is-size-3">Vendors</h3> - <div class="block" style="padding-left: 2rem;"> - - <b-field message="If not set, user must choose a "supported" vendor."> - <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor" - v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']" - native-value="true" - @input="settingsNeedSaved = true"> - Allow ordering for <span class="has-text-weight-bold">any</span> vendor - </b-checkbox> - </b-field> - - </div> - - <h3 class="block is-size-3">Order Parsers</h3> - <div class="block" style="padding-left: 2rem;"> - - <p class="block"> - Only the selected file parsers will be exposed to users. - </p> - - % for Parser in order_parsers: - <b-field message="${Parser.key}"> - <b-checkbox name="order_parser_${Parser.key}" - v-model="orderParsers['${Parser.key}']" - native-value="true" - @input="settingsNeedSaved = true"> - ${Parser.title} - </b-checkbox> - </b-field> - % endfor - - </div> - -</%def> - -<%def name="modify_vue_vars()"> - ${parent.modify_vue_vars()} - <script> - ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n} - </script> -</%def> diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako index db029e5a..9f969468 100644 --- a/tailbone/templates/products/batch.mako +++ b/tailbone/templates/products/batch.mako @@ -55,20 +55,19 @@ </%def> <%def name="render_form_template()"> - <script type="text/x-template" id="${form.vue_tagname}-template"> + <script type="text/x-template" id="${form.component}-template"> ${self.render_form_innards()} </script> </%def> <%def name="modify_vue_vars()"> ${parent.modify_vue_vars()} - <% request.register_component(form.vue_tagname, form.vue_component) %> <script> ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako) let ${form.vue_component} = { - template: '#${form.vue_tagname}-template', + template: '#${form.component}-template', methods: { ## TODO: deprecate / remove the latter option here diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako index a36dde43..f613e13e 100644 --- a/tailbone/templates/receiving/configure.mako +++ b/tailbone/templates/receiving/configure.mako @@ -69,12 +69,12 @@ <h3 class="block is-size-3">Vendors</h3> <div class="block" style="padding-left: 2rem;"> - <b-field message="If not set, user must choose a "supported" vendor."> - <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor" - v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']" + <b-field message="If set, user must choose a "supported" vendor; otherwise they may choose "any" vendor."> + <b-checkbox name="rattail.batch.purchase.supported_vendors_only" + v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']" native-value="true" @input="settingsNeedSaved = true"> - Allow receiving for <span class="has-text-weight-bold">any</span> vendor + Only allow batch for "supported" vendors </b-checkbox> </b-field> diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako index 5cdf2be5..00ac1503 100644 --- a/tailbone/templates/reports/problems/view.mako +++ b/tailbone/templates/reports/problems/view.mako @@ -45,10 +45,11 @@ <b-button @click="runReportShowDialog = false"> Cancel </b-button> - ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})} + ${h.form(master.get_action_url('execute', instance))} ${h.csrf_token(request)} <b-button type="is-primary" native-type="submit" + @click="runReportSubmitting = true" :disabled="runReportSubmitting" icon-pack="fas" icon-left="arrow-circle-right"> diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako index b69eacfb..14616474 100644 --- a/tailbone/templates/themes/butterball/base.mako +++ b/tailbone/templates/themes/butterball/base.mako @@ -909,7 +909,7 @@ ${h.form(url('stop_root'), ref='stopBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="$refs.stopBeingRootForm.submit()" + <a @click="stopBeingRoot()" class="navbar-item has-background-danger has-text-white"> Stop being root </a> @@ -918,7 +918,7 @@ ${h.form(url('become_root'), ref='startBeingRootForm')} ${h.csrf_token(request)} <input type="hidden" name="referrer" value="${request.current_route_url()}" /> - <a @click="$refs.startBeingRootForm.submit()" + <a @click="startBeingRoot()" class="navbar-item has-background-danger has-text-white"> Become root </a> @@ -1103,6 +1103,18 @@ const key = 'menu_' + hash + '_shown' this[key] = !this[key] }, + + % if request.is_admin: + + startBeingRoot() { + this.$refs.startBeingRootForm.submit() + }, + + stopBeingRoot() { + this.$refs.stopBeingRootForm.submit() + }, + + % endif }, } diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index eceab803..730d7b6a 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -24,6 +24,8 @@ Auth Views """ +from rattail.db.auth import set_user_password + import colander from deform import widget as dfwidget from pyramid.httpexceptions import HTTPForbidden @@ -44,6 +46,28 @@ class UserLogin(colander.MappingSchema): widget=dfwidget.PasswordWidget()) +@colander.deferred +def current_password_correct(node, kw): + request = kw['request'] + app = request.rattail_config.get_app() + auth = app.get_auth_handler() + user = kw['user'] + def validate(node, value): + if not auth.authenticate_user(Session(), user.username, value): + raise colander.Invalid(node, "The password is incorrect") + return validate + + +class ChangePassword(colander.MappingSchema): + + current_password = colander.SchemaNode(colander.String(), + widget=dfwidget.PasswordWidget(), + validator=current_password_correct) + + new_password = colander.SchemaNode(colander.String(), + widget=dfwidget.CheckedPasswordWidget()) + + class AuthenticationView(View): def forbidden(self): @@ -80,7 +104,6 @@ class AuthenticationView(View): form.save_label = "Login" form.show_reset = True form.show_cancel = False - form.button_icon_submit = 'user' if form.validate(): user = self.authenticate_user(form.validated['username'], form.validated['password']) @@ -94,6 +117,10 @@ class AuthenticationView(View): else: self.request.session.flash("Invalid username or password", 'error') + image_url = self.rattail_config.get( + 'tailbone', 'main_image_url', + default=self.request.static_url('tailbone:static/img/home_logo.png')) + # nb. hacky..but necessary, to add the refs, for autofocus # (also add key handler, so ENTER acts like TAB) dform = form.make_deform_form() @@ -106,6 +133,7 @@ class AuthenticationView(View): return { 'form': form, 'referrer': referrer, + 'image_url': image_url, 'index_title': app.get_node_title(), 'help_url': global_help_url(self.rattail_config), } @@ -154,27 +182,10 @@ class AuthenticationView(View): self.request.user)) return self.redirect(self.request.get_referrer()) - def check_user_password(node, value): - auth = self.app.get_auth_handler() - user = self.request.user - if not auth.check_user_password(user, value): - node.raise_invalid("The password is incorrect") - - schema = colander.Schema() - - schema.add(colander.SchemaNode(colander.String(), - name='current_password', - widget=dfwidget.PasswordWidget(), - validator=check_user_password)) - - schema.add(colander.SchemaNode(colander.String(), - name='new_password', - widget=dfwidget.CheckedPasswordWidget())) - + schema = ChangePassword().bind(user=self.request.user, request=self.request) form = forms.Form(schema=schema, request=self.request) if form.validate(): - auth = self.app.get_auth_handler() - auth.set_user_password(self.request.user, form.validated['new_password']) + set_user_password(self.request.user, form.validated['new_password']) self.request.session.flash("Your password has been changed.") return self.redirect(self.request.get_referrer()) diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index c162b579..8ee3a37d 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -46,11 +46,10 @@ import colander from deform import widget as dfwidget from webhelpers2.html import HTML, tags -from wuttaweb.util import render_csrf_token - from tailbone import forms, grids from tailbone.db import Session from tailbone.views import MasterView +from tailbone.util import csrf_token log = logging.getLogger(__name__) @@ -384,7 +383,7 @@ class BatchMasterView(MasterView): f.set_label('executed_by', "Executed by") # notes - f.set_type('notes', 'text_wrapped') + f.set_type('notes', 'text') # if self.creating and self.request.user: # batch = fs.model @@ -442,7 +441,7 @@ class BatchMasterView(MasterView): form = [ begin_form, - render_csrf_token(self.request), + csrf_token(self.request), tags.hidden('complete', value=value), submit, tags.end_form(), diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py index 2b955b5f..134d6018 100644 --- a/tailbone/views/datasync.py +++ b/tailbone/views/datasync.py @@ -202,36 +202,10 @@ class DataSyncThreadView(MasterView): return self.redirect(self.request.get_referrer( default=self.request.route_url('datasyncchanges'))) - def configure_get_simple_settings(self): - """ """ - return [ - - # basic - {'section': 'rattail.datasync', - 'option': 'use_profile_settings', - 'type': bool}, - - # misc. - {'section': 'rattail.datasync', - 'option': 'supervisor_process_name'}, - {'section': 'rattail.datasync', - 'option': 'batch_size_limit', - 'type': int}, - - # legacy - {'section': 'tailbone', - 'option': 'datasync.restart'}, - - ] - - def configure_get_context(self, **kwargs): - """ """ - context = super().configure_get_context(**kwargs) - + def configure_get_context(self): profiles = self.datasync_handler.get_configured_profiles( include_disabled=True, ignore_problems=True) - context['profiles'] = profiles profiles_data = [] for profile in sorted(profiles.values(), key=lambda p: p.key): @@ -269,15 +243,25 @@ class DataSyncThreadView(MasterView): data['consumers_data'] = consumers profiles_data.append(data) - context['profiles_data'] = profiles_data - return context + return { + 'profiles': profiles, + 'profiles_data': profiles_data, + 'use_profile_settings': self.datasync_handler.should_use_profile_settings(), + 'supervisor_process_name': self.rattail_config.get( + 'rattail.datasync', 'supervisor_process_name'), + 'restart_command': self.rattail_config.get( + 'tailbone', 'datasync.restart'), + } - def configure_gather_settings(self, data, **kwargs): - """ """ - settings = super().configure_gather_settings(data, **kwargs) + def configure_gather_settings(self, data): + settings = [] + watch = [] - if data.get('rattail.datasync.use_profile_settings') == 'true': - watch = [] + use_profile_settings = data.get('use_profile_settings') == 'true' + settings.append({'name': 'rattail.datasync.use_profile_settings', + 'value': 'true' if use_profile_settings else 'false'}) + + if use_profile_settings: for profile in json.loads(data['profiles']): pkey = profile['key'] @@ -339,12 +323,17 @@ class DataSyncThreadView(MasterView): settings.append({'name': 'rattail.datasync.watch', 'value': ', '.join(watch)}) + if data['supervisor_process_name']: + settings.append({'name': 'rattail.datasync.supervisor_process_name', + 'value': data['supervisor_process_name']}) + + if data['restart_command']: + settings.append({'name': 'tailbone.datasync.restart', + 'value': data['restart_command']}) + return settings - def configure_remove_settings(self, **kwargs): - """ """ - super().configure_remove_settings(**kwargs) - + def configure_remove_settings(self): purge_datasync_settings(self.rattail_config, self.Session()) @classmethod diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 21a5e58f..baf63caa 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -412,7 +412,7 @@ class MasterView(View): session = self.Session() kwargs.setdefault('paginated', False) grid = self.make_grid(session=session, **kwargs) - return grid.get_visible_data() + return grid.make_visible_data() def get_grid_columns(self): """ @@ -903,7 +903,7 @@ class MasterView(View): def valid_employee_uuid(self, node, value): if value: - model = self.app.model + model = self.model employee = self.Session.get(model.Employee, value) if not employee: node.raise_invalid("Employee not found") @@ -939,7 +939,7 @@ class MasterView(View): def valid_vendor_uuid(self, node, value): if value: - model = self.app.model + model = self.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") @@ -1382,7 +1382,7 @@ class MasterView(View): return classes def make_revisions_grid(self, obj, empty_data=False): - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version', uuid=obj.uuid, @@ -1710,7 +1710,7 @@ class MasterView(View): kwargs.setdefault('paginated', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) - return grid.get_visible_data() + return grid.make_visible_data() @classmethod def get_row_url_prefix(cls): @@ -2153,7 +2153,7 @@ class MasterView(View): Thread target for executing an object. """ app = self.get_rattail_app() - model = self.app.model + model = self.model session = app.make_session() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) @@ -2594,7 +2594,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2617,7 +2617,7 @@ class MasterView(View): """ # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() info = session.query(model.TailbonePageHelp)\ @@ -2639,7 +2639,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -2673,7 +2673,7 @@ class MasterView(View): # nb. self.Session may differ, so use tailbone.db.Session session = Session() - model = self.app.model + model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() @@ -5541,7 +5541,7 @@ class MasterView(View): input_file_templates=True, output_file_templates=True): app = self.get_rattail_app() - model = self.app.model + model = self.model names = [] if simple_settings is None: @@ -6100,7 +6100,7 @@ class MasterView(View): renderer='json') -class ViewSupplement: +class ViewSupplement(object): """ Base class for view "supplements" - which are sort of like plugins which can "supplement" certain aspects of the view. @@ -6127,7 +6127,6 @@ class ViewSupplement: def __init__(self, master): self.master = master self.request = master.request - self.app = master.app self.model = master.model self.rattail_config = master.rattail_config self.Session = master.Session @@ -6161,7 +6160,7 @@ class ViewSupplement: This is accomplished by subjecting the current base query to a join, e.g. something like:: - model = self.app.model + model = self.model query = query.outerjoin(model.MyExtension) return query """ diff --git a/tailbone/views/people.py b/tailbone/views/people.py index 405b1ca3..b6a4c0b9 100644 --- a/tailbone/views/people.py +++ b/tailbone/views/people.py @@ -564,19 +564,15 @@ class PersonView(MasterView): Method which must return the base query for the profile's POS Transactions grid data. """ - customer = self.app.get_customer(person) + app = self.get_rattail_app() + customer = app.get_customer(person) - if customer: - key_field = self.app.get_customer_key_field() - customer_key = getattr(customer, key_field) - if customer_key is not None: - customer_key = str(customer_key) - else: - # nb. this should *not* match anything, so query returns - # no results.. - customer_key = person.uuid + key_field = app.get_customer_key_field() + customer_key = getattr(customer, key_field) + if customer_key is not None: + customer_key = str(customer_key) - trainwreck = self.app.get_trainwreck_handler() + trainwreck = app.get_trainwreck_handler() model = trainwreck.get_model() query = TrainwreckSession.query(model.Transaction)\ .filter(model.Transaction.customer_id == customer_key) @@ -1386,8 +1382,8 @@ class PersonView(MasterView): } if not context['users']: - context['suggested_username'] = auth.make_unique_username(self.Session(), - person=person) + context['suggested_username'] = auth.generate_unique_username(self.Session(), + person=person) return context diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 8461ae03..c546a0f4 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum from rattail import enum, pod, sil from rattail.db import api, auth, Session as RattailSession -from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem +from rattail.db.model import Product, PendingProduct, CustomerOrderItem from rattail.gpc import GPC from rattail.threads import Thread from rattail.exceptions import LabelPrintingError @@ -1857,8 +1857,7 @@ class ProductView(MasterView): lookup_fields.append('alt_code') if lookup_fields: product = self.products_handler.locate_product_for_entry( - session, term, lookup_fields=lookup_fields, - first_if_multiple=True) + session, term, lookup_fields=lookup_fields) if product: final_results.append(self.search_normalize_result(product)) @@ -2669,78 +2668,6 @@ class PendingProductView(MasterView): permission=f'{permission_prefix}.ignore_product') -class ProductCostView(MasterView): - """ - Master view for Product Costs - """ - model_class = ProductCost - route_prefix = 'product_costs' - url_prefix = '/products/costs' - has_versions = True - - grid_columns = [ - '_product_key_', - 'vendor', - 'preference', - 'code', - 'case_size', - 'case_cost', - 'pack_size', - 'pack_cost', - 'unit_cost', - ] - - def query(self, session): - """ """ - query = super().query(session) - model = self.app.model - - # always join on Product - return query.join(model.Product) - - def configure_grid(self, g): - """ """ - super().configure_grid(g) - model = self.app.model - - # product key - field = self.get_product_key_field() - g.set_renderer(field, self.render_product_key) - g.set_sorter(field, getattr(model.Product, field)) - g.set_sort_defaults(field) - g.set_filter(field, getattr(model.Product, field)) - - # vendor - g.set_joiner('vendor', lambda q: q.join(model.Vendor)) - g.set_sorter('vendor', model.Vendor.name) - g.set_filter('vendor', model.Vendor.name, label="Vendor Name") - - def render_product_key(self, cost, field): - """ """ - handler = self.app.get_products_handler() - return handler.render_product_key(cost.product) - - def configure_form(self, f): - """ """ - super().configure_form(f) - - # product - f.set_renderer('product', self.render_product) - if 'product_uuid' in f and 'product' in f: - f.remove('product') - f.replace('product_uuid', 'product') - - # vendor - f.set_renderer('vendor', self.render_vendor) - if 'vendor_uuid' in f and 'vendor' in f: - f.remove('vendor') - f.replace('vendor_uuid', 'vendor') - - # futures - # TODO: should eventually show a subgrid here? - f.remove('futures') - - def defaults(config, **kwargs): base = globals() @@ -2750,9 +2677,6 @@ def defaults(config, **kwargs): PendingProductView = kwargs.get('PendingProductView', base['PendingProductView']) PendingProductView.defaults(config) - ProductCostView = kwargs.get('ProductCostView', base['ProductCostView']) - ProductCostView.defaults(config) - def includeme(config): defaults(config) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 5e00704e..590b9af5 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -24,8 +24,6 @@ Base class for purchasing batch views """ -import warnings - from rattail.db.model import PurchaseBatch, PurchaseBatchRow import colander @@ -69,8 +67,6 @@ class PurchasingBatchView(BatchMasterView): 'store', 'buyer', 'vendor', - 'description', - 'workflow', 'department', 'purchase', 'vendor_email', @@ -162,174 +158,6 @@ class PurchasingBatchView(BatchMasterView): def batch_mode(self): raise NotImplementedError("Please define `batch_mode` for your purchasing batch view") - def get_supported_workflows(self): - """ - Return the supported "create batch" workflows. - """ - enum = self.app.enum - if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: - return self.batch_handler.supported_ordering_workflows() - elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: - return self.batch_handler.supported_receiving_workflows() - elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: - return self.batch_handler.supported_costing_workflows() - raise ValueError("unknown batch mode") - - def allow_any_vendor(self): - """ - Return boolean indicating whether creating a batch for "any" - vendor is allowed, vs. only supported vendors. - """ - enum = self.app.enum - - if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: - return self.batch_handler.allow_ordering_any_vendor() - - elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: - value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor') - if value is not None: - return value - value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only') - if value is not None: - warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; " - "please use rattail.batch.purchase.allow_receiving_any_vendor instead", - DeprecationWarning) - # nb. must negate this setting - return not value - return False - - raise ValueError("unknown batch mode") - - def get_supported_vendors(self): - """ - Return the supported vendors for creating a batch. - """ - return [] - - def create(self, form=None, **kwargs): - """ - Custom view for creating a new batch. We split the process - into two steps, 1) choose workflow and 2) create batch. This - is because the specific form details for creating a batch will - depend on which "type" of batch creation is to be done, and - it's much easier to keep conditional logic for that in the - server instead of client-side etc. - """ - model = self.app.model - enum = self.app.enum - route_prefix = self.get_route_prefix() - - workflows = self.get_supported_workflows() - valid_workflows = [workflow['workflow_key'] - for workflow in workflows] - - # if user has already identified their desired workflow, then - # we can just farm out to the default logic. we will of - # course configure our form differently, based on workflow, - # but this create() method at least will not need - # customization for that. - if self.request.matched_route.name.endswith('create_workflow'): - - redirect = self.redirect(self.request.route_url(f'{route_prefix}.create')) - - # however we do have one more thing to check - the workflow - # requested must of course be valid! - workflow_key = self.request.matchdict['workflow_key'] - if workflow_key not in valid_workflows: - self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error') - raise redirect - - # also, we require vendor to be correctly identified. if - # someone e.g. navigates to a URL by accident etc. we want - # to gracefully handle and redirect - uuid = self.request.matchdict['vendor_uuid'] - vendor = self.Session.get(model.Vendor, uuid) - if not vendor: - self.request.session.flash("Invalid vendor selection. " - "Please choose an existing vendor.", - 'warning') - raise redirect - - # okay now do the normal thing, per workflow - return super().create(**kwargs) - - # on the other hand, if caller provided a form, that means we are in - # the middle of some other custom workflow, e.g. "add child to truck - # dump parent" or some such. in which case we also defer to the normal - # logic, so as to not interfere with that. - if form: - return super().create(form=form, **kwargs) - - # okay, at this point we need the user to select a vendor and workflow - self.creating = True - context = {} - - # form to accept user choice of vendor/workflow - schema = colander.Schema() - schema.add(colander.SchemaNode(colander.String(), name='vendor')) - schema.add(colander.SchemaNode(colander.String(), name='workflow', - validator=colander.OneOf(valid_workflows))) - factory = self.get_form_factory() - form = factory(schema=schema, request=self.request) - - # configure vendor field - vendor_handler = self.app.get_vendor_handler() - if self.allow_any_vendor(): - # user may choose *any* available vendor - use_dropdown = vendor_handler.choice_uses_dropdown() - if use_dropdown: - vendors = self.Session.query(model.Vendor)\ - .order_by(model.Vendor.id)\ - .all() - vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}") - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - if len(vendors) == 1: - form.set_default('vendor', vendors[0].uuid) - else: - vendor_display = "" - if self.request.method == 'POST': - if self.request.POST.get('vendor'): - vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) - if vendor: - vendor_display = str(vendor) - vendors_url = self.request.route_url('vendors.autocomplete') - form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( - field_display=vendor_display, service_url=vendors_url)) - else: # only "supported" vendors allowed - vendors = self.get_supported_vendors() - vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) - for vendor in vendors] - form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) - form.set_validator('vendor', self.valid_vendor_uuid) - - # configure workflow field - values = [(workflow['workflow_key'], workflow['display']) - for workflow in workflows] - form.set_widget('workflow', - dfwidget.SelectWidget(values=values)) - if len(workflows) == 1: - form.set_default('workflow', workflows[0]['workflow_key']) - - form.submit_label = "Continue" - form.cancel_url = self.get_index_url() - - # if form validates, that means user has chosen a creation - # type, so we just redirect to the appropriate "new batch of - # type X" page - if form.validate(): - workflow_key = form.validated['workflow'] - vendor_uuid = form.validated['vendor'] - url = self.request.route_url(f'{route_prefix}.create_workflow', - workflow_key=workflow_key, - vendor_uuid=vendor_uuid) - raise self.redirect(url) - - context['form'] = form - if hasattr(form, 'make_deform_form'): - context['dform'] = form.make_deform_form() - return self.render_to_response('create', context) - def query(self, session): model = self.model return session.query(model.PurchaseBatch)\ @@ -398,40 +226,20 @@ class PurchasingBatchView(BatchMasterView): def configure_form(self, f): super().configure_form(f) - model = self.app.model - enum = self.app.enum - route_prefix = self.get_route_prefix() - - today = self.app.today() + model = self.model batch = f.model_instance - workflow = self.request.matchdict.get('workflow_key') - vendor_handler = self.app.get_vendor_handler() + app = self.get_rattail_app() + today = app.localtime().date() # mode - f.set_enum('mode', enum.PURCHASE_BATCH_MODE) - - # workflow - if self.creating: - if workflow: - f.set_widget('workflow', dfwidget.HiddenWidget()) - f.set_default('workflow', workflow) - f.set_hidden('workflow') - # nb. show readonly '_workflow' - f.insert_after('workflow', '_workflow') - f.set_readonly('_workflow') - f.set_renderer('_workflow', self.render_workflow) - else: - f.set_readonly('workflow') - f.set_renderer('workflow', self.render_workflow) - else: - f.remove('workflow') + f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) # store - single_store = self.config.single_store() + single_store = self.rattail_config.single_store() if self.creating: f.replace('store', 'store_uuid') if single_store: - store = self.config.get_store(self.Session()) + store = self.rattail_config.get_store(self.Session()) f.set_widget('store_uuid', dfwidget.HiddenWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') @@ -455,6 +263,7 @@ class PurchasingBatchView(BatchMasterView): if self.creating: f.replace('vendor', 'vendor_uuid') f.set_label('vendor_uuid', "Vendor") + vendor_handler = app.get_vendor_handler() use_dropdown = vendor_handler.choice_uses_dropdown() if use_dropdown: vendors = self.Session.query(model.Vendor)\ @@ -504,7 +313,7 @@ class PurchasingBatchView(BatchMasterView): if buyer: buyer_display = str(buyer) elif self.creating: - buyer = self.app.get_employee(self.request.user) + buyer = app.get_employee(self.request.user) if buyer: buyer_display = str(buyer) f.set_default('buyer_uuid', buyer.uuid) @@ -515,30 +324,6 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") - # order_file - if self.creating: - f.set_type('order_file', 'file', required=False) - else: - f.set_readonly('order_file') - f.set_renderer('order_file', self.render_downloadable_file) - - # order_parser_key - if self.creating: - kwargs = {} - if 'vendor_uuid' in self.request.matchdict: - vendor = self.Session.get(model.Vendor, - self.request.matchdict['vendor_uuid']) - if vendor: - kwargs['vendor'] = vendor - parsers = vendor_handler.get_supported_order_parsers(**kwargs) - parser_values = [(p.key, p.title) for p in parsers] - if len(parsers) == 1: - f.set_default('order_parser_key', parsers[0].key) - f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values)) - f.set_label('order_parser_key', "Order Parser") - else: - f.remove_field('order_parser_key') - # invoice_file if self.creating: f.set_type('invoice_file', 'file', required=False) @@ -556,7 +341,7 @@ class PurchasingBatchView(BatchMasterView): if vendor: kwargs['vendor'] = vendor - parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs) + parsers = self.handler.get_supported_invoice_parsers(**kwargs) parser_values = [(p.key, p.display) for p in parsers] if len(parsers) == 1: f.set_default('invoice_parser_key', parsers[0].key) @@ -615,35 +400,6 @@ class PurchasingBatchView(BatchMasterView): 'vendor_contact', 'status_code') - # tweak some things if we are in "step 2" of creating new batch - if self.creating and workflow: - - # display vendor but do not allow changing - vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid']) - if not vendor: - raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}") - f.set_readonly('vendor_uuid') - f.set_default('vendor_uuid', str(vendor)) - - # cancel should take us back to choosing a workflow - f.cancel_url = self.request.route_url(f'{route_prefix}.create') - - def render_workflow(self, batch, field): - key = self.request.matchdict['workflow_key'] - info = self.get_workflow_info(key) - if info: - return info['display'] - - def get_workflow_info(self, key): - enum = self.app.enum - if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING: - return self.batch_handler.ordering_workflow_info(key) - elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING: - return self.batch_handler.receiving_workflow_info(key) - elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING: - return self.batch_handler.costing_workflow_info(key) - raise ValueError("unknown batch mode") - def render_store(self, batch, field): store = batch.store if not store: @@ -759,12 +515,10 @@ class PurchasingBatchView(BatchMasterView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) - model = self.app.model + model = self.model kwargs['mode'] = self.batch_mode - kwargs['workflow'] = self.request.POST['workflow'] kwargs['truck_dump'] = batch.truck_dump - kwargs['order_parser_key'] = batch.order_parser_key kwargs['invoice_parser_key'] = batch.invoice_parser_key if batch.store: @@ -782,11 +536,6 @@ class PurchasingBatchView(BatchMasterView): elif batch.vendor_uuid: kwargs['vendor_uuid'] = batch.vendor_uuid - # must pull vendor from URL if it was not in form data - if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: - if 'vendor_uuid' in self.request.matchdict: - kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] - if batch.department: kwargs['department'] = batch.department elif batch.department_uuid: @@ -1170,25 +919,6 @@ class PurchasingBatchView(BatchMasterView): # # otherwise just view batch again # return self.get_action_url('view', batch) - @classmethod - def defaults(cls, config): - cls._purchase_batch_defaults(config) - cls._batch_defaults(config) - cls._defaults(config) - - @classmethod - def _purchase_batch_defaults(cls, config): - route_prefix = cls.get_route_prefix() - url_prefix = cls.get_url_prefix() - permission_prefix = cls.get_permission_prefix() - - # new batch using workflow X - config.add_route(f'{route_prefix}.create_workflow', - f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}') - config.add_view(cls, attr='create', - route_name=f'{route_prefix}.create_workflow', - permission=f'{permission_prefix}.create') - class NewProduct(colander.Schema): diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py index c7cc7bfc..2e24eebb 100644 --- a/tailbone/views/purchasing/ordering.py +++ b/tailbone/views/purchasing/ordering.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. # @@ -28,10 +28,14 @@ import os import json import openpyxl +from sqlalchemy import orm +from rattail.db import model, api from rattail.core import Object +from rattail.time import localtime + +from webhelpers2.html import tags -from tailbone.db import Session from tailbone.views.purchasing import PurchasingBatchView @@ -47,8 +51,6 @@ class OrderingBatchView(PurchasingBatchView): rows_editable = True has_worksheet = True default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html' - downloadable = True - configurable = True labels = { 'po_total_calculated': "PO Total", @@ -57,14 +59,9 @@ class OrderingBatchView(PurchasingBatchView): form_fields = [ 'id', 'store', - 'vendor', - 'description', - 'workflow', - 'order_file', - 'order_parser_key', 'buyer', + 'vendor', 'department', - 'params', 'purchase', 'vendor_email', 'vendor_fax', @@ -135,26 +132,15 @@ class OrderingBatchView(PurchasingBatchView): return self.enum.PURCHASE_BATCH_MODE_ORDERING def configure_form(self, f): - super().configure_form(f) + super(OrderingBatchView, self).configure_form(f) batch = f.model_instance - workflow = self.request.matchdict.get('workflow_key') # purchase if self.creating or not batch.executed or not batch.purchase: f.remove_field('purchase') - # now that all fields are setup, some final tweaks based on workflow - if self.creating and workflow: - - if workflow == 'from_scratch': - f.remove('order_file', - 'order_parser_key') - - elif workflow == 'from_file': - f.set_required('order_file') - def get_batch_kwargs(self, batch, **kwargs): - kwargs = super().get_batch_kwargs(batch, **kwargs) + kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs) kwargs['ship_method'] = batch.ship_method kwargs['notes_to_vendor'] = batch.notes_to_vendor return kwargs @@ -169,7 +155,7 @@ class OrderingBatchView(PurchasingBatchView): * ``cases_ordered`` * ``units_ordered`` """ - super().configure_row_form(f) + super(OrderingBatchView, self).configure_row_form(f) # when editing, only certain fields should allow changes if self.editing: @@ -322,7 +308,7 @@ class OrderingBatchView(PurchasingBatchView): title = self.get_instance_title(batch) order_date = batch.date_ordered if not order_date: - order_date = self.app.today() + order_date = localtime(self.rattail_config).date() return self.render_to_response('worksheet', { 'batch': batch, @@ -383,7 +369,6 @@ class OrderingBatchView(PurchasingBatchView): of being updated. If a matching row is not found, it will not be created. """ - model = self.app.model batch = self.get_instance() try: @@ -493,75 +478,13 @@ class OrderingBatchView(PurchasingBatchView): return self.file_response(path) def get_execute_success_url(self, batch, result, **kwargs): - model = self.app.model if isinstance(result, model.Purchase): return self.request.route_url('purchases.view', uuid=result.uuid) - return super().get_execute_success_url(batch, result, **kwargs) - - def configure_get_simple_settings(self): - return [ - - # workflows - {'section': 'rattail.batch', - 'option': 'purchase.allow_ordering_from_scratch', - 'type': bool, - 'default': True}, - {'section': 'rattail.batch', - 'option': 'purchase.allow_ordering_from_file', - 'type': bool, - 'default': True}, - - # vendors - {'section': 'rattail.batch', - 'option': 'purchase.allow_ordering_any_vendor', - 'type': bool, - 'default': True, - }, - ] - - def configure_get_context(self): - context = super().configure_get_context() - vendor_handler = self.app.get_vendor_handler() - - Parsers = vendor_handler.get_all_order_parsers() - Supported = vendor_handler.get_supported_order_parsers() - context['order_parsers'] = Parsers - context['order_parsers_data'] = dict([(Parser.key, Parser in Supported) - for Parser in Parsers]) - - return context - - def configure_gather_settings(self, data): - settings = super().configure_gather_settings(data) - vendor_handler = self.app.get_vendor_handler() - - supported = [] - for Parser in vendor_handler.get_all_order_parsers(): - name = f'order_parser_{Parser.key}' - if data.get(name) == 'true': - supported.append(Parser.key) - settings.append({'name': 'rattail.vendors.supported_order_parsers', - 'value': ', '.join(supported)}) - - return settings - - def configure_remove_settings(self): - super().configure_remove_settings() - - names = [ - 'rattail.vendors.supported_order_parsers', - ] - - # nb. using thread-local session here; we do not use - # self.Session b/c it may not point to Rattail - session = Session() - for name in names: - self.app.delete_setting(session, name) + return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs) @classmethod def defaults(cls, config): cls._ordering_defaults(config) - cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 01858c98..de19a2b9 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView): 'store', 'vendor', 'description', - 'workflow', + 'receiving_workflow', 'truck_dump', 'truck_dump_children_first', 'truck_dump_children', @@ -235,18 +235,135 @@ class ReceivingBatchView(PurchasingBatchView): if not self.handler.allow_truck_dump_receiving(): g.remove('truck_dump') - def get_supported_vendors(self): - """ """ - vendor_handler = self.app.get_vendor_handler() - vendors = {} - for parser in self.batch_handler.get_supported_invoice_parsers(): - if parser.vendor_key: - vendor = vendor_handler.get_vendor(self.Session(), - parser.vendor_key) - if vendor: - vendors[vendor.uuid] = vendor - vendors = sorted(vendors.values(), key=lambda v: v.name) - return vendors + def create(self, form=None, **kwargs): + """ + Custom view for creating a new receiving batch. We split the process + into two steps, 1) choose and 2) create. This is because the specific + form details for creating a batch will depend on which "type" of batch + creation is to be done, and it's much easier to keep conditional logic + for that in the server instead of client-side etc. + + See also + :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` + which uses similar logic. + """ + model = self.model + route_prefix = self.get_route_prefix() + workflows = self.handler.supported_receiving_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then we can + # just farm out to the default logic. we will of course configure our + # form differently, based on workflow, but this create() method at + # least will not need customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash( + "Not a supported workflow: {}".format(workflow_key), + 'error') + raise redirect + + # also, we require vendor to be correctly identified. if + # someone e.g. navigates to a URL by accident etc. we want + # to gracefully handle and redirect + uuid = self.request.matchdict['vendor_uuid'] + vendor = self.Session.get(model.Vendor, uuid) + if not vendor: + self.request.session.flash("Invalid vendor selection. " + "Please choose an existing vendor.", + 'warning') + raise redirect + + # okay now do the normal thing, per workflow + return super().create(**kwargs) + + # on the other hand, if caller provided a form, that means we are in + # the middle of some other custom workflow, e.g. "add child to truck + # dump parent" or some such. in which case we also defer to the normal + # logic, so as to not interfere with that. + if form: + return super().create(form=form, **kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + context = {} + + # form to accept user choice of vendor/workflow + schema = NewReceivingBatch().bind(valid_workflows=valid_workflows) + form = forms.Form(schema=schema, request=self.request) + + # configure vendor field + app = self.get_rattail_app() + vendor_handler = app.get_vendor_handler() + if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'): + # only show vendors for which we have dedicated invoice parsers + vendors = {} + for parser in self.batch_handler.get_supported_invoice_parsers(): + if parser.vendor_key: + vendor = vendor_handler.get_vendor(self.Session(), + parser.vendor_key) + if vendor: + vendors[vendor.uuid] = vendor + vendors = sorted(vendors.values(), key=lambda v: v.name) + vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor)) + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + else: + # user may choose *any* available vendor + use_dropdown = vendor_handler.choice_uses_dropdown() + if use_dropdown: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id)\ + .all() + vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) + for vendor in vendors] + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + if len(vendors) == 1: + form.set_default('vendor', vendors[0].uuid) + else: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.get(model.Vendor, self.request.POST['vendor']) + if vendor: + vendor_display = str(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + form.set_validator('vendor', self.valid_vendor_uuid) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + if len(workflows) == 1: + form.set_default('workflow', workflows[0]['workflow_key']) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation type, so we + # just redirect to the appropriate "new batch of type X" page + if form.validate(): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url('{}.create_workflow'.format(route_prefix), + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) def row_deletable(self, row): @@ -287,7 +404,13 @@ class ReceivingBatchView(PurchasingBatchView): # cancel should take us back to choosing a workflow f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) - # TODO: remove this + # receiving_workflow + if self.creating and workflow: + f.set_readonly('receiving_workflow') + f.set_renderer('receiving_workflow', self.render_receiving_workflow) + else: + f.remove('receiving_workflow') + # batch_type if self.creating: f.set_widget('batch_type', dfwidget.HiddenWidget()) @@ -402,7 +525,7 @@ class ReceivingBatchView(PurchasingBatchView): # multiple invoice files (if applicable) if (not self.creating - and batch.get_param('workflow') == 'from_multi_invoice'): + and batch.get_param('receiving_workflow') == 'from_multi_invoice'): if 'invoice_files' not in f: f.insert_before('invoice_file', 'invoice_files') @@ -501,6 +624,12 @@ class ReceivingBatchView(PurchasingBatchView): items.append(HTML.tag('li', c=[link])) return HTML.tag('ul', c=items) + def render_receiving_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.handler.receiving_workflow_info(key) + if info: + return info['display'] + def get_visible_params(self, batch): params = super().get_visible_params(batch) @@ -525,40 +654,42 @@ class ReceivingBatchView(PurchasingBatchView): def get_batch_kwargs(self, batch, **kwargs): kwargs = super().get_batch_kwargs(batch, **kwargs) + batch_type = self.request.POST['batch_type'] # must pull vendor from URL if it was not in form data if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs: if 'vendor_uuid' in self.request.matchdict: kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid'] - workflow = kwargs['workflow'] - if workflow == 'from_scratch': + # TODO: ugh should just have workflow and no batch_type + kwargs['receiving_workflow'] = batch_type + if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) - elif workflow == 'from_invoice': + elif batch_type == 'from_invoice': pass - elif workflow == 'from_multi_invoice': + elif batch_type == 'from_multi_invoice': pass - elif workflow == 'from_po': + elif batch_type == 'from_po': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif workflow == 'from_po_with_invoice': + elif batch_type == 'from_po_with_invoice': # TODO: how to best handle this field? this doesn't seem flexible kwargs['purchase_key'] = batch.purchase_uuid - elif workflow == 'truck_dump_children_first': + elif batch_type == 'truck_dump_children_first': kwargs['truck_dump'] = True kwargs['truck_dump_children_first'] = True kwargs['order_quantities_known'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif workflow == 'truck_dump_children_last': + elif batch_type == 'truck_dump_children_last': kwargs['truck_dump'] = True kwargs['truck_dump_ready'] = True # TODO: this makes sense in some cases, but all? # (should just omit that field when not relevant) kwargs['date_ordered'] = None - elif workflow.startswith('truck_dump_child'): + elif batch_type.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor @@ -1855,12 +1986,6 @@ class ReceivingBatchView(PurchasingBatchView): 'type': bool}, # vendors - {'section': 'rattail.batch', - 'option': 'purchase.allow_receiving_any_vendor', - 'type': bool}, - # TODO: deprecated; can remove this once all live config - # is updated. but for now it remains so this setting is - # auto-deleted {'section': 'rattail.batch', 'option': 'purchase.supported_vendors_only', 'type': bool}, @@ -1911,7 +2036,6 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def defaults(cls, config): cls._receiving_defaults(config) - cls._purchase_batch_defaults(config) cls._batch_defaults(config) cls._defaults(config) @@ -1919,11 +2043,17 @@ class ReceivingBatchView(PurchasingBatchView): def _receiving_defaults(cls, config): rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() instance_url_prefix = cls.get_instance_url_prefix() model_key = cls.get_model_key() model_title = cls.get_model_title() permission_prefix = cls.get_permission_prefix() + # new receiving batch using workflow X + config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) + config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + # row-level receiving config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix)) config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix), @@ -1976,6 +2106,33 @@ class ReceivingBatchView(PurchasingBatchView): permission='{}.auto_receive'.format(permission_prefix)) +@colander.deferred +def valid_workflow(node, kw): + """ + Deferred validator for ``workflow`` field, for new batches. + """ + valid_workflows = kw['valid_workflows'] + + def validate(node, value): + # we just need to provide possible values, and let stock validator + # handle the rest + oneof = colander.OneOf(valid_workflows) + return oneof(node, value) + + return validate + + +class NewReceivingBatch(colander.Schema): + """ + Schema for choosing which "type" of new receiving batch should be created. + """ + vendor = colander.SchemaNode(colander.String(), + label="Vendor") + + workflow = colander.SchemaNode(colander.String(), + validator=valid_workflow) + + class ReceiveRowForm(colander.MappingSchema): mode = colander.SchemaNode(colander.String(), diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py index ffa88032..3276b64d 100644 --- a/tailbone/views/upgrades.py +++ b/tailbone/views/upgrades.py @@ -348,27 +348,56 @@ class UpgradeView(MasterView): commit_hash_pattern = re.compile(r'^.{40}$') def get_changelog_projects(self): - project_map = { - 'onager': 'onager', - 'pyCOREPOS': 'pycorepos', - 'rattail': 'rattail', - 'rattail_corepos': 'rattail-corepos', - 'rattail-onager': 'rattail-onager', - 'rattail_tempmon': 'rattail-tempmon', - 'rattail_woocommerce': 'rattail-woocommerce', - 'Tailbone': 'tailbone', - 'tailbone_corepos': 'tailbone-corepos', - 'tailbone-onager': 'tailbone-onager', - 'tailbone_theo': 'theo', - 'tailbone_woocommerce': 'tailbone-woocommerce', + projects = { + 'rattail': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst', + }, + 'Tailbone': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst', + }, + 'pyCOREPOS': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst', + }, + 'rattail_corepos': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst', + }, + 'tailbone_corepos': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst', + }, + 'onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst', + }, + 'rattail-onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md', + }, + 'rattail_tempmon': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst', + }, + 'tailbone-onager': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md', + }, + 'rattail_woocommerce': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst', + }, + 'tailbone_woocommerce': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst', + }, + 'tailbone_theo': { + 'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10', + 'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst', + }, } - - projects = {} - for name, repo in project_map.items(): - projects[name] = { - 'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}', - 'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md', - } return projects def get_changelog_url(self, project, old_version, new_version):