Skip to content

Commit

Permalink
New printing api (#226)
Browse files Browse the repository at this point in the history
* Add concept of "MAX_API_VERSION"

* Add new LabelTemplate model type

- Still retain the "old" ones (at least for now)

* Modern label printing API works

* Code updates

* Stringyness

* PEP fixes

* Updated docs

* Update tasks.py

- Add more options for running tests

* Add basic class for Plugin introspection

* Add basic plugin test

* Fix unit testing for plugin class

* Update unit tests for label printing

* fixes

* linting

* Update unit tests

* Refactor report printing API endpoint

* PEP fixes

* Restrict new unit testing to modern API
  • Loading branch information
SchrodingersGat authored May 11, 2024
1 parent 7e5effe commit e2323bc
Show file tree
Hide file tree
Showing 16 changed files with 449 additions and 408 deletions.
18 changes: 14 additions & 4 deletions inventree/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ class InventreeObject(object):
URL = ""

# Minimum server version for the particular model type
REQUIRED_API_VERSION = None
MIN_API_VERSION = None
MAX_API_VERSION = None

MODEL_TYPE = None

def __str__(self):
"""
Expand Down Expand Up @@ -67,6 +70,11 @@ def __init__(self, api, pk=None, data=None):
if len(self._data) == 0:
self.reload()

@classmethod
def getModelType(cls):
"""Return the model type for this label printing class."""
return cls.MODEL_TYPE

@classmethod
def checkApiVersion(cls, api):
"""Check if the API version supports this particular model.
Expand All @@ -75,9 +83,11 @@ def checkApiVersion(cls, api):
NotSupportedError if the server API version is too 'old'
"""

if cls.REQUIRED_API_VERSION is not None:
if cls.REQUIRED_API_VERSION > api.api_version:
raise NotImplementedError(f"Server API Version ({api.api_version}) is too old for the '{cls.__name__}' class, which requires API version {cls.REQUIRED_API_VERSION}")
if cls.MIN_API_VERSION and cls.MIN_API_VERSION > api.api_version:
raise NotImplementedError(f"Server API Version ({api.api_version}) is too old for the '{cls.__name__}' class, which requires API version >= {cls.MIN_API_VERSION}")

if cls.MAX_API_VERSION and cls.MAX_API_VERSION < api.api_version:
raise NotImplementedError(f"Server API Version ({api.api_version}) is too new for the '{cls.__name__}' class, which requires API version <= {cls.MAX_API_VERSION}")

@classmethod
def options(cls, api):
Expand Down
1 change: 1 addition & 0 deletions inventree/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Build(
""" Class representing the Build database model """

URL = 'build'
MODEL_TYPE = 'build'

# Setup for Report mixin
REPORTNAME = 'build'
Expand Down
4 changes: 2 additions & 2 deletions inventree/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ class Contact(inventree.base.InventreeObject):
"""Class representing the Contact model"""

URL = 'company/contact/'
REQUIRED_API_VERSION = 104
MIN_API_VERSION = 104


class Address(inventree.base.InventreeObject):
"""Class representing the Address model"""

URL = 'company/address/'
REQUIRED_API_VERSION = 126
MIN_API_VERSION = 126


class Company(inventree.base.ImageMixin, inventree.base.MetadataMixin, inventree.base.InventreeObject):
Expand Down
179 changes: 149 additions & 30 deletions inventree/label.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,61 @@

logger = logging.getLogger('inventree')

# The InvenTree API endpoint changed considerably @ version 197
# Ref: https://github.com/inventree/InvenTree/pull/7074
MODERN_LABEL_PRINTING_API = 197


class LabelPrintingMixin:
"""Mixin class for label printing.
Classes which implement this mixin should define the following attributes:
LABELNAME: The name of the label type (e.g. 'part', 'stock', 'location')
LABELITEM: The name of the label item (e.g. 'parts', 'items', 'locations')
Legacy API: < 197
- LABELNAME: The name of the label type (e.g. 'part', 'stock', 'location')
- LABELITEM: The name of the label item (e.g. 'parts', 'items', 'locations')
Modern API: >= 197
- MODEL_TYPE: The model type for the label printing class (e.g. 'part', 'stockitem', 'location')
"""

LABELNAME = ''
LABELITEM = ''

def printlabel(self, label, plugin=None, destination=None, *args, **kwargs):
def getTemplateId(self, template):
"""Return the ID (pk) from the supplied template."""

if type(template) in [str, int]:
return int(template)

if hasattr(template, 'pk'):
return int(template.pk)

raise ValueError(f"Provided label template is not a valid type: {type(template)}")

def saveOutput(self, output, filename):
"""Save the output from a label printing job to the specified file path."""

if os.path.exists(filename) and os.path.isdir(filename):
filename = os.path.join(
filename,
f'Label_{self.getModelType()}_{self.pk}.pdf'
)

return self._api.downloadFile(url=output, destination=filename)

def printLabel(self, label=None, plugin=None, destination=None, *args, **kwargs):
"""Print a label for the given item.
Check the connected API version to determine if the "modern" or "legacy" approach should be used.
"""

if self._api.api_version < MODERN_LABEL_PRINTING_API:
return self.printLabelLegacy(label, plugin=plugin, destination=destination, *args, **kwargs)
else:
return self.printLabelModern(label, plugin=plugin, destination=destination, *args, **kwargs)

def printLabelLegacy(self, label, plugin=None, destination=None, *args, **kwargs):
"""Print the label belonging to the given item.
Set the label with 'label' argument, as the ID of the corresponding
Expand All @@ -32,14 +73,13 @@ def printlabel(self, label, plugin=None, destination=None, *args, **kwargs):
Otherwise, if a destination is given, the file will be downloaded to 'destination'.
Use overwrite=True to overwrite an existing file.
If neither plugin nor destination is given, nothing will be done
"""
If neither plugin nor destination is given, nothing will be done.
if isinstance(label, (LabelPart, LabelStock, LabelLocation)):
label_id = label.pk
else:
label_id = label
Note: This legacy API support will be deprecated at some point in the future.
"""

label_id = self.getTemplateId(label)

# Set URL to use
URL = f'/label/{self.LABELNAME}/{label_id}/print/'

Expand All @@ -48,6 +88,11 @@ def printlabel(self, label, plugin=None, destination=None, *args, **kwargs):
}

