mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-18 12:56:31 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f15ef932 | ||
|
|
67e0a99012 | ||
|
|
40c74c7563 | ||
|
|
a55fe5941c | ||
|
|
5ff7468bfc | ||
|
|
698070b31e | ||
|
|
f58e98c930 |
@@ -64,7 +64,7 @@ RUN apt-get install -y --no-install-recommends \
|
|||||||
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
# Weasyprint requirements : https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#debian-11
|
||||||
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
poppler-utils libpango-1.0-0 libpangoft2-1.0-0 \
|
||||||
# Image format support
|
# Image format support
|
||||||
libjpeg-dev webp \
|
libjpeg-dev webp libwebp-dev \
|
||||||
# SQLite support
|
# SQLite support
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
# PostgreSQL support
|
# PostgreSQL support
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ CONFIG_DATA = None
|
|||||||
CONFIG_LOOKUPS = {}
|
CONFIG_LOOKUPS = {}
|
||||||
|
|
||||||
|
|
||||||
|
def to_list(value, delimiter=','):
|
||||||
|
"""Take a configuration setting and make sure it is a list.
|
||||||
|
|
||||||
|
For example, we might have a configuration setting taken from the .config file,
|
||||||
|
which is already a list.
|
||||||
|
|
||||||
|
However, the same setting may be specified via an environment variable,
|
||||||
|
using a comma delimited string!
|
||||||
|
"""
|
||||||
|
|
||||||
|
if type(value) in [list, tuple]:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Otherwise, force string value
|
||||||
|
value = str(value)
|
||||||
|
|
||||||
|
return [x.strip() for x in value.split(delimiter)]
|
||||||
|
|
||||||
|
|
||||||
def is_true(x):
|
def is_true(x):
|
||||||
"""Shortcut function to determine if a value "looks" like a boolean"""
|
"""Shortcut function to determine if a value "looks" like a boolean"""
|
||||||
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
|
return str(x).strip().lower() in ['1', 'y', 'yes', 't', 'true', 'on']
|
||||||
@@ -101,7 +120,12 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
|||||||
"""
|
"""
|
||||||
def try_typecasting(value, source: str):
|
def try_typecasting(value, source: str):
|
||||||
"""Attempt to typecast the value"""
|
"""Attempt to typecast the value"""
|
||||||
if typecast is not None:
|
|
||||||
|
# Force 'list' of strings
|
||||||
|
if typecast is list:
|
||||||
|
value = to_list(value)
|
||||||
|
|
||||||
|
elif typecast is not None:
|
||||||
# Try to typecast the value
|
# Try to typecast the value
|
||||||
try:
|
try:
|
||||||
val = typecast(value)
|
val = typecast(value)
|
||||||
@@ -109,6 +133,7 @@ def get_setting(env_var=None, config_key=None, default_value=None, typecast=None
|
|||||||
return val
|
return val
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
|
logger.error(f"Failed to typecast '{env_var}' with value '{value}' to type '{typecast}' with error {error}")
|
||||||
|
|
||||||
set_metadata(source)
|
set_metadata(source)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ MEDIA_ROOT = config.get_media_dir()
|
|||||||
# List of allowed hosts (default = allow all)
|
# List of allowed hosts (default = allow all)
|
||||||
ALLOWED_HOSTS = get_setting(
|
ALLOWED_HOSTS = get_setting(
|
||||||
config_key='allowed_hosts',
|
config_key='allowed_hosts',
|
||||||
default_value=['*']
|
default_value=['*'],
|
||||||
|
typecast=list,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cross Origin Resource Sharing (CORS) options
|
# Cross Origin Resource Sharing (CORS) options
|
||||||
@@ -119,7 +120,8 @@ CORS_ORIGIN_ALLOW_ALL = get_boolean_setting(
|
|||||||
|
|
||||||
CORS_ORIGIN_WHITELIST = get_setting(
|
CORS_ORIGIN_WHITELIST = get_setting(
|
||||||
config_key='cors.whitelist',
|
config_key='cors.whitelist',
|
||||||
default_value=[]
|
default_value=[],
|
||||||
|
typecast=list,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
|
# Needed for the parts importer, directly impacts the maximum parts that can be uploaded
|
||||||
@@ -736,9 +738,11 @@ if get_boolean_setting('TEST_TRANSLATIONS', default_value=False): # pragma: no
|
|||||||
django.conf.locale.LANG_INFO = LANG_INFO
|
django.conf.locale.LANG_INFO = LANG_INFO
|
||||||
|
|
||||||
# Currencies available for use
|
# Currencies available for use
|
||||||
CURRENCIES = get_setting('INVENTREE_CURRENCIES', 'currencies', [
|
CURRENCIES = get_setting(
|
||||||
'AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD',
|
'INVENTREE_CURRENCIES', 'currencies',
|
||||||
])
|
['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'NZD', 'USD'],
|
||||||
|
typecast=list,
|
||||||
|
)
|
||||||
|
|
||||||
# Maximum number of decimal places for currency rendering
|
# Maximum number of decimal places for currency rendering
|
||||||
CURRENCY_DECIMAL_PLACES = 6
|
CURRENCY_DECIMAL_PLACES = 6
|
||||||
@@ -746,7 +750,7 @@ CURRENCY_DECIMAL_PLACES = 6
|
|||||||
# Check that each provided currency is supported
|
# Check that each provided currency is supported
|
||||||
for currency in CURRENCIES:
|
for currency in CURRENCIES:
|
||||||
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
||||||
print(f"Currency code '{currency}' is not supported")
|
logger.error(f"Currency code '{currency}' is not supported")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Custom currency exchange backend
|
# Custom currency exchange backend
|
||||||
@@ -795,7 +799,7 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
|||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
# Load the allauth social backends
|
# Load the allauth social backends
|
||||||
SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [])
|
SOCIAL_BACKENDS = get_setting('INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list)
|
||||||
|
|
||||||
for app in SOCIAL_BACKENDS:
|
for app in SOCIAL_BACKENDS:
|
||||||
INSTALLED_APPS.append(app) # pragma: no cover
|
INSTALLED_APPS.append(app) # pragma: no cover
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ def check_for_updates():
|
|||||||
|
|
||||||
# Save the version to the database
|
# Save the version to the database
|
||||||
common.models.InvenTreeSetting.set_setting(
|
common.models.InvenTreeSetting.set_setting(
|
||||||
'INVENTREE_LATEST_VERSION',
|
'_INVENTREE_LATEST_VERSION',
|
||||||
tag,
|
tag,
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
@@ -440,11 +440,11 @@ def run_backup():
|
|||||||
time.sleep(random.randint(1, 5))
|
time.sleep(random.randint(1, 5))
|
||||||
|
|
||||||
# Check for records of previous backup attempts
|
# Check for records of previous backup attempts
|
||||||
last_attempt = InvenTreeSetting.get_setting('INVENTREE_BACKUP_ATTEMPT', '', cache=False)
|
last_attempt = InvenTreeSetting.get_setting('_INVENTREE_BACKUP_ATTEMPT', '', cache=False)
|
||||||
last_success = InvenTreeSetting.get_setting('INVENTREE_BACKUP_SUCCESS', '', cache=False)
|
last_success = InvenTreeSetting.get_setting('_INVENTREE_BACKUP_SUCCESS', '', cache=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
backup_n_days = int(InvenTreeSetting.get_setting('INVENTREE_BACKUP_DAYS', 1, cache=False))
|
backup_n_days = int(InvenTreeSetting.get_setting('_INVENTREE_BACKUP_DAYS', 1, cache=False))
|
||||||
except Exception:
|
except Exception:
|
||||||
backup_n_days = 1
|
backup_n_days = 1
|
||||||
|
|
||||||
@@ -456,14 +456,14 @@ def run_backup():
|
|||||||
|
|
||||||
if last_attempt:
|
if last_attempt:
|
||||||
# Do not attempt if the 'last attempt' at backup was within 12 hours
|
# Do not attempt if the 'last attempt' at backup was within 12 hours
|
||||||
threshold = timezone.now() - timezone.timedelta(hours=12)
|
threshold = datetime.now() - timedelta(hours=12)
|
||||||
|
|
||||||
if last_attempt > threshold:
|
if last_attempt > threshold:
|
||||||
logger.info('Last backup attempt was too recent - skipping backup operation')
|
logger.info('Last backup attempt was too recent - skipping backup operation')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Record the timestamp of most recent backup attempt
|
# Record the timestamp of most recent backup attempt
|
||||||
InvenTreeSetting.set_setting('INVENTREE_BACKUP_ATTEMPT', timezone.now().isoformat(), None)
|
InvenTreeSetting.set_setting('_INVENTREE_BACKUP_ATTEMPT', datetime.now().isoformat(), None)
|
||||||
|
|
||||||
if not last_attempt:
|
if not last_attempt:
|
||||||
# If there is no record of a previous attempt, exit quickly
|
# If there is no record of a previous attempt, exit quickly
|
||||||
@@ -479,7 +479,7 @@ def run_backup():
|
|||||||
|
|
||||||
# Exit early if the backup was successful within the number of required days
|
# Exit early if the backup was successful within the number of required days
|
||||||
if last_success:
|
if last_success:
|
||||||
threshold = timezone.now() - timezone.timedelta(days=backup_n_days)
|
threshold = datetime.now() - timedelta(days=backup_n_days)
|
||||||
|
|
||||||
if last_success > threshold:
|
if last_success > threshold:
|
||||||
logger.info('Last successful backup was too recent - skipping backup operation')
|
logger.info('Last successful backup was too recent - skipping backup operation')
|
||||||
@@ -489,7 +489,7 @@ def run_backup():
|
|||||||
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
|
call_command("mediabackup", noinput=True, clean=True, compress=True, interactive=False)
|
||||||
|
|
||||||
# Record the timestamp of most recent backup success
|
# Record the timestamp of most recent backup success
|
||||||
InvenTreeSetting.set_setting('INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None)
|
InvenTreeSetting.set_setting('_INVENTREE_BACKUP_SUCCESS', datetime.now().isoformat(), None)
|
||||||
|
|
||||||
|
|
||||||
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
def send_email(subject, body, recipients, from_email=None, html_message=None):
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
def test_task_check_for_updates(self):
|
def test_task_check_for_updates(self):
|
||||||
"""Test the task check_for_updates."""
|
"""Test the task check_for_updates."""
|
||||||
# Check that setting should be empty
|
# Check that setting should be empty
|
||||||
self.assertEqual(InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION'), '')
|
self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '')
|
||||||
|
|
||||||
# Get new version
|
# Get new version
|
||||||
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
|
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates)
|
||||||
|
|
||||||
# Check that setting is not empty
|
# Check that setting is not empty
|
||||||
response = InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION')
|
response = InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION')
|
||||||
self.assertNotEqual(response, '')
|
self.assertNotEqual(response, '')
|
||||||
self.assertTrue(bool(response))
|
self.assertTrue(bool(response))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import common.models
|
|||||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||||
|
|
||||||
# InvenTree software version
|
# InvenTree software version
|
||||||
INVENTREE_SW_VERSION = "0.10.0"
|
INVENTREE_SW_VERSION = "0.10.1"
|
||||||
|
|
||||||
|
|
||||||
def inventreeInstanceName():
|
def inventreeInstanceName():
|
||||||
@@ -64,9 +64,9 @@ def inventreeDocsVersion():
|
|||||||
def isInvenTreeUpToDate():
|
def isInvenTreeUpToDate():
|
||||||
"""Test if the InvenTree instance is "up to date" with the latest version.
|
"""Test if the InvenTree instance is "up to date" with the latest version.
|
||||||
|
|
||||||
A background task periodically queries GitHub for latest version, and stores it to the database as INVENTREE_LATEST_VERSION
|
A background task periodically queries GitHub for latest version, and stores it to the database as "_INVENTREE_LATEST_VERSION"
|
||||||
"""
|
"""
|
||||||
latest = common.models.InvenTreeSetting.get_setting('INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
latest = common.models.InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION', backup_value=None, create=False)
|
||||||
|
|
||||||
# No record for "latest" version - we must assume we are up to date!
|
# No record for "latest" version - we must assume we are up to date!
|
||||||
if not latest:
|
if not latest:
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class SettingsList(ListAPI):
|
|||||||
class GlobalSettingsList(SettingsList):
|
class GlobalSettingsList(SettingsList):
|
||||||
"""API endpoint for accessing a list of global settings objects."""
|
"""API endpoint for accessing a list of global settings objects."""
|
||||||
|
|
||||||
queryset = common.models.InvenTreeSetting.objects.all()
|
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
||||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
lookup_field = 'key'
|
lookup_field = 'key'
|
||||||
queryset = common.models.InvenTreeSetting.objects.all()
|
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith="_")
|
||||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
"""
|
"""
|
||||||
results = cls.objects.all()
|
results = cls.objects.all()
|
||||||
|
|
||||||
|
if exclude_hidden:
|
||||||
|
# Keys which start with an undersore are used for internal functionality
|
||||||
|
results = results.exclude(key__startswith='_')
|
||||||
|
|
||||||
# Optionally filter by user
|
# Optionally filter by user
|
||||||
if user is not None:
|
if user is not None:
|
||||||
results = results.filter(user=user)
|
results = results.filter(user=user)
|
||||||
|
|||||||
@@ -1718,10 +1718,8 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
Ref: https://github.com/inventree/InvenTree/pull/3986
|
Ref: https://github.com/inventree/InvenTree/pull/3986
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
pricing = self.pricing
|
||||||
self.pricing.schedule_for_update()
|
pricing.schedule_for_update()
|
||||||
except (PartPricing.DoesNotExist, IntegrityError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
||||||
"""Return a simplified pricing string for this part.
|
"""Return a simplified pricing string for this part.
|
||||||
@@ -2295,9 +2293,11 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
"""Schedule this pricing to be updated"""
|
"""Schedule this pricing to be updated"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.refresh_from_db()
|
if self.pk:
|
||||||
|
self.refresh_from_db()
|
||||||
except (PartPricing.DoesNotExist, IntegrityError):
|
except (PartPricing.DoesNotExist, IntegrityError):
|
||||||
# Error thrown if this PartPricing instance has already been removed
|
# Error thrown if this PartPricing instance has already been removed
|
||||||
|
logger.warning(f"Error refreshing PartPricing instance for part '{self.part}'")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure that the referenced part still exists in the database
|
# Ensure that the referenced part still exists in the database
|
||||||
@@ -2305,6 +2305,7 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
p = self.part
|
p = self.part
|
||||||
p.refresh_from_db()
|
p.refresh_from_db()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
logger.error(f"Could not update PartPricing as Part '{self.part}' does not exist")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.scheduled_for_update:
|
if self.scheduled_for_update:
|
||||||
@@ -2322,6 +2323,7 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
self.save()
|
self.save()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# An IntegrityError here likely indicates that the referenced part has already been deleted
|
# An IntegrityError here likely indicates that the referenced part has already been deleted
|
||||||
|
logger.error(f"Could not save PartPricing for part '{self.part}' to the database")
|
||||||
return
|
return
|
||||||
|
|
||||||
import part.tasks as part_tasks
|
import part.tasks as part_tasks
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ def delete_scheduled(apps, schema_editor):
|
|||||||
|
|
||||||
if items.count() > 0:
|
if items.count() > 0:
|
||||||
logger.info(f"Removing {items.count()} stock items scheduled for deletion")
|
logger.info(f"Removing {items.count()} stock items scheduled for deletion")
|
||||||
items.delete()
|
|
||||||
|
# Ensure any parent / child relationships are updated!
|
||||||
|
for item in items:
|
||||||
|
childs = StockItem.objects.filter(parent=item)
|
||||||
|
childs.update(parent=item.parent)
|
||||||
|
|
||||||
|
item.delete()
|
||||||
|
|
||||||
Task = apps.get_model('django_q', 'schedule')
|
Task = apps.get_model('django_q', 'schedule')
|
||||||
|
|
||||||
|
|||||||
@@ -67,3 +67,65 @@ class TestSerialNumberMigration(MigratorTestCase):
|
|||||||
# Check that the StockItem maximum serial number
|
# Check that the StockItem maximum serial number
|
||||||
self.assertEqual(big_ref_item.serial, '9999999999999999999999999999999999999999999999999999999999999')
|
self.assertEqual(big_ref_item.serial, '9999999999999999999999999999999999999999999999999999999999999')
|
||||||
self.assertEqual(big_ref_item.serial_int, 0x7fffffff)
|
self.assertEqual(big_ref_item.serial_int, 0x7fffffff)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduledForDeletionMigration(MigratorTestCase):
|
||||||
|
"""Test data migration for removing 'scheduled_for_deletion' field"""
|
||||||
|
|
||||||
|
migrate_from = ('stock', '0066_stockitem_scheduled_for_deletion')
|
||||||
|
migrate_to = ('stock', helpers.getNewestMigrationFile('stock'))
|
||||||
|
|
||||||
|
def prepare(self):
|
||||||
|
"""Create some initial stock items"""
|
||||||
|
|
||||||
|
Part = self.old_state.apps.get_model('part', 'part')
|
||||||
|
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||||
|
|
||||||
|
for idx in range(5):
|
||||||
|
part = Part.objects.create(
|
||||||
|
name=f'Part_{idx}',
|
||||||
|
description='Just a part, nothing to see here',
|
||||||
|
active=True,
|
||||||
|
level=0, tree_id=0,
|
||||||
|
lft=0, rght=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for jj in range(5):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=jj + 5,
|
||||||
|
level=0, tree_id=0,
|
||||||
|
lft=0, rght=0,
|
||||||
|
scheduled_for_deletion=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# For extra points, create some parent-child relationships between stock items
|
||||||
|
part = Part.objects.first()
|
||||||
|
|
||||||
|
item_1 = StockItem.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=100,
|
||||||
|
level=0, tree_id=0,
|
||||||
|
lft=0, rght=0,
|
||||||
|
scheduled_for_deletion=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ii in range(3):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part,
|
||||||
|
quantity=200,
|
||||||
|
level=0, tree_id=0,
|
||||||
|
lft=0, rght=0,
|
||||||
|
scheduled_for_deletion=False,
|
||||||
|
parent=item_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(StockItem.objects.count(), 29)
|
||||||
|
|
||||||
|
def test_migration(self):
|
||||||
|
"""Test that all stock items were actually removed"""
|
||||||
|
|
||||||
|
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||||
|
|
||||||
|
# All the "scheduled for deletion" items have been removed
|
||||||
|
self.assertEqual(StockItem.objects.count(), 3)
|
||||||
|
|||||||
@@ -1758,13 +1758,11 @@ function loadPartTable(table, url, options={}) {
|
|||||||
field: 'category_detail',
|
field: 'category_detail',
|
||||||
title: '{% trans "Category" %}',
|
title: '{% trans "Category" %}',
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
if (row.category && row.category_detail) {
|
||||||
var text = shortenString(row.category_detail.pathstring);
|
var text = shortenString(row.category_detail.pathstring);
|
||||||
|
|
||||||
if (row.category) {
|
|
||||||
return withTitle(renderLink(text, `/part/category/${row.category}/`), row.category_detail.pathstring);
|
return withTitle(renderLink(text, `/part/category/${row.category}/`), row.category_detail.pathstring);
|
||||||
} else {
|
} else {
|
||||||
return '{% trans "No category" %}';
|
return '<em>{% trans "No category" %}</em>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user