Source code for rattail.labels

# -*- 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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Label Printing
"""

import io
import os
import os.path
import socket
import shutil
from collections import OrderedDict

from rattail.app import GenericHandler
from rattail.core import Object
from rattail.files import temp_path
from rattail.exceptions import LabelPrintingError
from rattail.time import localtime


[docs] class LabelHandler(GenericHandler): """ Base class and default implementation for label handlers. """
[docs] def get_label_profiles(self, session, visible=True, **kwargs): """ Return the set of label profiles which are available for use with actual product label printing. """ model = self.model return session.query(model.LabelProfile)\ .filter(model.LabelProfile.visible == visible)\ .order_by(model.LabelProfile.ordinal)\ .all()
[docs] def get_formatter(self, profile, ignore_errors=False): """ Return the label formatter for the given profile. :param profile: A :class:`~rattail.db.model.labels.LabelProfile` instance. :returns: A :class:`~rattail.labels.LabelFormatter` instance. """ if not profile.formatter_spec: if ignore_errors: return raise ValueError("label profile has no formatter_spec: {}".format( profile)) factory = self.app.load_object(profile.formatter_spec) formatter = factory(self.config, template=profile.format) return formatter
[docs] def get_printer(self, profile, ignore_errors=False): """ Return the label printer for the given profile. :param profile: A :class:`~rattail.db.model.labels.LabelProfile` instance. :returns: A :class:`~rattail.labels.LabelPrinter` instance. """ if not profile.printer_spec: if ignore_errors: return raise ValueError("label profile has no printer_spec: {}".format( profile)) # create the printer factory = self.app.load_object(profile.printer_spec) printer = factory(self.config) # establish settings for it for name in printer.required_settings: value = self.get_printer_setting(profile, name) setattr(printer, name, value) # give it a formatter printer.formatter = self.get_formatter(profile, ignore_errors=ignore_errors) return printer
[docs] def get_printer_setting(self, profile, name): """ Read a printer setting from the DB. """ if not profile.uuid: return session = self.app.get_session(profile) name = 'labels.{}.printer.{}'.format(profile.uuid, name) return self.app.get_setting(session, name)
[docs] def save_printer_setting(self, profile, name, value): """ Write a printer setting to the DB. """ session = self.app.get_session(profile) if not profile.uuid: session.flush() name = 'labels.{}.printer.{}'.format(profile.uuid, name) self.app.save_setting(session, name, value)
[docs] def get_product_label_data(self, product, **kwargs): """ Return a data dict with common product fields, suitable for use with formatting labels for printing. The intention is that this method should be able to provide enough data to make basic label printing possible. In fact when printing is done "ad-hoc" for one product at a time, the data used for printing comes only from this method. :param product: A :class:`~rattail.db.model.products.Product` instance from which field values will come. :returns: Data dict containing common product fields. """ data = { 'description': product.description, 'size': product.size, } brand = product.brand data['brand_name'] = brand.name if brand else None regprice = product.regular_price data['regular_price'] = regprice.price if regprice else None data['regular_pack_price'] = regprice.pack_price if regprice else None tprprice = product.tpr_price data['tpr_price'] = tprprice.price if tprprice else None data['tpr_starts'] = None if tprprice and tprprice.starts: starts = self.app.localtime(tprprice.starts, from_utc=True) data['tpr_starts'] = self.app.render_datetime(tprprice.starts) data['tpr_ends'] = None if tprprice and tprprice.ends: ends = self.app.localtime(tprprice.ends, from_utc=True) data['tpr_ends'] = self.app.render_datetime(tprprice.ends) salprice = product.sale_price data['sale_price'] = salprice.price if salprice else None data['sale_starts'] = None if salprice and salprice.starts: starts = self.app.localtime(salprice.starts, from_utc=True) data['sale_starts'] = self.app.render_datetime(salprice.starts) data['sale_ends'] = None if salprice and salprice.ends: ends = self.app.localtime(salprice.ends, from_utc=True) data['sale_ends'] = self.app.render_datetime(salprice.ends) curprice = product.current_price data['current_price'] = curprice.price if curprice else None data['current_starts'] = None if curprice and curprice.starts: starts = self.app.localtime(curprice.starts, from_utc=True) data['current_starts'] = self.app.render_datetime(curprice.starts) data['current_ends'] = None if curprice and curprice.ends: ends = self.app.localtime(curprice.ends, from_utc=True) data['current_ends'] = self.app.render_datetime(curprice.ends) sugprice = product.suggested_price data['suggested_price'] = sugprice.price if sugprice else None vendor = product.vendor data['vendor_name'] = vendor.name if vendor else None return data
[docs] class LabelPrinter(object): """ Base class for all label printers. Label printing devices which are "natively" supported by Rattail will each derive from this class in order to provide implementation details specific to the device. You will typically instantiate one of those subclasses (or one of your own design) in order to send labels to your physical printer. """ profile_name = None formatter = None required_settings = None def __init__(self, config): self.config = config
[docs] def print_labels(self, labels, *args, **kwargs): """ Prints labels found in ``labels``. """ raise NotImplementedError
[docs] class CommandPrinter(LabelPrinter): """ Generic :class:`LabelPrinter` class which "prints" labels via native printer (textual) commands. It does not directly implement any method for sending the commands to a printer; a subclass must be used for that. """
[docs] def batch_header_commands(self): """ This method, if implemented, must return a sequence of string commands to be interpreted by the printer. These commands will be the first which are written to the file. """ return None
[docs] class CommandFilePrinter(CommandPrinter): """ Generic :class:`LabelPrinter` implementation which "prints" labels to a file in the form of native printer (textual) commands. The output file is then expected to be picked up by a file monitor, and finally sent to the printer from there. """ required_settings = {'output_dir': "Output Folder"} output_dir = None
[docs] def print_labels(self, labels, output_dir=None, progress=None): """ "Prints" ``labels`` by generating a command file in the output folder. The full path of the output file to which commands are written will be returned to the caller. If ``output_dir`` is not specified, and the printer instance is associated with a :class:`LabelProfile` instance, then config will be consulted for the output path. If a path still is not found, the current (working) directory will be assumed. """ if not output_dir: output_dir = self.output_dir if not output_dir: raise LabelPrintingError("Printer does not have an output folder defined") labels_path = temp_path(prefix='rattail.', suffix='.labels') labels_file = open(labels_path, 'w') header = self.batch_header_commands() if header: labels_file.write('%s\n' % '\n'.join(header)) commands = self.formatter.format_labels(labels, progress=progress) if commands is None: labels_file.close() os.remove(labels_path) return None labels_file.write(commands) footer = self.batch_footer_commands() if footer: labels_file.write('%s\n' % '\n'.join(footer)) labels_file.close() fn = '{0}_{1}.labels'.format( socket.gethostname(), localtime(self.config).strftime('%Y-%m-%d_%H-%M-%S')) final_path = os.path.join(output_dir, fn) shutil.move(labels_path, final_path) return final_path
# Force ordering for network printer required settings. settings = OrderedDict() settings['address'] = "IP Address" settings['port'] = "Port" settings['timeout'] = "Timeout"
[docs] class CommandNetworkPrinter(CommandPrinter): """ Generic :class:`LabelPrinter` implementation which "prints" labels to a network socket in the form of native printer (textual) commands. The socket is assumed to exist on a networked label printer. """ required_settings = settings address = None port = None timeout = None
[docs] def print_labels(self, labels, progress=None): """ Prints ``labels`` by generating commands and sending directly to a socket which exists on a networked printer. """ if not self.address: raise LabelPrintingError("Printer does not have an IP address defined") if not self.port: raise LabelPrintingError("Printer does not have a port defined.") data = io.StringIO() header = self.batch_header_commands() if header: header = "{0}\n".format('\n'.join(header)) data.write(header.encode('utf_8')) commands = self.formatter.format_labels(labels, progress=progress) if commands is None: # process canceled? data.close() return None data.write(commands.encode('utf_8')) footer = self.batch_footer_commands() if footer: footer = "{0}\n".format('\n'.join(footer)) data.write(footer.encode('utf_8')) try: timeout = int(self.timeout) except ValueError: timeout = socket.getdefaulttimeout() try: # Must pass byte-strings (not unicode) to this function. sock = socket.create_connection((self.address.decode('utf_8'), int(self.port)), timeout) bytes = sock.send(data.getvalue()) sock.close() return bytes finally: data.close()
[docs] class LabelFormatter(Object): """ Base class for all label formatters. """ template = None def __init__(self, config, template=None): self.config = config self.app = self.config.get_app() if template: self.template = template @property def default_template(self): """ Default formatting template. This will be used if no template is defined within the label profile; see also :attr:`rattail.db.model.labels.LabelProfile.format`. """ raise NotImplementedError
[docs] def format_labels(self, labels, progress=None, **kwargs): """ Formats a set of labels and returns the result. :param labels: Sequence of 2-tuples representing labels to be formatted, and ultimately printed. """ raise NotImplementedError
[docs] class CommandFormatter(LabelFormatter): """ Base class and default implementation for label formatters which generate raw printer commands for sending (in)directly to the printer device. There are various printer command languages out there; this class is not language-specific and should work for any of them. .. attribute:: template Formatting template. This is a string containing a template of raw printer commands, suitable for printing a single label record. Value for this normally comes from :attr:`rattail.db.model.labels.LabelProfile.format`. Normally these commands would print a "complete" label in terms of physical media, but not so for 2-up, in which case the template should only contain commands for "half" the label, i.e. only the commands to print one "record". There are 2 "languages" at play within the template: * template language * printer command language The template language refers to the syntax of the template itself, which ultimately will be "rendered" into a final result which should contain valid printer command language. (See also :class:`~rattail.labels.CommandFormatter.format_labels()`.) Thus far there is only one template language supported, although it is likely more will be added in the future: * :ref:`python:old-string-formatting` The printer command language refers to the syntax of commands which can be sent to the printer in order to cause it to produce desired physical media, i.e. "formatted printed label". There are a number of printer command languages out there; the one you need to use will depend on the make and/or model and/or settings for your printer device. Thus far the following languages have been used successfully: * `Cognitive Programming Language (CPL) <http://cognitivetpg.com/assets/downloads/105-008-04%20F(CPL%20ProgrammersGuide).pdf>`_ * `Zebra Programming Language (ZPL) <https://en.wikipedia.org/wiki/Zebra_Programming_Language>`_ A template example using ZPL: .. code-block:: none ^XA ^FO035,65^A0N,40,30^FD%(brand)-17.17s^FS ^FO035,110^A0N,30,30^FD%(description)s %(size)s^FS ^FO163,36^A0N,80,55^FB230,1,0,R,0^FD$%(price)0.2f^FS ^FO265,170,0^A0N,25,20^FD%(vendor)-14.14s^FS ^FO050,144^BY2%(barcode)s^FS ^XZ A template example using CPL for a 2-up layout: .. code-block:: none STRING 5X7 %(description_x)d 5 %(description1)s STRING 5X7 %(description_x)d 15 %(description2)s BARCODE %(barcode_type)s %(barcode_x)d 60 20 %(barcode)s """
[docs] def format_labels(self, labels, progress=None, **kwargs): """ Format a set of labels and return the result. By "formatting" here we really mean generating a set of commands which ultimately will be submitted directly to a label printer device. Each of the ``labels`` specified should be a 2-tuple like ``(data, quantity)``, where ``data`` is a dict of record data (e.g. product description, price etc.) and ``quantity`` is the number of labels to be printed for that record. The formatter's :attr:`template` is "rendered" by feeding it the data dict from a single label record. That process is repeated until all labels have been rendered. Note that the formatting template is only able to reference fields present in the ``data`` dict for any given label record. If the incoming data is incomplete then you can add to it by overriding :meth:`get_all_data()`. :param labels: Sequence of 2-tuples representing labels to be formatted for printing. :param progress: Optional progress factory. :returns: Unicode string containing the formatted label data, i.e. commands to print the labels. """ fmt = io.StringIO() def format_label(record, i): data, quantity = record product = data.get('product') if not product: return for j in range(quantity): header = self.label_header_commands() if header: header = "{0}\n".format('\n'.join(header)) fmt.write(header.encode('utf_8')) data = self.get_all_data(data) body = "{}\n".format('\n'.join(self.label_body_commands(product, data))) fmt.write(body) footer = self.label_footer_commands() if footer: footer = "{0}\n".format('\n'.join(footer)) fmt.write(footer.encode('utf_8')) self.app.progress_loop(format_label, labels, progress, message="Formatting labels") val = fmt.getvalue() fmt.close() return val
[docs] def get_all_data(self, data, **kwargs): """ Returns the "complete' data dict for a given label record. When the caller asks us to format labels, it provides a data dict for each label to be printed. This method is able to add more things to that data dict, if needed. Note that which fields are actually needed will depend on the contents of :attr:`template`. By default this will check the data dict for a ``'product'`` key, and if there is a value, calls :meth:`get_product_data()` to add common product fields. :param data: Dict of data for a label record, as provided by the caller. :returns: Final data dict with all necessary fields. """ if data.get('product'): data = self.get_product_data(data, data['product']) return data
[docs] def get_product_data(self, data, product, **kwargs): """ Add common product fields to the given data dict. The intention is that even if ``data`` is an empty dict, this method should be able to add enough data to make basic label printing possible. Default logic for this will call :meth:`rattail.labels.LabelHandler.get_product_label_data()` to get the product data dict, and then use ``data`` from the caller to override anything as needed, and return the result. :param data: Dict of data for a label record. :param product: A :class:`~rattail.db.model.products.Product` instance to which the label record applies, and from which additional field values will come. :returns: Final data dict including common product fields. """ label_handler = self.app.get_label_handler() final_data = label_handler.get_product_label_data(product) final_data.update(data) return final_data
[docs] def label_header_commands(self): """ This method, if implemented, must return a sequence of string commands to be interpreted by the printer. These commands will immediately precede each *label* in one-up printing, and immediately precede each *label pair* in two-up printing. """
def label_body_commands(self, product, data, **kwargs): raise NotImplementedError
[docs] class TwoUpCommandFormatter(CommandFormatter): """ Generic subclass of :class:`LabelFormatter` which generates native printer (textual) commands. This class contains logic to implement "two-up" label printing. """ @property def half_offset(self): """ The X-coordinate value by which the second label should be offset, when two labels are printed side-by-side. """ raise NotImplementedError def format_labels(self, labels, progress=None, **kwargs): "" # avoid auto-generated docs fmt = io.StringIO() self.half_started = False def format_label(record, i): data, quantity = record product = data.get('product') if not product: return for j in range(quantity): kw = self.get_all_data(data) if self.half_started: kw['x'] = self.half_offset fmt.write('{}\n'.format('\n'.join(self.label_body_commands(product, kw)))) footer = self.label_footer_commands() if footer: fmt.write('{}\n'.format('\n'.join(footer))) self.half_started = False else: header = self.label_header_commands() if header: fmt.write('{}\n'.format('\n'.join(header))) kw['x'] = 0 fmt.write('{}\n'.format('\n'.join(self.label_body_commands(product, kw)))) self.half_started = True self.app.progress_loop(format_label, labels, progress, message="Formatting labels") if self.half_started: footer = self.label_footer_commands() if footer: fmt.write('{}\n'.format('\n'.join(footer))) val = fmt.getvalue() fmt.close() return val