if plugin is not None:

# For the legacy printing API, plugin is provided as a 'slug' (string)
if type(plugin) is not str:
raise TypeError(f"Plugin must be a string, not {type(plugin)}")

# Append profile
params['plugin'] = plugin

Expand All @@ -61,24 +106,72 @@ def printlabel(self, label, plugin=None, destination=None, *args, **kwargs):
download_url = response.get('file', None)

# Label file is available for download
if download_url and destination is not None:
if os.path.exists(destination) and os.path.isdir(destination):
# No file name given, construct one
# Otherwise, filename will be something like '?parts[]=37'
destination = os.path.join(
destination,
f'Label_{self.LABELNAME}{label}_{self.pk}.pdf'
)

# Use downloadFile method to get the file
return self._api.downloadFile(url=download_url, destination=destination, params=params, *args, **kwargs)
if download_url and destination:
return self.saveOutput(download_url, destination)

else:
return response

def printLabelModern(self, template, plugin=None, destination=None, *args, **kwargs):
"""Print a label against the provided label template."""

print_url = '/label/print/'

template_id = self.getTemplateId(template)

data = {
'template': template_id,
'items': [self.pk]
}

if plugin is not None:
# For the modern printing API, plugin is provided as a pk (integer) value
if type(plugin) is int:
plugin = int(plugin)
elif hasattr(plugin, 'pk'):
plugin = int(plugin.pk)
else:
raise ValueError(f"Invalid plugin provided: {type(plugin)}")

data['plugin'] = plugin

response = self._api.post(
print_url,
data=data
)

output = response.get('output', None)

if output and destination:
return self.saveOutput(output, destination)
else:
return response

def getLabelTemplates(self, **kwargs):
"""Return a list of label templates for this model class."""

if self._api.api_version < MODERN_LABEL_PRINTING_API:
logger.error("Legacy label printing API is not supported")
return []

return LabelTemplate.list(
self._api,
model_type=self.getModelType(),
**kwargs
)


class LabelFunctions(inventree.base.MetadataMixin, inventree.base.InventreeObject):
"""Base class for label functions"""
"""Base class for label functions."""

@property
def template_key(self):
"""Return the attribute name for the template file."""

if self._api.api_version < MODERN_LABEL_PRINTING_API:
return 'label'
else:
return 'template'

