# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework # Copyright © 2010-2023 Lance Edgar # # This file is part of Rattail. # # Rattail is free software: you can redistribute it and/or modify it under the # terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # Rattail is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # Rattail. If not, see . # ################################################################################ """ Model Master View """ import io import os import csv import datetime import getpass import shutil import tempfile import logging from collections import OrderedDict import json import sqlalchemy as sa from sqlalchemy import orm import sqlalchemy_continuum as continuum from sqlalchemy_utils.functions import get_primary_keys, get_columns from rattail.db import model, Session as RattailSession from rattail.db.continuum import model_transaction_query from rattail.util import prettify, simple_error, get_class_hierarchy from rattail.time import localtime from rattail.threads import Thread from rattail.csvutil import UnicodeDictWriter from rattail.files import temp_path from rattail.excel import ExcelWriter from rattail.gpc import GPC import colander import deform from deform import widget as dfwidget from pyramid import httpexceptions from pyramid.renderers import get_renderer, render_to_response, render from pyramid.response import FileResponse from webhelpers2.html import HTML, tags from webob.compat import cgi_FieldStorage from tailbone import forms, grids, diffs from tailbone.views import View from tailbone.db import Session from tailbone.config import global_help_url log = logging.getLogger(__name__) class EverythingComplete(Exception): pass class MasterView(View): """ Base "master" view class. All model master views should derive from this. """ filterable = True pageable = True checkboxes = False # set to True to allow user to click "anywhere" in a row in order # to toggle its checkbox clicking_row_checks_box = False # set to True in order to encode search values as utf-8 use_byte_string_filters = False # set to True if all timestamps are "local" instead of UTC has_local_times = False listable = True sortable = True results_downloadable = False results_downloadable_csv = False results_downloadable_xlsx = False results_rows_downloadable = False creatable = True show_create_link = True viewable = True editable = True deletable = True delete_requires_progress = False delete_confirm = 'full' bulk_deletable = False set_deletable = False supports_autocomplete = False supports_set_enabled_toggle = False populatable = False mergeable = False merge_handler = None downloadable = False cloneable = False touchable = False executable = False execute_progress_template = None execute_progress_initial_msg = None execute_can_cancel = True supports_prev_next = False supports_import_batch_from_file = False has_input_file_templates = False configurable = False # set to True to add "View *global* Objects" permission, and # expose / leverage the ``local_only`` object flag secure_global_objects = False # quickie (search) supports_quickie_search = False # set to True to declare model as "contact" is_contact = False listing = False creating = False creates_multiple = False viewing = False editing = False deleting = False executing = False cloning = False has_pk_fields = False has_image = False has_thumbnail = False # can set this to true, and set type key as needed, and implement some # other things also, to get a DB picker in the header for all views supports_multiple_engines = False engine_type_key = 'rattail' SessionDefault = None SessionExtras = {} row_attrs = {} cell_attrs = {} grid_index = None use_index_links = False has_versions = False default_help_url = None help_url = None labels = {'uuid': "UUID"} customer_key_fields = {} member_key_fields = {} product_key_fields = {} # ROW-RELATED ATTRS FOLLOW: has_rows = False model_row_class = None rows_title = None rows_pageable = True rows_sortable = True rows_filterable = True rows_viewable = True rows_creatable = False rows_editable = False rows_editable_but_not_directly = False rows_deletable = False rows_deletable_speedbump = True rows_bulk_deletable = False rows_default_pagesize = None rows_downloadable_csv = False rows_downloadable_xlsx = False row_labels = { 'upc': "UPC", } @property def Session(self): """ Which session we return may depend on user's "current" engine. """ if self.supports_multiple_engines: dbkey = self.get_current_engine_dbkey() if dbkey != 'default' and dbkey in self.SessionExtras: return self.SessionExtras[dbkey] if self.SessionDefault: return self.SessionDefault from tailbone.db import Session return Session def make_isolated_session(self): """ This method should return a newly-created SQLAlchemy Session instance. The use case here is primarily for secondary threads, which may be employed for long-running processes such as executing a batch. The session returned should *not* have any web hooks to auto-commit with the request/response cycle etc. It should just be a plain old session, "isolated" from the rest of the web app in a sense. So whereas ``self.Session`` by default will return a reference to ``tailbone.db.Session``, which is a "scoped" session wrapper specific to the current thread (one per request), this method should instead return e.g. a new independent ``rattail.db.Session`` instance. """ return RattailSession() @classmethod def get_grid_factory(cls): """ Returns the grid factory or class which is to be used when creating new grid instances. """ return getattr(cls, 'grid_factory', grids.Grid) @classmethod def get_rows_title(cls): # nb. we do not provide a default value for this, since it # will not always make sense to show a row title return cls.rows_title @classmethod def get_row_grid_factory(cls): """ Returns the grid factory or class which is to be used when creating new row grid instances. """ return getattr(cls, 'row_grid_factory', grids.Grid) @classmethod def get_version_grid_factory(cls): """ Returns the grid factory or class which is to be used when creating new version grid instances. """ return getattr(cls, 'version_grid_factory', grids.Grid) def set_labels(self, obj): labels = self.collect_labels() for key, label in labels.items(): obj.set_label(key, label) def collect_labels(self): """ Collect all labels defined within the master class hierarchy. """ labels = {} for supp in self.iter_view_supplements(): labels.update(supp.labels) hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, 'labels'): labels.update(cls.labels) return labels def get_class_hierarchy(self): return get_class_hierarchy(self.__class__) def set_row_labels(self, obj): labels = self.collect_row_labels() for key, label in labels.items(): obj.set_label(key, label) def collect_row_labels(self): """ Collect all row labels defined within the master class hierarchy. """ labels = {} hierarchy = self.get_class_hierarchy() for cls in hierarchy: if hasattr(cls, 'row_labels'): labels.update(cls.row_labels) return labels def has_perm(self, name): """ Convenience function which returns boolean which should indicate whether the current user has been granted the named permission. Note that this method actually assembles the permission name, using the ``name`` provided, but also :meth:`get_permission_prefix()`. """ return self.request.has_perm('{}.{}'.format( self.get_permission_prefix(), name)) def has_any_perm(self, *names): for name in names: if self.has_perm(name): return True return False @classmethod def get_config_url(cls): if hasattr(cls, 'config_url'): return cls.config_url return '{}/configure'.format(cls.get_url_prefix()) ############################## # Available Views ############################## def index(self): """ View to list/filter/sort the model data. If this view receives a non-empty 'partial' parameter in the query string, then the view will return the rendered grid only. Otherwise returns the full page. """ self.listing = True grid = self.make_grid() # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. if self.request.GET.get('reset-to-default-filters') == 'true': return self.redirect(self.request.current_route_url(_query=None)) # Stash some grid stats, for possible use when generating URLs. if grid.pageable and hasattr(grid, 'pager'): self.first_visible_grid_index = grid.pager.first_item # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON return render_to_response('json', grid.get_buefy_data(), request=self.request) context = { 'grid': grid, } if self.results_downloadable and self.has_perm('download_results'): route_prefix = self.get_route_prefix() context['download_results_path'] = self.request.session.pop( '{}.results.generated'.format(route_prefix), None) available = self.download_results_fields_available() context['download_results_fields_available'] = available context['download_results_fields_default'] = self.download_results_fields_default(available) if self.has_rows and self.results_rows_downloadable and self.has_perm('download_results_rows'): route_prefix = self.get_route_prefix() context['download_results_rows_path'] = self.request.session.pop( '{}.results_rows.generated'.format(route_prefix), None) available = self.download_results_fields_available() context['download_results_rows_fields_available'] = available context['download_results_rows_fields_default'] = self.download_results_rows_fields_default(available) self.before_render_index() return self.render_to_response('index', context) def before_render_index(self): """ Perform any needed logic just prior to rendering the index response. Note that this logic is invoked only when rendering the main index page, but *not* invoked when refreshing partial grid contents etc. """ def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new grid instance """ if factory is None: factory = self.get_grid_factory() if key is None: key = self.get_grid_key() if data is None: data = self.get_data(session=kwargs.get('session')) if columns is None: columns = self.get_grid_columns() kwargs.setdefault('request', self.request) kwargs = self.make_grid_kwargs(**kwargs) grid = factory(key, data, columns, **kwargs) self.configure_grid(grid) grid.load_settings() return grid def get_effective_data(self, session=None, **kwargs): """ Convenience method which returns the "effective" data for the master grid, filtered and sorted to match what would show on the UI, but not paged etc. """ if session is None: session = self.Session() kwargs.setdefault('pageable', False) grid = self.make_grid(session=session, **kwargs) return grid.make_visible_data() def get_grid_columns(self): """ Returns the default list of grid column names. This may return ``None``, in which case the grid will generate its own default list. """ if hasattr(self, 'grid_columns'): return self.grid_columns def make_grid_kwargs(self, **kwargs): """ Return a dictionary of kwargs to be passed to the factory when creating new grid instances. """ checkboxes = kwargs.get('checkboxes', self.checkboxes) if not checkboxes and self.mergeable and self.has_perm('merge'): checkboxes = True if not checkboxes and self.supports_set_enabled_toggle and self.has_perm('enable_disable_set'): checkboxes = True if not checkboxes and self.set_deletable and self.has_perm('delete_set'): checkboxes = True defaults = { 'model_class': getattr(self, 'model_class', None), 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'width': 'full', 'filterable': self.filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.sortable, 'pageable': self.pageable, 'extra_row_class': self.grid_extra_class, 'url': lambda obj: self.get_action_url('view', obj), 'checkboxes': checkboxes, 'checked': self.checked, 'checkable': self.checkbox, 'clicking_row_checks_box': self.clicking_row_checks_box, 'assume_local_times': self.has_local_times, } if self.sortable or self.pageable or self.filterable: defaults['expose_direct_link'] = True if 'main_actions' not in kwargs and 'more_actions' not in kwargs: main, more = self.get_grid_actions() defaults['main_actions'] = main defaults['more_actions'] = more defaults.update(kwargs) return defaults def configure_grid(self, grid): """ Perform "final" configuration for the main data grid. """ self.set_labels(grid) # hide "local only" grid filter, unless global access allowed if self.secure_global_objects: if not self.has_perm('view_global'): grid.remove('local_only') grid.remove_filter('local_only') self.configure_column_customer_key(grid) self.configure_column_member_key(grid) self.configure_column_product_key(grid) for supp in self.iter_view_supplements(): supp.configure_grid(grid) def grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to the given object, or ``None``. """ def quickie(self): raise NotImplementedError def get_quickie_url(self): route_prefix = self.get_route_prefix() return self.request.route_url('{}.quickie'.format(route_prefix)) def get_quickie_perm(self): permission_prefix = self.get_permission_prefix() return '{}.quickie'.format(permission_prefix) def get_quickie_placeholder(self): pass def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Make and return a new (configured) rows grid instance. """ instance = kwargs.pop('instance', None) if not instance: instance = self.get_instance() if factory is None: factory = self.get_row_grid_factory() if key is None: key = self.get_row_grid_key() if data is None: data = self.get_row_data(instance) if columns is None: columns = self.get_row_grid_columns() kwargs.setdefault('request', self.request) kwargs = self.make_row_grid_kwargs(**kwargs) grid = factory(key, data, columns, **kwargs) self.configure_row_grid(grid) grid.load_settings() return grid def get_row_grid_columns(self): if hasattr(self, 'row_grid_columns'): return self.row_grid_columns def make_row_grid_kwargs(self, **kwargs): """ Return a dict of kwargs to be used when constructing a new rows grid. """ defaults = { 'model_class': self.model_row_class, 'width': 'full', 'filterable': self.rows_filterable, 'use_byte_string_filters': self.use_byte_string_filters, 'sortable': self.rows_sortable, 'pageable': self.rows_pageable, 'extra_row_class': self.row_grid_extra_class, 'url': lambda obj: self.get_row_action_url('view', obj), } if self.rows_default_pagesize: defaults['default_pagesize'] = self.rows_default_pagesize if self.has_rows and 'main_actions' not in defaults: actions = [] # view action if self.rows_viewable: actions.append(self.make_action('view', icon='eye', url=self.row_view_action_url)) # edit action if self.rows_editable and self.has_perm('edit_row'): actions.append(self.make_action('edit', icon='edit', url=self.row_edit_action_url)) # delete action if self.rows_deletable and self.has_perm('delete_row'): actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url)) defaults['delete_speedbump'] = self.rows_deletable_speedbump defaults['main_actions'] = actions defaults.update(kwargs) return defaults def configure_row_grid(self, grid): # super(MasterView, self).configure_row_grid(grid) self.set_row_labels(grid) self.configure_column_customer_key(grid) self.configure_column_member_key(grid) self.configure_column_product_key(grid) def row_grid_extra_class(self, obj, i): """ Returns string of extra class(es) for the table row corresponding to the given row object, or ``None``. """ def make_version_grid(self, factory=None, key=None, data=None, columns=None, **kwargs): """ Creates a new version grid instance """ instance = kwargs.pop('instance', None) if not instance: instance = self.get_instance() if factory is None: factory = self.get_version_grid_factory() if key is None: key = self.get_version_grid_key() if data is None: data = self.get_version_data(instance) if columns is None: columns = self.get_version_grid_columns() kwargs.setdefault('request', self.request) kwargs = self.make_version_grid_kwargs(**kwargs) grid = factory(key, data, columns, **kwargs) self.configure_version_grid(grid) grid.load_settings() return grid def get_version_grid_columns(self): if hasattr(self, 'version_grid_columns'): return self.version_grid_columns # TODO return [ 'issued_at', 'user', 'remote_addr', 'comment', ] def make_version_grid_kwargs(self, **kwargs): """ Return a dictionary of kwargs to be passed to the factory when constructing a new version grid. """ instance = kwargs.get('instance') or self.get_instance() route = '{}.version'.format(self.get_route_prefix()) defaults = { 'model_class': continuum.transaction_class(self.get_model_class()), 'width': 'full', 'pageable': True, 'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id), } if 'main_actions' not in kwargs: url = lambda txn, i: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id) defaults['main_actions'] = [ self.make_action('view', icon='eye', url=url), ] defaults.update(kwargs) return defaults def configure_version_grid(self, g): g.set_sort_defaults('issued_at', 'desc') g.set_renderer('comment', self.render_version_comment) g.set_label('issued_at', "Changed") g.set_label('user', "Changed by") g.set_label('remote_addr', "IP Address") g.set_link('issued_at') g.set_link('user') g.set_link('comment') def render_version_comment(self, transaction, column): return transaction.meta.get('comment', "") def create(self, form=None, template='create'): """ View for creating a new model record. """ self.creating = True if form is None: form = self.make_create_form() if self.request.method == 'POST': if self.validate_form(form): # let save_create_form() return alternate object if necessary obj = self.save_create_form(form) self.after_create(obj) self.flash_after_create(obj) return self.redirect_after_create(obj) context = {'form': form} if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() return self.render_to_response(template, context) def make_create_form(self): return self.make_form() def save_create_form(self, form): uploads = self.normalize_uploads(form) self.before_create(form) with self.Session().no_autoflush: obj = self.objectify(form, self.form_deserialized) self.before_create_flush(obj, form) self.Session.add(obj) self.Session.flush() self.process_uploads(obj, form, uploads) return obj def normalize_uploads(self, form, skip=None): uploads = {} def normalize(filedict): tempdir = tempfile.mkdtemp() filepath = os.path.join(tempdir, filedict['filename']) tmpinfo = form.deform_form[node.name].widget.tmpstore.get(filedict['uid']) tmpdata = tmpinfo['fp'].read() with open(filepath, 'wb') as f: f.write(tmpdata) return {'tempdir': tempdir, 'temp_path': filepath} for node in form.schema: if skip and node.name in skip: continue value = form.validated.get(node.name) if not value: continue if isinstance(value, dfwidget.filedict): uploads[node.name] = normalize(value) elif not isinstance(value, dict): try: values = iter(value) except TypeError: pass else: for value in values: if isinstance(value, dfwidget.filedict): uploads.setdefault(node.name, []).append( normalize(value)) return uploads def process_uploads(self, obj, form, uploads): pass def import_batch_from_file(self, handler_factory, model_name, delete=False, schema=None, importer_host_title=None): handler = handler_factory(self.rattail_config) if not schema: schema = forms.SimpleFileImport().bind(request=self.request) form = forms.Form(schema=schema, request=self.request) form.save_label = "Upload" form.cancel_url = self.get_index_url() if form.validate(): uploads = self.normalize_uploads(form) filepath = uploads['filename']['temp_path'] batches = handler.make_batches(model_name, delete=delete, # tdc_input_path=filepath, # source_csv_path=filepath, source_data_path=filepath, runas_user=self.request.user) batch = batches[0] return self.redirect(self.request.route_url('batch.importer.view', uuid=batch.uuid)) if not importer_host_title: importer_host_title = handler.host_title return self.render_to_response('import_file', { 'form': form, 'dform': form.make_deform_form(), 'importer_host_title': importer_host_title, }) def render_truncated_value(self, obj, field): """ Simple renderer which truncates the (string) value to 100 chars. """ value = getattr(obj, field) if value is None: return "" value = str(value) if len(value) > 100: value = value[:100] + '...' return value def render_id_str(self, obj, field): """ Render the ``id_str`` attribute value for the given object. """ return obj.id_str def render_as_is(self, obj, field): return getattr(obj, field) def render_url(self, obj, field): url = getattr(obj, field) if url: return tags.link_to(url, url, target='_blank') def render_html(self, obj, field): html = getattr(obj, field) if html: return HTML.literal(html) def render_default_phone(self, obj, field): """ Render the "default" (first) phone number for the given contact. """ if obj.phones: return obj.phones[0].number def render_default_email(self, obj, field): """ Render the "default" (first) email address for the given contact. """ if obj.emails: return obj.emails[0].address # TODO: deprecate / remove this def render_product_key_value(self, obj, field=None): """ Render the "canonical" product key value for the given object. nb. the ``field`` kwarg is ignored if present """ product_key = self.rattail_config.product_key() if product_key == 'upc': return obj.upc.pretty() if obj.upc else '' return getattr(obj, product_key) def render_upc(self, obj, field): """ Render a :class:`~rattail:rattail.gpc.GPC` field. """ value = getattr(obj, field) if value: app = self.rattail_config.get_app() return app.render_gpc(value) def render_store(self, obj, field): store = getattr(obj, field) if store: text = "({}) {}".format(store.id, store.name) url = self.request.route_url('stores.view', uuid=store.uuid) return tags.link_to(text, url) def render_product(self, obj, field): product = getattr(obj, field) if not product: return "" text = str(product) url = self.request.route_url('products.view', uuid=product.uuid) return tags.link_to(text, url) def render_pending_product(self, obj, field): pending = getattr(obj, field) if not pending: return text = str(pending) url = self.request.route_url('pending_products.view', uuid=pending.uuid) return tags.link_to(text, url, class_='has-background-warning') def render_vendor(self, obj, field): vendor = getattr(obj, field) if not vendor: return "" short = vendor.id or vendor.abbreviation if short: text = "({}) {}".format(short, vendor.name) else: text = str(vendor) url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) def valid_vendor_uuid(self, node, value): if value: model = self.model vendor = self.Session.get(model.Vendor, value) if not vendor: node.raise_invalid("Vendor not found") def render_department(self, obj, field): department = getattr(obj, field) if not department: return "" text = "({}) {}".format(department.number, department.name) url = self.request.route_url('departments.view', uuid=department.uuid) return tags.link_to(text, url) def render_subdepartment(self, obj, field): subdepartment = getattr(obj, field) if not subdepartment: return "" text = "({}) {}".format(subdepartment.number, subdepartment.name) url = self.request.route_url('subdepartments.view', uuid=subdepartment.uuid) return tags.link_to(text, url) def render_brand(self, obj, field): brand = getattr(obj, field) if not brand: return text = brand.name url = self.request.route_url('brands.view', uuid=brand.uuid) return tags.link_to(text, url) def render_category(self, obj, field): category = getattr(obj, field) if not category: return "" text = "({}) {}".format(category.code, category.name) url = self.request.route_url('categories.view', uuid=category.uuid) return tags.link_to(text, url) def render_family(self, obj, field): family = getattr(obj, field) if not family: return "" text = "({}) {}".format(family.code, family.name) url = self.request.route_url('families.view', uuid=family.uuid) return tags.link_to(text, url) def render_report(self, obj, field): report = getattr(obj, field) if not report: return "" text = "({}) {}".format(report.code, report.name) url = self.request.route_url('reportcodes.view', uuid=report.uuid) return tags.link_to(text, url) def render_person(self, obj, field): person = getattr(obj, field) if not person: return "" text = str(person) url = self.request.route_url('people.view', uuid=person.uuid) return tags.link_to(text, url) def render_person_profile(self, obj, field): person = getattr(obj, field) if not person: return "" text = str(person) url = self.request.route_url('people.view_profile', uuid=person.uuid) return tags.link_to(text, url) def render_user(self, obj, field): user = getattr(obj, field) if not user: return "" text = str(user) url = self.request.route_url('users.view', uuid=user.uuid) return tags.link_to(text, url) def render_users(self, obj, field): users = obj.users if not users: return "" items = [] for user in users: text = user.username url = self.request.route_url('users.view', uuid=user.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) return HTML.tag('ul', c=items) def render_customer(self, obj, field): customer = getattr(obj, field) if not customer: return "" text = str(customer) url = self.request.route_url('customers.view', uuid=customer.uuid) return tags.link_to(text, url) def render_email_key(self, obj, field): if hasattr(obj, field): email_key = getattr(obj, field) else: email_key = obj[field] if not email_key: return if self.request.has_perm('emailprofiles.view'): url = self.request.route_url('emailprofiles.view', key=email_key) return tags.link_to(email_key, url) return email_key def make_status_renderer(self, enum): """ Creates and returns a function for use with rendering a "status combo" field(s) for a record. Assumes the record has both ``status_code`` and ``status_text`` fields, as batches do. Renders the simple status code text, and if custom status text is present, it is rendered as a tooltip. """ def render_status(obj, field): value = obj.status_code if value is None: return "" status_code_text = enum.get(value, str(value)) if obj.status_text: return HTML.tag('span', title=obj.status_text, c=status_code_text) return status_code_text return render_status def before_create_flush(self, obj, form): pass def flash_after_create(self, obj): self.request.session.flash("{} has been created: {}".format( self.get_model_title(), self.get_instance_title(obj))) def redirect_after_create(self, instance, **kwargs): if self.populatable and self.should_populate(instance): return self.redirect(self.get_action_url('populate', instance)) return self.redirect(self.get_action_url('view', instance)) def should_populate(self, obj): return True def populate(self): """ View for populating a new object. What exactly this means / does will depend on the logic in :meth:`populate_object()`. """ obj = self.get_instance() route_prefix = self.get_route_prefix() permission_prefix = self.get_permission_prefix() # showing progress requires a separate thread; start that first key = '{}.populate'.format(route_prefix) progress = self.make_progress(key) thread = Thread(target=self.populate_thread, args=(obj.uuid, progress)) # TODO: uuid? thread.start() # Send user to progress page. kwargs = { 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} population was canceled.".format(self.get_model_title()), } return self.render_progress(progress, kwargs) def populate_thread(self, uuid, progress): # TODO: uuid? """ Thread target for populating new object with progress indicator. """ # mustn't use tailbone web session here session = RattailSession() obj = session.get(self.model_class, uuid) try: self.populate_object(session, obj, progress=progress) except Exception as error: session.rollback() msg = "{} population failed".format(self.get_model_title()) log.warning("{}: {}".format(msg, obj), exc_info=True) session.close() if progress: progress.session.load() progress.session['error'] = True progress.session['error_msg'] = "{}: {}".format( msg, simple_error(error)) progress.session.save() return session.commit() session.refresh(obj) session.close() # finalize progress if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = self.get_action_url('view', obj) progress.session.save() def populate_object(self, session, obj, progress=None): """ You must define this if new objects require population. """ raise NotImplementedError def view(self, instance=None): """ View for viewing details of an existing model record. """ self.viewing = True if instance is None: instance = self.get_instance() form = self.make_form(instance) if self.has_rows: # must make grid prior to redirecting from filter reset, b/c the # grid will detect the filter reset request and store defaults in # the session, that way redirect will then show The Right Thing grid = self.make_row_grid(instance=instance) # If user just refreshed the page with a reset instruction, issue a # redirect in order to clear out the query string. if self.request.GET.get('reset-to-default-filters') == 'true': return self.redirect(self.request.current_route_url(_query=None)) # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON return render_to_response('json', grid.get_buefy_data(), request=self.request) context = { 'instance': instance, 'instance_title': self.get_instance_title(instance), 'instance_editable': self.editable_instance(instance), 'instance_deletable': self.deletable_instance(instance), 'form': form, } if self.executable: context['instance_executable'] = self.executable_instance(instance) if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() if self.has_rows: context['rows_grid'] = grid context['rows_grid_tools'] = HTML(self.make_row_grid_tools(instance) or '').strip() return self.render_to_response('view', context) def image(self): """ View which renders the object's image as a response. """ obj = self.get_instance() image_bytes = self.get_image_bytes(obj) if not image_bytes: raise self.notfound() # TODO: how to properly detect image type? self.request.response.content_type = str('image/jpeg') self.request.response.body = image_bytes return self.request.response def get_image_bytes(self, obj): raise NotImplementedError def thumbnail(self): """ View which renders the object's thumbnail image as a response. """ obj = self.get_instance() image_bytes = self.get_thumbnail_bytes(obj) if not image_bytes: raise self.notfound() # TODO: how to properly detect image type? self.request.response.content_type = str('image/jpeg') self.request.response.body = image_bytes return self.request.response def get_thumbnail_bytes(self, obj): raise NotImplementedError def clone(self): """ View for cloning an object's data into a new object. """ self.viewing = True self.cloning = True instance = self.get_instance() form = self.make_form(instance) self.configure_clone_form(form) if self.request.method == 'POST' and self.request.POST.get('clone') == 'clone': cloned = self.clone_instance(instance) self.request.session.flash("{} has been cloned: {}".format( self.get_model_title(), self.get_instance_title(instance))) self.request.session.flash("(NOTE, you are now viewing the clone!)") return self.redirect_after_clone(cloned) return self.render_to_response('clone', { 'instance': instance, 'instance_title': self.get_instance_title(instance), 'instance_url': self.get_action_url('view', instance), 'form': form, }) def configure_clone_form(self, form): pass def clone_instance(self, instance): """ This method should create and return a *new* instance, which has been "cloned" from the given instance. Default behavior assumes a typical SQLAlchemy record instance, and the new one has all "column" values copied *except* for the ``'uuid'`` column. """ cloned = self.model_class() for column in get_columns(instance): if column.name != 'uuid': setattr(cloned, column.name, getattr(instance, column.name)) self.Session.add(cloned) self.Session.flush() return cloned def redirect_after_clone(self, instance, **kwargs): return self.redirect(self.get_action_url('view', instance)) def touch(self): """ View for "touching" an object so as to trigger datasync logic for it. Useful instead of actually "editing" the object, which is generally the alternative. """ obj = self.get_instance() self.touch_instance(obj) self.request.session.flash("{} has been touched: {}".format( self.get_model_title(), self.get_instance_title(obj))) return self.redirect(self.get_action_url('view', obj)) def touch_instance(self, obj): """ Perform actual "touch" logic for the given object. """ app = self.get_rattail_app() app.touch_object(self.Session(), obj) def versions(self): """ View to list version history for an object. """ instance = self.get_instance() instance_title = self.get_instance_title(instance) grid = self.make_version_grid(instance=instance) # return grid only, if partial page was requested if self.request.params.get('partial'): # render grid data only, as JSON return render_to_response('json', grid.get_buefy_data(), request=self.request) return self.render_to_response('versions', { 'instance': instance, 'instance_title': instance_title, 'instance_url': self.get_action_url('view', instance), 'grid': grid, }) @classmethod def get_version_grid_key(cls): """ Returns the unique key to be used for the version grid, for caching sort/filter options etc. """ if hasattr(cls, 'version_grid_key'): return cls.version_grid_key return '{}.history'.format(cls.get_route_prefix()) def get_version_data(self, instance): """ Generate the base data set for the version grid. """ model_class = self.get_model_class() transaction_class = continuum.transaction_class(model_class) query = model_transaction_query(self.Session(), instance, model_class, child_classes=self.normalize_version_child_classes()) return query.order_by(transaction_class.issued_at.desc()) def get_version_child_classes(self): """ If applicable, should return a list of child classes which should be considered when querying for version history of an object. """ classes = [] for supp in self.iter_view_supplements(): classes.extend(supp.get_version_child_classes()) return classes def normalize_version_child_classes(self): classes = [] for cls in self.get_version_child_classes(): if not isinstance(cls, tuple): cls = (cls, 'uuid', 'uuid') elif len(cls) == 2: cls = tuple([cls[0], cls[1], 'uuid']) classes.append(cls) return classes def view_version(self): """ View showing diff details of a particular object version. """ instance = self.get_instance() model_class = self.get_model_class() route_prefix = self.get_route_prefix() Transaction = continuum.transaction_class(model_class) transactions = model_transaction_query(self.Session(), instance, model_class, child_classes=self.normalize_version_child_classes()) transaction_id = self.request.matchdict['txnid'] transaction = transactions.filter(Transaction.id == transaction_id).first() if not transaction: return self.notfound() older = transactions.filter(Transaction.issued_at <= transaction.issued_at)\ .filter(Transaction.id != transaction_id)\ .order_by(Transaction.issued_at.desc())\ .first() newer = transactions.filter(Transaction.issued_at >= transaction.issued_at)\ .filter(Transaction.id != transaction_id)\ .order_by(Transaction.issued_at)\ .first() instance_title = self.get_instance_title(instance) prev_url = next_url = None if older: prev_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=older.id) if newer: next_url = self.request.route_url('{}.version'.format(route_prefix), uuid=instance.uuid, txnid=newer.id) return self.render_to_response('view_version', { 'instance': instance, 'instance_title': "{} (history)".format(instance_title), 'instance_title_normal': instance_title, 'instance_url': self.get_action_url('versions', instance), 'transaction': transaction, 'changed': localtime(self.rattail_config, transaction.issued_at, from_utc=True), 'versions': self.get_relevant_versions(transaction, instance), 'show_prev_next': True, 'prev_url': prev_url, 'next_url': next_url, 'previous_transaction': older, 'next_transaction': newer, 'title_for_version': self.title_for_version, 'fields_for_version': self.fields_for_version, 'continuum': continuum, 'render_old_value': self.render_version_old_field_value, 'render_new_value': self.render_version_new_field_value, }) def title_for_version(self, version): cls = continuum.parent_class(version.__class__) return cls.get_model_title() def fields_for_version(self, version): mapper = orm.class_mapper(version.__class__) fields = sorted(mapper.columns.keys()) fields.remove('transaction_id') fields.remove('end_transaction_id') fields.remove('operation_type') return fields def get_relevant_versions(self, transaction, instance): versions = [] version_cls = self.get_model_version_class() query = self.Session.query(version_cls)\ .filter(version_cls.transaction == transaction)\ .filter(version_cls.uuid == instance.uuid) versions.extend(query.all()) for cls, foreign_attr, primary_attr in self.normalize_version_child_classes(): version_cls = continuum.version_class(cls) query = self.Session.query(version_cls)\ .filter(version_cls.transaction == transaction)\ .filter(getattr(version_cls, foreign_attr) == getattr(instance, primary_attr)) versions.extend(query.all()) return versions def render_version_old_field_value(self, version, field): return repr(getattr(version.previous, field)) def render_version_new_field_value(self, version, field, typ): return repr(getattr(version, field)) def configure_common_form(self, form): """ Configure the form in whatever way is deemed "common" - i.e. where configuration should be done the same for desktop and mobile. By default this removes the 'uuid' field (if present), sets any primary key fields to be readonly (if we have a :attr:`model_class` and are in edit mode), and sets labels as defined by the master class hierarchy. TODO: this logic should be moved back into configure_form() """ form.remove_field('uuid') if self.editing: model_class = self.get_model_class(error=False) if model_class: # set readonly for all primary key fields mapper = orm.class_mapper(model_class) for key in mapper.primary_key: for field in form.fields: if field == key.name: form.set_readonly(field) break self.set_labels(form) # hide "local only" field, unless global access allowed if self.secure_global_objects: if not self.has_perm('view_global'): form.remove_field('local_only') elif self.creating: # assume this flag should be ON by default - it is hoped this # is the safer option and would help prevent unwanted mistakes form.set_default('local_only', True) def make_quick_row_form(self, instance=None, factory=None, fields=None, schema=None, **kwargs): """ Creates a "quick" form for adding a new row to the given instance. """ if factory is None: factory = self.get_quick_row_form_factory() if fields is None: fields = self.get_quick_row_form_fields() if schema is None: schema = self.make_quick_row_form_schema() kwargs = self.make_quick_row_form_kwargs(**kwargs) form = factory(fields, schema, **kwargs) self.configure_quick_row_form(form) return form def get_quick_row_form_factory(self, **kwargs): return forms.Form def get_quick_row_form_fields(self, **kwargs): pass def make_quick_row_form_schema(self, **kwargs): schema = colander.MappingSchema() schema.add(colander.SchemaNode(colander.String(), name='quick_entry')) return schema def make_quick_row_form_kwargs(self, **kwargs): defaults = { 'request': self.request, 'model_class': getattr(self, 'model_row_class', None), 'cancel_url': self.request.get_referrer(), } defaults.update(kwargs) return defaults def configure_quick_row_form(self, form, **kwargs): pass def validate_quick_row_form(self, form): return form.validate() def make_default_row_grid_tools(self, obj): if self.rows_creatable: link = tags.link_to("Create a new {}".format(self.get_row_model_title()), self.get_action_url('create_row', obj)) return HTML.tag('p', c=[link]) def make_row_grid_tools(self, obj): return self.make_default_row_grid_tools(obj) # TODO: depracate / remove this def get_effective_row_query(self): """ Convenience method which returns the "effective" query for the master grid, filtered and sorted to match what would show on the UI, but not paged etc. """ return self.get_effective_row_data(sort=False) def get_row_data(self, instance): """ Generate the base data set for a rows grid. """ raise NotImplementedError def get_effective_row_data(self, session=None, sort=False, **kwargs): """ Convenience method which returns the "effective" data for the row grid, filtered (and optionally sorted) to match what would show on the UI, but not paged. """ if session is None: session = self.Session() kwargs.setdefault('pageable', False) kwargs.setdefault('sortable', sort) grid = self.make_row_grid(session=session, **kwargs) return grid.make_visible_data() @classmethod def get_row_url_prefix(cls): """ Returns a prefix which (by default) applies to all URLs provided by the master view class, for "row" views, e.g. '/products/rows'. """ return getattr(cls, 'row_url_prefix', '{}/rows'.format(cls.get_url_prefix())) @classmethod def get_row_permission_prefix(cls): """ Permission prefix specific to the row-level data for this batch type, e.g. ``'vendorcatalogs.rows'``. """ return "{}.rows".format(cls.get_permission_prefix()) def row_editable(self, row): """ Returns boolean indicating whether or not the given row can be considered "editable". Returns ``True`` by default; override as necessary. """ return True def row_view_action_url(self, row, i): return self.get_row_action_url('view', row) def row_edit_action_url(self, row, i): if self.row_editable(row): return self.get_row_action_url('edit', row) def row_delete_action_url(self, row, i): if self.row_deletable(row): return self.get_row_action_url('delete', row) def row_grid_row_attrs(self, row, i): return {} @classmethod def get_row_model_title(cls): if hasattr(cls, 'row_model_title'): return cls.row_model_title return "{} Row".format(cls.get_model_title()) @classmethod def get_row_model_title_plural(cls): if hasattr(cls, 'row_model_title_plural'): return cls.row_model_title_plural return "{} Rows".format(cls.get_model_title()) def view_index(self): """ View a record according to its grid index. """ try: index = int(self.request.GET['index']) except (KeyError, ValueError): return self.redirect(self.get_index_url()) if index < 1: return self.redirect(self.get_index_url()) data = self.get_effective_data() try: instance = data[index-1] except IndexError: return self.redirect(self.get_index_url()) self.grid_index = index if hasattr(data, 'count'): self.grid_count = data.count() else: self.grid_count = len(data) return self.view(instance) def download(self): """ View for downloading a data file. """ obj = self.get_instance() filename = self.request.GET.get('filename', None) if not filename: raise self.notfound() path = self.download_path(obj, filename) response = FileResponse(path, request=self.request) response.content_length = os.path.getsize(path) content_type = self.download_content_type(path, filename) if content_type: response.content_type = content_type # content-disposition filename = os.path.basename(path) response.content_disposition = str('attachment; filename="{}"'.format(filename)) return response def download_content_type(self, path, filename): """ Return a content type for a file download, if known. """ def download_input_file_template(self): """ View for downloading an input file template. """ key = self.request.GET['key'] filespec = self.request.GET['file'] matches = [tmpl for tmpl in self.get_input_file_templates() if tmpl['key'] == key] if not matches: raise self.notfound() template = matches[0] templatesdir = os.path.join(self.rattail_config.datadir(), 'templates', 'input_files', self.get_route_prefix()) basedir = os.path.join(templatesdir, template['key']) path = os.path.join(basedir, filespec) return self.file_response(path) def edit(self): """ View for editing an existing model record. """ self.editing = True instance = self.get_instance() instance_title = self.get_instance_title(instance) if not self.editable_instance(instance): self.request.session.flash("Edit is not permitted for {}: {}".format( self.get_model_title(), instance_title), 'error') return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) if self.request.method == 'POST': if self.validate_form(form): self.save_edit_form(form) # note we must fetch new instance title, in case it changed self.request.session.flash("{} has been updated: {}".format( self.get_model_title(), self.get_instance_title(instance))) return self.redirect_after_edit(instance) context = { 'instance': instance, 'instance_title': instance_title, 'instance_deletable': self.deletable_instance(instance), 'form': form, } if hasattr(form, 'make_deform_form'): context['dform'] = form.make_deform_form() return self.render_to_response('edit', context) def save_edit_form(self, form): uploads = self.normalize_uploads(form) obj = self.objectify(form) self.process_uploads(obj, form, uploads) self.after_edit(obj) self.Session.flush() return obj def redirect_after_edit(self, instance, **kwargs): return self.redirect(self.get_action_url('view', instance)) def delete(self): """ View for deleting an existing model record. """ if not self.deletable: raise httpexceptions.HTTPForbidden() self.deleting = True instance = self.get_instance() instance_title = self.get_instance_title(instance) if not self.deletable_instance(instance): self.request.session.flash("Deletion is not permitted for {}: {}".format( self.get_model_title(), instance_title), 'error') return self.redirect(self.get_action_url('view', instance)) form = self.make_form(instance) # TODO: Add better validation, ideally CSRF etc. if self.request.method == 'POST': # Let derived classes prep for (or cancel) deletion. result = self.before_delete(instance) if isinstance(result, httpexceptions.HTTPException): return result if self.delete_requires_progress: return self.delete_instance_with_progress(instance) else: self.delete_instance(instance) self.request.session.flash("{} has been deleted: {}".format( self.get_model_title(), instance_title)) return self.redirect(self.get_after_delete_url(instance)) form.readonly = True return self.render_to_response('delete', { 'instance': instance, 'instance_title': instance_title, 'instance_editable': self.editable_instance(instance), 'instance_deletable': self.deletable_instance(instance), 'form': form}) def bulk_delete(self): """ Delete all records matching the current grid query """ objects = self.get_effective_data() key = '{}.bulk_delete'.format(self.model_class.__tablename__) progress = self.make_progress(key) thread = Thread(target=self.bulk_delete_thread, args=(objects, progress)) thread.start() return self.render_progress(progress, { 'cancel_url': self.get_index_url(), 'cancel_msg': "Bulk deletion was canceled", }) def bulk_delete_objects(self, session, objects, progress=None): def delete(obj, i): self.delete_instance(obj) if i % 1000 == 0: session.flush() self.progress_loop(delete, objects, progress, message="Deleting objects") def get_bulk_delete_session(self): return self.make_isolated_session() def bulk_delete_thread(self, objects, progress): """ Thread target for bulk-deleting current results, with progress. """ session = self.get_bulk_delete_session() objects = objects.with_session(session).all() try: self.bulk_delete_objects(session, objects, progress=progress) # If anything goes wrong, rollback and log the error etc. except Exception as error: session.rollback() log.exception("execution failed for batch results") session.close() if progress: progress.session.load() progress.session['error'] = True progress.session['error_msg'] = "Bulk deletion failed: {}".format( simple_error(error)) progress.session.save() # If no error, check result flag (false means user canceled). else: session.commit() session.close() if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = self.get_index_url() progress.session.save() def obtain_set(self): """ Obtain the effective "set" (selection) of records from POST data. """ # TODO: should have a cleaner way to parse object uuids? uuids = self.request.POST.get('uuids') if uuids: uuids = uuids.split(',') # TODO: probably need to allow override of fetcher callable fetcher = lambda uuid: self.Session.get(self.model_class, uuid) objects = [] for uuid in uuids: obj = fetcher(uuid) if obj: objects.append(obj) return objects def enable_set(self): """ View which can turn ON the 'enabled' flag for a specific set of records. """ objects = self.obtain_set() if objects: enabled = 0 for obj in objects: if not obj.enabled: obj.enabled = True enabled += 1 model_title_plural = self.get_model_title_plural() self.request.session.flash("Enabled {} {}".format(enabled, model_title_plural)) return self.redirect(self.get_index_url()) def disable_set(self): """ View which can turn OFF the 'enabled' flag for a specific set of records. """ objects = self.obtain_set() if objects: disabled = 0 for obj in objects: if obj.enabled: obj.enabled = False disabled += 1 model_title_plural = self.get_model_title_plural() self.request.session.flash("Disabled {} {}".format(disabled, model_title_plural)) return self.redirect(self.get_index_url()) def delete_set(self): """ View which can delete a specific set of records. """ objects = self.obtain_set() if objects: for obj in objects: self.delete_instance(obj) model_title_plural = self.get_model_title_plural() self.request.session.flash("Deleted {} {}".format(len(objects), model_title_plural)) return self.redirect(self.get_index_url()) def oneoff_import(self, importer, host_object=None): """ Basic helper method, to do a one-off import (or export, depending on perspective) of the "current instance" object. Where the data "goes" depends on the importer you provide. """ if not host_object: host_object = self.get_instance() host_data = importer.normalize_host_object(host_object) if not host_data: return key = importer.get_key(host_data) local_object = importer.get_local_object(key) if local_object: if importer.allow_update: local_data = importer.normalize_local_object(local_object) if importer.data_diffs(local_data, host_data) and importer.allow_update: local_object = importer.update_object(local_object, host_data, local_data) return local_object elif importer.allow_create: return importer.create_object(key, host_data) def executable_instance(self, instance): """ Returns boolean indicating whether or not the given instance can be considered "executable". Returns ``True`` by default; override as necessary. """ return True def execute(self): """ Execute an object. """ obj = self.get_instance() model_title = self.get_model_title() # caller must explicitly request websocket behavior; otherwise # we will assume traditional behavior for progress ws = self.request.is_xhr and self.request.json_body.get('ws') # make our progress tracker progress = self.make_execute_progress(obj, ws=ws) # start execution in a separate thread kwargs = {'progress': progress} key = [self.request.matchdict[k] for k in self.get_model_key(as_tuple=True)] thread = Thread(target=self.execute_thread, args=(key, self.request.user.uuid), kwargs=kwargs) thread.start() # we're done here if using websockets if ws: return self.json_response({'ok': True}) # traditional behavior sends user to dedicated progress page return self.render_progress(progress, { 'instance': obj, 'initial_msg': self.execute_progress_initial_msg, 'can_cancel': self.execute_can_cancel, 'cancel_url': self.get_action_url('view', obj), 'cancel_msg': "{} execution was canceled".format(model_title), }, template=self.execute_progress_template) def make_execute_progress(self, obj, ws=False): if ws: key = '{}.{}.execution_progress'.format(self.get_route_prefix(), obj.uuid) else: key = '{}.execute'.format(self.get_grid_key()) return self.make_progress(key, ws=ws) def get_instance_for_key(self, key, session): model_key = self.get_model_key(as_tuple=True) if len(model_key) == 1 and model_key[0] == 'uuid': uuid = key[0] return session.get(self.model_class, uuid) raise NotImplementedError def execute_thread(self, key, user_uuid, progress=None, **kwargs): """ Thread target for executing an object. """ session = RattailSession() obj = self.get_instance_for_key(key, session) user = session.get(model.User, user_uuid) try: success_msg = self.execute_instance(obj, user, progress=progress, **kwargs) # If anything goes wrong, rollback and log the error etc. except Exception as error: session.rollback() log.exception("{} failed to execute: {}".format(self.get_model_title(), obj)) session.close() if progress: progress.session.load() progress.session['error'] = True progress.session['error_msg'] = self.execute_error_message(error) progress.session.save() # If no error, check result flag (false means user canceled). else: session.commit() try: needs_refresh = obj in session except: pass else: if needs_refresh: session.refresh(obj) success_url = self.get_execute_success_url(obj) session.close() if progress: progress.session.load() progress.session['complete'] = True progress.session['success_url'] = success_url if success_msg: progress.session['success_msg'] = success_msg progress.session.save() def execute_error_message(self, error): return "Execution of {} failed: {}".format(self.get_model_title(), simple_error(error)) def get_execute_success_url(self, obj, **kwargs): return self.get_action_url('view', obj, **kwargs) def progress_thread(self, sock, success_url, progress): """ This method is meant to be used as a thread target. Its job is to read progress data from ``connection`` and update the session progress accordingly. When a final "process complete" indication is read, the socket will be closed and the thread will end. """ while True: try: self.process_progress(sock, progress) except EverythingComplete: break # close server socket sock.close() # finalize session progress progress.session.load() progress.session['complete'] = True if callable(success_url): success_url = success_url() progress.session['success_url'] = success_url progress.session.save() def process_progress(self, sock, progress): """ This method will accept a client connection on the given socket, and then update the given progress object according to data written by the client. """ connection, client_address = sock.accept() active_progress = None # TODO: make this configurable? suffix = "\n\n.".encode('utf_8') data = b'' # listen for progress info, update session progress as needed while True: # accumulate data bytestring until we see the suffix byte = connection.recv(1) data += byte if data.endswith(suffix): # strip suffix, interpret data as JSON data = data[:-len(suffix)] data = data.decode('utf_8') data = json.loads(data) if data.get('everything_complete'): if active_progress: active_progress.finish() raise EverythingComplete elif data.get('process_complete'): active_progress.finish() active_progress = None break elif 'value' in data: if not active_progress: active_progress = progress(data['message'], data['maximum']) active_progress.update(data['value']) # reset data buffer data = b'' # close client connection connection.close() def get_merge_fields(self): if hasattr(self, 'merge_fields'): return self.merge_fields if self.merge_handler: fields = self.merge_handler.get_merge_preview_fields() return [field['name'] for field in fields] mapper = orm.class_mapper(self.get_model_class()) return mapper.columns.keys() def get_merge_coalesce_fields(self): if hasattr(self, 'merge_coalesce_fields'): return self.merge_coalesce_fields if self.merge_handler: fields = self.merge_handler.get_merge_preview_fields() return [field['name'] for field in fields if field.get('coalesce')] return [] def get_merge_additive_fields(self): if hasattr(self, 'merge_additive_fields'): return self.merge_additive_fields if self.merge_handler: fields = self.merge_handler.get_merge_preview_fields() return [field['name'] for field in fields if field.get('additive')] return [] def merge(self): """ Preview and execute a merge of two records. """ object_to_remove = object_to_keep = None if self.request.method == 'POST': uuids = self.request.POST.get('uuids', '').split(',') if len(uuids) == 2: object_to_remove = self.Session.get(self.get_model_class(), uuids[0]) object_to_keep = self.Session.get(self.get_model_class(), uuids[1]) if object_to_remove and object_to_keep and self.request.POST.get('commit-merge') == 'yes': msg = str(object_to_remove) try: self.validate_merge(object_to_remove, object_to_keep) except Exception as error: self.request.session.flash("Requested merge cannot proceed (maybe swap kept/removed and try again?): {}".format(error), 'error') else: self.merge_objects(object_to_remove, object_to_keep) self.request.session.flash("{} has been merged into {}".format(msg, object_to_keep)) return self.redirect(self.get_action_url('view', object_to_keep)) if not object_to_remove or not object_to_keep or object_to_remove is object_to_keep: return self.redirect(self.get_index_url()) remove = self.get_merge_data(object_to_remove) keep = self.get_merge_data(object_to_keep) return self.render_to_response('merge', {'object_to_remove': object_to_remove, 'object_to_keep': object_to_keep, 'view_url': lambda obj: self.get_action_url('view', obj), 'merge_fields': self.get_merge_fields(), 'remove_data': remove, 'keep_data': keep, 'resulting_data': self.get_merge_resulting_data(remove, keep)}) def validate_merge(self, removing, keeping): """ If applicable, your view should override this in order to confirm that the requested merge is valid, in your context. If it is not - for *any reason* - you should raise an exception; the type does not matter. """ if self.merge_handler: reason = self.merge_handler.why_not_merge(removing, keeping) if reason: raise Exception(reason) def get_merge_data(self, obj): if self.merge_handler: return self.merge_handler.get_merge_preview_data(obj) return dict([(f, getattr(obj, f)) for f in self.get_merge_fields()]) def get_merge_resulting_data(self, remove, keep): result = dict(keep) for field in self.get_merge_coalesce_fields(): if remove[field] is not None and keep[field] is None: result[field] = remove[field] elif remove[field] and not keep[field]: result[field] = remove[field] for field in self.get_merge_additive_fields(): if isinstance(keep[field], (list, tuple)): result[field] = sorted(set(remove[field] + keep[field])) else: result[field] = remove[field] + keep[field] return result def merge_objects(self, removing, keeping): """ Merge the two given objects. You should probably override this; default behavior is merely to delete the 'removing' object. """ if self.merge_handler: self.merge_handler.perform_merge(removing, keeping, user=self.request.user) else: # nb. default "merge" does not update kept object! self.Session.delete(removing) ############################## # Core Stuff ############################## @classmethod def get_model_class(cls, error=True): """ Returns the data model class for which the master view exists. """ if not hasattr(cls, 'model_class') and error: raise NotImplementedError("You must define the `model_class` for: {}".format(cls)) return getattr(cls, 'model_class', None) @classmethod def get_model_version_class(cls): """ Returns the version class for the master model class. """ return continuum.version_class(cls.get_model_class()) @classmethod def get_normalized_model_name(cls): """ Returns the "normalized" name for the view's model class. This will be the value of the :attr:`normalized_model_name` attribute if defined; otherwise it will be a simple lower-cased version of the associated model class name. """ if hasattr(cls, 'normalized_model_name'): return cls.normalized_model_name return cls.get_model_class().__name__.lower() @classmethod def get_model_key(cls, as_tuple=False): """ Returns the primary model key(s) for the master view. Internally, model keys are a sequence of one or more keys. Most typically it's just one, so e.g. ``('uuid',)``, but composite keys are possible too, e.g. ``('parent_id', 'child_id')``. Despite that, this method will return a *string* representation of the keys, unless ``as_tuple=True`` in which case it returns a tuple. For example:: # for model keys: ('uuid',) cls.get_model_key() # => 'uuid' cls.get_model_key(as_tuple=True) # => ('uuid',) # for model keys: ('parent_id', 'child_id') cls.get_model_key() # => 'parent_id,child_id' cls.get_model_key(as_tuple=True) # => ('parent_id', 'child_id') :param as_tuple: Whether to return a tuple instead of string. :returns: Either a string or tuple of model keys. """ if hasattr(cls, 'model_key'): keys = cls.model_key if isinstance(keys, str): keys = [keys] else: keys = get_primary_keys(cls.get_model_class()) if as_tuple: return tuple(keys) return ','.join(keys) @classmethod def get_model_title(cls): """ Return a "humanized" version of the model name, for display in templates. """ if hasattr(cls, 'model_title'): return cls.model_title # model class itself may provide title model_class = cls.get_model_class() if hasattr(model_class, 'get_model_title'): return model_class.get_model_title() # otherwise just use model class name return model_class.__name__ @classmethod def get_model_title_plural(cls): """ Return a "humanized" (and plural) version of the model name, for display in templates. """ if hasattr(cls, 'model_title_plural'): return cls.model_title_plural try: return cls.get_model_class().get_model_title_plural() except (NotImplementedError, AttributeError): return '{}s'.format(cls.get_model_title()) @classmethod def get_route_prefix(cls): """ Returns a prefix which (by default) applies to all routes provided by the master view class. This is the plural, lower-cased name of the model class by default, e.g. 'products'. """ if hasattr(cls, 'route_prefix'): return cls.route_prefix model_name = cls.get_normalized_model_name() return '{}s'.format(model_name) @classmethod def get_url_prefix(cls): """ Returns a prefix which (by default) applies to all URLs provided by the master view class. By default this is the route prefix, preceded by a slash, e.g. '/products'. """ return getattr(cls, 'url_prefix', '/{0}'.format(cls.get_route_prefix())) @classmethod def get_template_prefix(cls): """ Returns a prefix which (by default) applies to all templates required by the master view class. This uses the URL prefix by default. """ return getattr(cls, 'template_prefix', cls.get_url_prefix()) @classmethod def get_permission_prefix(cls): """ Returns a prefix which (by default) applies to all permissions leveraged by the master view class. This uses the route prefix by default. """ return getattr(cls, 'permission_prefix', cls.get_route_prefix()) def get_index_url(self, **kwargs): """ Returns the master view's index URL. """ if self.listable: route = self.get_route_prefix() return self.request.route_url(route, **kwargs) # TODO: this should not be class method, if possible # (pretty sure overriding as instance method works fine) @classmethod def get_index_title(cls): """ Returns the title for the index page. """ return getattr(cls, 'index_title', cls.get_model_title_plural()) @classmethod def get_config_title(cls): """ Returns the view's "config title". """ if hasattr(cls, 'config_title'): return cls.config_title return cls.get_model_title_plural() def get_action_url(self, action, instance, **kwargs): """ Generate a URL for the given action on the given instance """ kw = self.get_action_route_kwargs(instance) kw.update(kwargs) route_prefix = self.get_route_prefix() return self.request.route_url('{}.{}'.format(route_prefix, action), **kw) def get_help_url(self): """ May return a "help URL" if applicable. Default behavior is to simply return the value of :attr:`help_url` (regardless of which view is in effect), which in turn defaults to ``None``. If an actual URL is returned, then a Help button will be shown in the page header; otherwise it is not shown. This method is invoked whenever a template is rendered for a response, so if you like you can return a different help URL depending on which type of CRUD view is in effect, etc. """ model = self.model route_prefix = self.get_route_prefix() # nb. self.Session may differ, so use tailbone.db.Session info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.help_url: return info.help_url if self.help_url: return self.help_url if self.default_help_url: return self.default_help_url return global_help_url(self.rattail_config) def get_help_markdown(self): """ Return the markdown help text for current page, if defined. """ model = self.model route_prefix = self.get_route_prefix() # nb. self.Session may differ, so use tailbone.db.Session info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if info and info.markdown_text: return info.markdown_text def can_edit_help(self): if self.has_perm('edit_help'): return True if self.request.has_perm('common.edit_help'): return True return False def edit_help(self): if not self.can_edit_help(): raise self.forbidden() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() schema.add(colander.SchemaNode(colander.String(), name='help_url', missing=None)) schema.add(colander.SchemaNode(colander.String(), name='markdown_text', missing=None)) factory = self.get_form_factory() form = factory(schema=schema, request=self.request) if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session info = Session.query(model.TailbonePageHelp)\ .filter(model.TailbonePageHelp.route_prefix == route_prefix)\ .first() if not info: info = model.TailbonePageHelp(route_prefix=route_prefix) Session.add(info) info.help_url = form.validated['help_url'] info.markdown_text = form.validated['markdown_text'] return {'ok': True} def edit_field_help(self): if not self.can_edit_help(): raise self.forbidden() model = self.model route_prefix = self.get_route_prefix() schema = colander.Schema() schema.add(colander.SchemaNode(colander.String(), name='field_name')) schema.add(colander.SchemaNode(colander.String(), name='markdown_text', missing=None)) factory = self.get_form_factory() form = factory(schema=schema, request=self.request) if not form.validate(): return {'error': "Form did not validate"} # nb. self.Session may differ, so use tailbone.db.Session info = Session.query(model.TailboneFieldInfo)\ .filter(model.TailboneFieldInfo.route_prefix == route_prefix)\ .filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\ .first() if not info: info = model.TailboneFieldInfo(route_prefix=route_prefix, field_name=form.validated['field_name']) Session.add(info) info.markdown_text = form.validated['markdown_text'] return {'ok': True} def render_to_response(self, template, data, **kwargs): """ Return a response with the given template rendered with the given data. Note that ``template`` must only be a "key" (e.g. 'index' or 'view'). First an attempt will be made to render using the :attr:`template_prefix`. If that doesn't work, another attempt will be made using '/master' as the template prefix. """ context = { 'master': self, 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'route_prefix': self.get_route_prefix(), 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), 'config_title': self.get_config_title(), 'action_url': self.get_action_url, 'grid_index': self.grid_index, 'help_url': self.get_help_url(), 'help_markdown': self.get_help_markdown(), 'can_edit_help': self.can_edit_help(), 'quickie': None, } context['customer_key_field'] = self.get_customer_key_field() context['customer_key_label'] = self.get_customer_key_label() context['member_key_field'] = self.get_member_key_field() context['member_key_label'] = self.get_member_key_label() context['product_key_field'] = self.get_product_key_field() context['product_key_label'] = self.get_product_key_label() if self.expose_quickie_search: context['quickie'] = self.get_quickie_context() if self.grid_index: context['grid_count'] = self.grid_count if self.has_rows: context['rows_title'] = self.get_rows_title() context['row_permission_prefix'] = self.get_row_permission_prefix() context['row_model_title'] = self.get_row_model_title() context['row_model_title_plural'] = self.get_row_model_title_plural() context['row_action_url'] = self.get_row_action_url context.update(data) context.update(self.template_kwargs(**context)) if hasattr(self, 'template_kwargs_{}'.format(template)): context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context)) # First try the template path most specific to the view. mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template) try: return render_to_response(mako_path, context, request=self.request) except IOError: # Failing that, try one or more fallback templates. for fallback in self.get_fallback_templates(template): try: return render_to_response(fallback, context, request=self.request) except IOError: pass # If we made it all the way here, we found no templates at all, in # which case re-attempt the first and let that error raise on up. return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) # TODO: merge this logic with render_to_response() def render(self, template, data): """ Render the given template with the given context data. """ context = { 'master': self, 'model_title': self.get_model_title(), 'model_title_plural': self.get_model_title_plural(), 'route_prefix': self.get_route_prefix(), 'permission_prefix': self.get_permission_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), 'action_url': self.get_action_url, } context.update(data) # First try the template path most specific to the view. try: return render('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) except IOError: # Failing that, try one or more fallback templates. for fallback in self.get_fallback_templates(template): try: return render(fallback, context, request=self.request) except IOError: pass # If we made it all the way here, we found no templates at all, in # which case re-attempt the first and let that error raise on up. return render('{}/{}.mako'.format(self.get_template_prefix(), template), context, request=self.request) def get_fallback_templates(self, template, **kwargs): return ['/master/{}.mako'.format(template)] def get_default_engine_dbkey(self): """ Returns the "default" engine dbkey. """ return self.rattail_config.get( 'tailbone', 'engines.{}.pretend_default'.format(self.engine_type_key), default='default') def get_current_engine_dbkey(self): """ Returns the "current" engine's dbkey, for the current user. """ default = self.get_default_engine_dbkey() return self.request.session.get('tailbone.engines.{}.current'.format(self.engine_type_key), default) def template_kwargs(self, **kwargs): """ Supplement the template context, for all views. """ # whether or not to show the DB picker? kwargs['expose_db_picker'] = False if self.supports_multiple_engines: # DB picker is only shown for permissioned users if self.request.has_perm('common.change_db_engine'): # view declares support for multiple engines, but we only want to # show the picker if we have more than one engine configured engines = self.get_db_engines() if len(engines) > 1: # user session determines "current" db engine *of this type* # (note that many master views may declare the same type, and # would therefore share the "current" engine) selected = self.get_current_engine_dbkey() kwargs['expose_db_picker'] = True kwargs['db_picker_options'] = [tags.Option(k) for k in engines] kwargs['db_picker_selected'] = selected # add info for downloadable input file templates, if any if self.has_input_file_templates: templates = self.normalize_input_file_templates() kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl) for tmpl in templates]) return kwargs def get_input_file_templates(self): return [] def normalize_input_file_templates(self, templates=None, include_file_options=False): if templates is None: templates = self.get_input_file_templates() route_prefix = self.get_route_prefix() if include_file_options: templatesdir = os.path.join(self.rattail_config.datadir(), 'templates', 'input_files', route_prefix) for template in templates: if 'config_section' not in template: template['config_section'] = self.input_file_template_config_section section = template['config_section'] if 'config_prefix' not in template: template['config_prefix'] = '{}.{}'.format( self.input_file_template_config_prefix, template['key']) prefix = template['config_prefix'] for key in ('mode', 'file', 'url'): if 'option_{}'.format(key) not in template: template['option_{}'.format(key)] = '{}.{}'.format(prefix, key) if 'setting_{}'.format(key) not in template: template['setting_{}'.format(key)] = '{}.{}'.format( section, template['option_{}'.format(key)]) if key not in template: value = self.rattail_config.get( section, template['option_{}'.format(key)]) if value is not None: template[key] = value template.setdefault('mode', 'default') template.setdefault('file', None) template.setdefault('url', template['default_url']) if include_file_options: options = [] basedir = os.path.join(templatesdir, template['key']) if os.path.exists(basedir): for name in sorted(os.listdir(basedir)): if len(name) == 4 and name.isdigit(): files = os.listdir(os.path.join(basedir, name)) if len(files) == 1: options.append(os.path.join(name, files[0])) template['file_options'] = options template['file_options_dir'] = basedir if template['mode'] == 'external': template['effective_url'] = template['url'] elif template['mode'] == 'hosted': template['effective_url'] = self.request.route_url( '{}.download_input_file_template'.format(route_prefix), _query={'key': template['key'], 'file': template['file']}) else: template['effective_url'] = template['default_url'] return templates def template_kwargs_index(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. """ return kwargs def template_kwargs_create(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. """ return kwargs def template_kwargs_clone(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. """ return kwargs def template_kwargs_view(self, **kwargs): """ Method stub, so subclass can always invoke super() for it. """ obj = kwargs['instance'] kwargs['xref_buttons'] = self.get_xref_buttons(obj) kwargs['xref_links'] = self.get_xref_links(obj) return kwargs def get_xref_buttons(self, obj): buttons = [] for supp in self.iter_view_supplements(): buttons.extend(supp.get_xref_buttons(obj) or []) buttons = self.normalize_xref_buttons(buttons) return buttons def normalize_xref_buttons(self, buttons): normal = [] for button in buttons: # build a button if only given the data if isinstance(button, dict): button = self.make_xref_button(**button) normal.append(button) return normal def make_buefy_button(self, label, type=None, is_primary=False, url=None, target=None, is_external=False, icon_left=None, **kwargs): """ Make and return a HTML ```` literal. """ btn_kw = kwargs btn_kw.setdefault('c', label) btn_kw.setdefault('icon_pack', 'fas') if type: btn_kw['type'] = type elif is_primary: btn_kw['type'] = 'is-primary' if icon_left: btn_kw['icon_left'] = icon_left elif is_external: btn_kw['icon_left'] = 'external-link-alt' elif url: btn_kw['icon_left'] = 'eye' if url: btn_kw['href'] = url if target: btn_kw['target'] = target elif is_external: btn_kw['target'] = '_blank' button = HTML.tag('b-button', **btn_kw) if url: # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we # patch that into place manually button = str(button) button = button.replace('`` literal, for display in the cross-reference helper panel. :param url: URL for the link. :param text: Label for the button. :param internal: Boolean indicating if the link is internal to the site. This is false by default, meaning the link is assumed to be external, which affects the icon and causes button click to open link in a new tab. """ # TODO: this should call make_buefy_button() # nb. unfortunately HTML.tag() calls its first arg 'tag' and # so we can't pass a kwarg with that name...so instead we # patch that into place manually btn_kw = dict(type='is-primary', href=kwargs['url'], icon_pack='fas', c=kwargs['text']) if kwargs.get('internal'): btn_kw['icon_left'] = 'eye' else: btn_kw['icon_left'] = 'external-link-alt' btn_kw['target'] = '_blank' button = HTML.tag('b-button', **btn_kw) button = str(button) button = button.replace('