@classmethod
def create(cls, api, data, label, **kwargs):
Expand All @@ -90,7 +183,7 @@ def create(cls, api, data, label, **kwargs):
"""

# POST endpoints for creating new reports were added in API version 156
cls.REQUIRED_API_VERSION = 156
cls.MIN_API_VERSION = 156

try:
# If label is already a readable object, don't convert it
Expand All @@ -101,8 +194,10 @@ def create(cls, api, data, label, **kwargs):
if label.readable() is False:
raise ValueError("Label template file must be readable")

template_key = 'template' if api.api_version >= MODERN_LABEL_PRINTING_API else 'label'

try:
response = super().create(api, data=data, files={'label': label}, **kwargs)
response = super().create(api, data=data, files={template_key: label}, **kwargs)
finally:
if label is not None:
label.close()
Expand All @@ -117,7 +212,7 @@ def save(self, data=None, label=None, **kwargs):
"""

# PUT/PATCH endpoints for updating data were available before POST endpoints
self.REQUIRED_API_VERSION = None
self.MIN_API_VERSION = None

if label is not None:
try:
Expand All @@ -131,9 +226,9 @@ def save(self, data=None, label=None, **kwargs):

if 'files' in kwargs:
files = kwargs.pop('kwargs')
files['label'] = label
files[self.template_key] = label
else:
files = {'label': label}
files = {self.template_key: label}
else:
files = None

Expand All @@ -148,22 +243,46 @@ def downloadTemplate(self, destination, overwrite=False):
"""Download template file for the label to the given destination"""

# Use downloadFile method to get the file
return self._api.downloadFile(url=self._data['label'], destination=destination, overwrite=overwrite)
return self._api.downloadFile(url=self._data[self.template_key], destination=destination, overwrite=overwrite)


class LabelLocation(LabelFunctions):
""" Class representing the Label/Location database model """
"""Class representing the Label/Location database model.
Note: This class will be deprecated at some point in the future.
"""

URL = 'label/location'
MAX_API_VERSION = MODERN_LABEL_PRINTING_API - 1


class LabelPart(LabelFunctions):
""" Class representing the Label/Part database model """
"""Class representing the Label/Part database model.
Note: This class will be deprecated at some point in the future.
"""

URL = 'label/part'
MAX_API_VERSION = MODERN_LABEL_PRINTING_API - 1


class LabelStock(LabelFunctions):
""" Class representing the Label/stock database model """
"""Class representing the Label/stock database model.
Note: This class will be deprecated at some point in the future.
"""

URL = 'label/stock'
MAX_API_VERSION = MODERN_LABEL_PRINTING_API - 1


class LabelTemplate(LabelFunctions):
"""Class representing the LabelTemplate database model."""

URL = 'label/template'
MIN_API_VERSION = MODERN_LABEL_PRINTING_API

def __str__(self):
"""String representation of the LabelTemplate instance."""

return f"LabelTemplate <{self.pk}>: '{self.name}' - ({self.model_type})"
2 changes: 2 additions & 0 deletions inventree/part.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ class Part(
LABELNAME = 'part'
LABELITEM = 'parts'

MODEL_TYPE = 'part'

def getCategory(self):
""" Return the part category associated with this part """
return PartCategory(self._api, self.category)
Expand Down
16 changes: 16 additions & 0 deletions inventree/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-

import inventree.base


class InvenTreePlugin(inventree.base.MetadataMixin, inventree.base.InventreeObject):
"""Represents a PluginConfig instance on the InvenTree server."""

URL = 'plugins'

def setActive(self, active: bool):
"""Activate or deactivate this plugin."""

url = f'plugins/{self.pk}/activate/'

self._api.post(url, data={'active': active})
2 changes: 1 addition & 1 deletion inventree/project_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ class ProjectCode(inventree.base.InventreeObject):
"""Class representing the 'ProjectCode' database model"""

URL = 'project-code/'
REQUIRED_API_VERSION = 109
MIN_API_VERSION = 109
1 change: 1 addition & 0 deletions inventree/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class PurchaseOrder(
""" Class representing the PurchaseOrder database model """

URL = 'order/po'
MODEL_TYPE = 'purchaseorder'

# Setup for Report mixin
REPORTNAME = 'po'
Expand Down
Loading

0 comments on commit e2323bc

Please sign in to comment.