mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
fd6179fc9b
@ -18,6 +18,12 @@ repos:
|
|||||||
rev: '4.0.1'
|
rev: '4.0.1'
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
additional_dependencies: [
|
||||||
|
'flake8-bugbear',
|
||||||
|
'flake8-docstrings',
|
||||||
|
'flake8-string-format',
|
||||||
|
'pep8-naming ',
|
||||||
|
]
|
||||||
- repo: https://github.com/pycqa/isort
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: '5.10.1'
|
rev: '5.10.1'
|
||||||
hooks:
|
hooks:
|
||||||
|
@ -105,8 +105,12 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
|
|||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def get(self, url, data={}, expected_code=200):
|
def get(self, url, data=None, expected_code=200):
|
||||||
"""Issue a GET request."""
|
"""Issue a GET request."""
|
||||||
|
# Set default - see B006
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format='json')
|
||||||
|
|
||||||
if expected_code is not None:
|
if expected_code is not None:
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 56
|
INVENTREE_API_VERSION = 57
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||||
|
|
||||||
|
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
|
||||||
|
- Transfer PartCategoryTemplateParameter actions to the API
|
||||||
|
|
||||||
v56 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3123
|
v56 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3123
|
||||||
- Expose the PartParameterTemplate model to use the API
|
- Expose the PartParameterTemplate model to use the API
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except AppRegistryNotReady: # pragma: no cover
|
except AppRegistryNotReady: # pragma: no cover
|
||||||
|
logger.warning("Cannot start background tasks - app registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Starting background tasks...")
|
logger.info("Starting background tasks...")
|
||||||
@ -98,6 +99,24 @@ class InvenTreeConfig(AppConfig):
|
|||||||
schedule_type=Schedule.DAILY,
|
schedule_type=Schedule.DAILY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for overdue purchase orders
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'order.tasks.check_overdue_purchase_orders',
|
||||||
|
schedule_type=Schedule.DAILY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for overdue sales orders
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'order.tasks.check_overdue_sales_orders',
|
||||||
|
schedule_type=Schedule.DAILY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for overdue build orders
|
||||||
|
InvenTree.tasks.schedule_task(
|
||||||
|
'build.tasks.check_overdue_build_orders',
|
||||||
|
schedule_type=Schedule.DAILY
|
||||||
|
)
|
||||||
|
|
||||||
def update_exchange_rates(self): # pragma: no cover
|
def update_exchange_rates(self): # pragma: no cover
|
||||||
"""Update exchange rates each time the server is started.
|
"""Update exchange rates each time the server is started.
|
||||||
|
|
||||||
@ -136,7 +155,7 @@ class InvenTreeConfig(AppConfig):
|
|||||||
logger.info("Exchange backend not found - updating")
|
logger.info("Exchange backend not found - updating")
|
||||||
update = True
|
update = True
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
# Some other error - potentially the tables are not ready yet
|
# Some other error - potentially the tables are not ready yet
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -43,12 +43,16 @@ class InvenTreeExchange(SimpleExchangeBackend):
|
|||||||
context = ssl.create_default_context(cafile=certifi.where())
|
context = ssl.create_default_context(cafile=certifi.where())
|
||||||
response = urlopen(url, timeout=5, context=context)
|
response = urlopen(url, timeout=5, context=context)
|
||||||
return response.read()
|
return response.read()
|
||||||
except:
|
except Exception:
|
||||||
# Returning None here will raise an error upstream
|
# Returning None here will raise an error upstream
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_rates(self, base_currency=currency_code_default()):
|
def update_rates(self, base_currency=None):
|
||||||
"""Set the requested currency codes and get rates."""
|
"""Set the requested currency codes and get rates."""
|
||||||
|
# Set default - see B008
|
||||||
|
if base_currency is None:
|
||||||
|
base_currency = currency_code_default()
|
||||||
|
|
||||||
symbols = ','.join(currency_codes())
|
symbols = ','.join(currency_codes())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -16,13 +16,12 @@ from allauth.exceptions import ImmediateHttpResponse
|
|||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from allauth_2fa.adapter import OTPAdapter
|
from allauth_2fa.adapter import OTPAdapter
|
||||||
from allauth_2fa.utils import user_has_valid_totp_device
|
from allauth_2fa.utils import user_has_valid_totp_device
|
||||||
from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText,
|
from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
|
||||||
PrependedText, StrictButton)
|
PrependedText)
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Field, Layout
|
from crispy_forms.layout import Field, Layout
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from part.models import PartCategory
|
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -109,22 +108,6 @@ class HelperForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(*layouts)
|
self.helper.layout = Layout(*layouts)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmForm(forms.Form):
|
|
||||||
"""Generic confirmation form."""
|
|
||||||
|
|
||||||
confirm = forms.BooleanField(
|
|
||||||
required=False, initial=False,
|
|
||||||
help_text=_("Confirm")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
fields = [
|
|
||||||
'confirm'
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteForm(forms.Form):
|
class DeleteForm(forms.Form):
|
||||||
"""Generic deletion form which provides simple user confirmation."""
|
"""Generic deletion form which provides simple user confirmation."""
|
||||||
|
|
||||||
@ -185,39 +168,6 @@ class SetPasswordForm(HelperForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SettingCategorySelectForm(forms.ModelForm):
|
|
||||||
"""Form for setting category settings."""
|
|
||||||
|
|
||||||
category = forms.ModelChoiceField(queryset=PartCategory.objects.all())
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
model = PartCategory
|
|
||||||
fields = [
|
|
||||||
'category'
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Setup form layout."""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.helper = FormHelper()
|
|
||||||
# Form rendering
|
|
||||||
self.helper.form_show_labels = False
|
|
||||||
self.helper.layout = Layout(
|
|
||||||
Div(
|
|
||||||
Div(Field('category'),
|
|
||||||
css_class='col-sm-6',
|
|
||||||
style='width: 70%;'),
|
|
||||||
Div(StrictButton(_('Select Category'), css_class='btn btn-primary', type='submit'),
|
|
||||||
css_class='col-sm-6',
|
|
||||||
style='width: 30%; padding-left: 0;'),
|
|
||||||
css_class='row',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# override allauth
|
# override allauth
|
||||||
class CustomSignupForm(SignupForm):
|
class CustomSignupForm(SignupForm):
|
||||||
"""Override to use dynamic settings."""
|
"""Override to use dynamic settings."""
|
||||||
|
@ -95,7 +95,7 @@ def TestIfImage(img):
|
|||||||
try:
|
try:
|
||||||
Image.open(img).verify()
|
Image.open(img).verify()
|
||||||
return True
|
return True
|
||||||
except:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
except:
|
except Exception:
|
||||||
print("Error rebuilding Part objects")
|
print("Error rebuilding Part objects")
|
||||||
|
|
||||||
# Part category
|
# Part category
|
||||||
@ -26,7 +26,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
from part.models import PartCategory
|
from part.models import PartCategory
|
||||||
PartCategory.objects.rebuild()
|
PartCategory.objects.rebuild()
|
||||||
except:
|
except Exception:
|
||||||
print("Error rebuilding PartCategory objects")
|
print("Error rebuilding PartCategory objects")
|
||||||
|
|
||||||
# StockItem model
|
# StockItem model
|
||||||
@ -35,7 +35,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
StockItem.objects.rebuild()
|
StockItem.objects.rebuild()
|
||||||
except:
|
except Exception:
|
||||||
print("Error rebuilding StockItem objects")
|
print("Error rebuilding StockItem objects")
|
||||||
|
|
||||||
# StockLocation model
|
# StockLocation model
|
||||||
@ -44,7 +44,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
StockLocation.objects.rebuild()
|
StockLocation.objects.rebuild()
|
||||||
except:
|
except Exception:
|
||||||
print("Error rebuilding StockLocation objects")
|
print("Error rebuilding StockLocation objects")
|
||||||
|
|
||||||
# Build model
|
# Build model
|
||||||
@ -53,5 +53,5 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
Build.objects.rebuild()
|
Build.objects.rebuild()
|
||||||
except:
|
except Exception:
|
||||||
print("Error rebuilding Build objects")
|
print("Error rebuilding Build objects")
|
||||||
|
@ -137,7 +137,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
if callable(default):
|
if callable(default):
|
||||||
try:
|
try:
|
||||||
default = default()
|
default = default()
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
serializer_info[name]['default'] = default
|
serializer_info[name]['default'] = default
|
||||||
|
@ -98,7 +98,7 @@ class AuthRequiredMiddleware(object):
|
|||||||
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
|
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
|
||||||
# Save the 'next' parameter to pass through to the login view
|
# Save the 'next' parameter to pass through to the login view
|
||||||
|
|
||||||
return redirect('{}?next={}'.format(reverse_lazy('account_login'), request.path))
|
return redirect(f'{reverse_lazy("account_login")}?next={request.path}')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Return a 401 (Unauthorized) response code for this request
|
# Return a 401 (Unauthorized) response code for this request
|
||||||
|
@ -5,17 +5,21 @@ import os
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import pre_delete
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from error_report.models import Error
|
||||||
from mptt.exceptions import InvalidMove
|
from mptt.exceptions import InvalidMove
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
|
import InvenTree.helpers
|
||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.validators import validate_tree_name
|
from InvenTree.validators import validate_tree_name
|
||||||
|
|
||||||
@ -133,7 +137,7 @@ def extract_int(reference, clip=0x7fffffff):
|
|||||||
ref = result.groups()[0]
|
ref = result.groups()[0]
|
||||||
try:
|
try:
|
||||||
ref_int = int(ref)
|
ref_int = int(ref)
|
||||||
except:
|
except Exception:
|
||||||
ref_int = 0
|
ref_int = 0
|
||||||
|
|
||||||
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
# Ensure that the returned values are within the range that can be stored in an IntegerField
|
||||||
@ -276,7 +280,7 @@ class InvenTreeAttachment(models.Model):
|
|||||||
os.rename(old_file, new_file)
|
os.rename(old_file, new_file)
|
||||||
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
self.attachment.name = os.path.join(self.getSubdir(), fn)
|
||||||
self.save()
|
self.save()
|
||||||
except:
|
except Exception:
|
||||||
raise ValidationError(_("Error renaming file"))
|
raise ValidationError(_("Error renaming file"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -442,3 +446,37 @@ def before_delete_tree_item(sender, instance, using, **kwargs):
|
|||||||
for child in instance.children.all():
|
for child in instance.children.all():
|
||||||
child.parent = instance.parent
|
child.parent = instance.parent
|
||||||
child.save()
|
child.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Error, dispatch_uid='error_post_save_notification')
|
||||||
|
def after_error_logged(sender, instance: Error, created: bool, **kwargs):
|
||||||
|
"""Callback when a server error is logged.
|
||||||
|
|
||||||
|
- Send a UI notification to all users with staff status
|
||||||
|
"""
|
||||||
|
|
||||||
|
if created:
|
||||||
|
try:
|
||||||
|
import common.notifications
|
||||||
|
|
||||||
|
users = get_user_model().objects.filter(is_staff=True)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'error': instance,
|
||||||
|
'name': _('Server Error'),
|
||||||
|
'message': _('An error has been logged by the server.'),
|
||||||
|
'link': InvenTree.helpers.construct_absolute_url(
|
||||||
|
reverse('admin:error_report_error_change', kwargs={'object_id': instance.pk})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
common.notifications.trigger_notification(
|
||||||
|
instance,
|
||||||
|
'inventree.error_log',
|
||||||
|
context=context,
|
||||||
|
targets=users,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
"""We do not want to throw an exception while reporting an exception"""
|
||||||
|
logger.error(exc)
|
||||||
|
@ -47,7 +47,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
|||||||
try:
|
try:
|
||||||
if amount is not None and amount is not empty:
|
if amount is not None and amount is not empty:
|
||||||
amount = Decimal(amount)
|
amount = Decimal(amount)
|
||||||
except:
|
except Exception:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
self.field_name: [_("Must be a valid number")],
|
self.field_name: [_("Must be a valid number")],
|
||||||
})
|
})
|
||||||
@ -60,29 +60,6 @@ class InvenTreeMoneySerializer(MoneyField):
|
|||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serializer for User - provides all fields."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
model = User
|
|
||||||
fields = 'all'
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializerBrief(serializers.ModelSerializer):
|
|
||||||
"""Serializer for User - provides limited information."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options."""
|
|
||||||
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
'pk',
|
|
||||||
'username',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeModelSerializer(serializers.ModelSerializer):
|
class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||||
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
|
"""Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
|
||||||
|
|
||||||
@ -120,7 +97,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
if callable(value):
|
if callable(value):
|
||||||
try:
|
try:
|
||||||
value = value()
|
value = value()
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data[field_name] = value
|
data[field_name] = value
|
||||||
@ -150,7 +127,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
if callable(value):
|
if callable(value):
|
||||||
try:
|
try:
|
||||||
value = value()
|
value = value()
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
initials[field_name] = value
|
initials[field_name] = value
|
||||||
@ -218,6 +195,21 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(InvenTreeModelSerializer):
|
||||||
|
"""Serializer for a User."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines serializer fields."""
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'username',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'email'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ReferenceIndexingSerializerMixin():
|
class ReferenceIndexingSerializerMixin():
|
||||||
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
|
"""This serializer mixin ensures the the reference is not to big / small for the BigIntegerField."""
|
||||||
|
|
||||||
@ -239,9 +231,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
|||||||
|
|
||||||
/media/foo/bar.jpg
|
/media/foo/bar.jpg
|
||||||
|
|
||||||
Why? You can't handle the why!
|
If the server process is serving the data at 127.0.0.1,
|
||||||
|
|
||||||
Actually, if the server process is serving the data at 127.0.0.1,
|
|
||||||
but a proxy service (e.g. nginx) is then providing DNS lookup to the outside world,
|
but a proxy service (e.g. nginx) is then providing DNS lookup to the outside world,
|
||||||
then an attachment which prefixes the "address" of the internal server
|
then an attachment which prefixes the "address" of the internal server
|
||||||
will not be accessible from the outside world.
|
will not be accessible from the outside world.
|
||||||
@ -261,6 +251,8 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
The only real addition here is that we support "renaming" of the attachment file.
|
The only real addition here is that we support "renaming" of the attachment file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||||
|
|
||||||
attachment = InvenTreeAttachmentSerializerField(
|
attachment = InvenTreeAttachmentSerializerField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=False,
|
allow_null=False,
|
||||||
@ -302,7 +294,7 @@ class InvenTreeDecimalField(serializers.FloatField):
|
|||||||
# Convert the value to a string, and then a decimal
|
# Convert the value to a string, and then a decimal
|
||||||
try:
|
try:
|
||||||
return Decimal(str(data))
|
return Decimal(str(data))
|
||||||
except:
|
except Exception:
|
||||||
raise serializers.ValidationError(_("Invalid value"))
|
raise serializers.ValidationError(_("Invalid value"))
|
||||||
|
|
||||||
|
|
||||||
@ -423,7 +415,7 @@ class DataFileUploadSerializer(serializers.Serializer):
|
|||||||
if self.TARGET_MODEL:
|
if self.TARGET_MODEL:
|
||||||
try:
|
try:
|
||||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Extract a list of valid model field names
|
# Extract a list of valid model field names
|
||||||
@ -515,7 +507,7 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
if self.TARGET_MODEL:
|
if self.TARGET_MODEL:
|
||||||
try:
|
try:
|
||||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
except:
|
except Exception:
|
||||||
model_fields = {}
|
model_fields = {}
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
@ -568,7 +560,7 @@ class DataFileExtractSerializer(serializers.Serializer):
|
|||||||
if self.TARGET_MODEL:
|
if self.TARGET_MODEL:
|
||||||
try:
|
try:
|
||||||
model_fields = self.TARGET_MODEL.get_import_fields()
|
model_fields = self.TARGET_MODEL.get_import_fields()
|
||||||
except:
|
except Exception:
|
||||||
model_fields = {}
|
model_fields = {}
|
||||||
|
|
||||||
cols_seen = set()
|
cols_seen = set()
|
||||||
|
@ -242,7 +242,7 @@ def update_exchange_rates():
|
|||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||||
return
|
return
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Other error?
|
# Other error?
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -251,7 +251,7 @@ def update_exchange_rates():
|
|||||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||||
except ExchangeBackend.DoesNotExist:
|
except ExchangeBackend.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Some other error
|
# Some other error
|
||||||
logger.warning("update_exchange_rates: Database not ready")
|
logger.warning("update_exchange_rates: Database not ready")
|
||||||
return
|
return
|
||||||
|
@ -417,7 +417,7 @@ class CurrencyTests(TestCase):
|
|||||||
update_successful = False
|
update_successful = False
|
||||||
|
|
||||||
# Note: the update sometimes fails in CI, let's give it a few chances
|
# Note: the update sometimes fails in CI, let's give it a few chances
|
||||||
for idx in range(10):
|
for _ in range(10):
|
||||||
InvenTree.tasks.update_exchange_rates()
|
InvenTree.tasks.update_exchange_rates()
|
||||||
|
|
||||||
rates = Rate.objects.all()
|
rates = Rate.objects.all()
|
||||||
@ -469,12 +469,20 @@ class TestSettings(helpers.InvenTreeTestCase):
|
|||||||
|
|
||||||
superuser = True
|
superuser = True
|
||||||
|
|
||||||
def in_env_context(self, envs={}):
|
def in_env_context(self, envs=None):
|
||||||
"""Patch the env to include the given dict."""
|
"""Patch the env to include the given dict."""
|
||||||
|
# Set default - see B006
|
||||||
|
if envs is None:
|
||||||
|
envs = {}
|
||||||
|
|
||||||
return mock.patch.dict(os.environ, envs)
|
return mock.patch.dict(os.environ, envs)
|
||||||
|
|
||||||
def run_reload(self, envs={}):
|
def run_reload(self, envs=None):
|
||||||
"""Helper function to reload InvenTree."""
|
"""Helper function to reload InvenTree."""
|
||||||
|
# Set default - see B006
|
||||||
|
if envs is None:
|
||||||
|
envs = {}
|
||||||
|
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
|
||||||
with self.in_env_context(envs):
|
with self.in_env_context(envs):
|
||||||
|
@ -37,7 +37,7 @@ from .views import (AppearanceSelectView, CurrencyRefreshView,
|
|||||||
CustomSessionDeleteOtherView, CustomSessionDeleteView,
|
CustomSessionDeleteOtherView, CustomSessionDeleteView,
|
||||||
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
|
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
|
||||||
NotificationsView, SearchView, SetPasswordView,
|
NotificationsView, SearchView, SetPasswordView,
|
||||||
SettingCategorySelectView, SettingsView, auth_request)
|
SettingsView, auth_request)
|
||||||
|
|
||||||
admin.site.site_header = "InvenTree Admin"
|
admin.site.site_header = "InvenTree Admin"
|
||||||
|
|
||||||
@ -74,8 +74,6 @@ settings_urls = [
|
|||||||
re_path(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
|
re_path(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
|
||||||
re_path(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
|
re_path(r'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
|
||||||
|
|
||||||
re_path(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
|
|
||||||
|
|
||||||
# Catch any other urls
|
# Catch any other urls
|
||||||
re_path(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
|
re_path(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
|
||||||
]
|
]
|
||||||
|
@ -92,7 +92,7 @@ def validate_sales_order_reference(value):
|
|||||||
|
|
||||||
def validate_tree_name(value):
|
def validate_tree_name(value):
|
||||||
"""Prevent illegal characters in tree item names."""
|
"""Prevent illegal characters in tree item names."""
|
||||||
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"":
|
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"": # noqa: P103
|
||||||
if c in str(value):
|
if c in str(value):
|
||||||
raise ValidationError(_('Illegal character in name ({x})'.format(x=c)))
|
raise ValidationError(_('Illegal character in name ({x})'.format(x=c)))
|
||||||
|
|
||||||
|
@ -99,7 +99,7 @@ def inventreeCommitHash():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip()
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -114,5 +114,5 @@ def inventreeCommitDate():
|
|||||||
try:
|
try:
|
||||||
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
|
||||||
return d.split(' ')[0]
|
return d.split(' ')[0]
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
|
@ -17,8 +17,8 @@ from django.urls import reverse_lazy
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import (CreateView, DeleteView, DetailView, FormView,
|
from django.views.generic import (CreateView, DeleteView, DetailView, ListView,
|
||||||
ListView, UpdateView)
|
UpdateView)
|
||||||
from django.views.generic.base import RedirectView, TemplateView
|
from django.views.generic.base import RedirectView, TemplateView
|
||||||
|
|
||||||
from allauth.account.forms import AddEmailForm
|
from allauth.account.forms import AddEmailForm
|
||||||
@ -34,8 +34,7 @@ from common.settings import currency_code_default, currency_codes
|
|||||||
from part.models import PartCategory
|
from part.models import PartCategory
|
||||||
from users.models import RuleSet, check_user_role
|
from users.models import RuleSet, check_user_role
|
||||||
|
|
||||||
from .forms import (DeleteForm, EditUserForm, SetPasswordForm,
|
from .forms import DeleteForm, EditUserForm, SetPasswordForm
|
||||||
SettingCategorySelectForm)
|
|
||||||
from .helpers import str2bool
|
from .helpers import str2bool
|
||||||
|
|
||||||
|
|
||||||
@ -527,7 +526,7 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
|
|||||||
"""Return object matched to the model of the calling class."""
|
"""Return object matched to the model of the calling class."""
|
||||||
try:
|
try:
|
||||||
self.object = self.model.objects.get(pk=self.kwargs['pk'])
|
self.object = self.model.objects.get(pk=self.kwargs['pk'])
|
||||||
except:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
@ -691,14 +690,14 @@ class SettingsView(TemplateView):
|
|||||||
try:
|
try:
|
||||||
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
|
||||||
ctx["rates_updated"] = backend.last_update
|
ctx["rates_updated"] = backend.last_update
|
||||||
except:
|
except Exception:
|
||||||
ctx["rates_updated"] = None
|
ctx["rates_updated"] = None
|
||||||
|
|
||||||
# load locale stats
|
# load locale stats
|
||||||
STAT_FILE = os.path.abspath(os.path.join(settings.BASE_DIR, 'InvenTree/locale_stats.json'))
|
STAT_FILE = os.path.abspath(os.path.join(settings.BASE_DIR, 'InvenTree/locale_stats.json'))
|
||||||
try:
|
try:
|
||||||
ctx["locale_stats"] = json.load(open(STAT_FILE, 'r'))
|
ctx["locale_stats"] = json.load(open(STAT_FILE, 'r'))
|
||||||
except:
|
except Exception:
|
||||||
ctx["locale_stats"] = {}
|
ctx["locale_stats"] = {}
|
||||||
|
|
||||||
# Forms and context for allauth
|
# Forms and context for allauth
|
||||||
@ -801,40 +800,6 @@ class AppearanceSelectView(RedirectView):
|
|||||||
return redirect(reverse_lazy('settings'))
|
return redirect(reverse_lazy('settings'))
|
||||||
|
|
||||||
|
|
||||||
class SettingCategorySelectView(FormView):
|
|
||||||
"""View for selecting categories in settings."""
|
|
||||||
|
|
||||||
form_class = SettingCategorySelectForm
|
|
||||||
success_url = reverse_lazy('settings-category')
|
|
||||||
template_name = "InvenTree/settings/category.html"
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
"""Set category selection."""
|
|
||||||
initial = super().get_initial()
|
|
||||||
|
|
||||||
category = self.request.GET.get('category', None)
|
|
||||||
if category:
|
|
||||||
initial['category'] = category
|
|
||||||
|
|
||||||
return initial
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
"""Handle POST request (which contains category selection).
|
|
||||||
|
|
||||||
Pass the selected category to the page template
|
|
||||||
"""
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
context = self.get_context_data()
|
|
||||||
|
|
||||||
context['category'] = form.cleaned_data['category']
|
|
||||||
|
|
||||||
return super(SettingCategorySelectView, self).render_to_response(context)
|
|
||||||
|
|
||||||
return self.form_invalid(form)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseStatsView(AjaxView):
|
class DatabaseStatsView(AjaxView):
|
||||||
"""View for displaying database statistics."""
|
"""View for displaying database statistics."""
|
||||||
|
|
||||||
|
@ -223,7 +223,7 @@ class BuildUnallocate(generics.CreateAPIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
ctx['request'] = self.request
|
ctx['request'] = self.request
|
||||||
@ -243,7 +243,7 @@ class BuildOrderContextMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
@ -413,7 +413,7 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class BuildAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
@ -428,7 +428,7 @@ class BuildAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class BuildAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class BuildAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""Detail endpoint for a BuildOrderAttachment object."""
|
"""Detail endpoint for a BuildOrderAttachment object."""
|
||||||
|
|
||||||
queryset = BuildOrderAttachment.objects.all()
|
queryset = BuildOrderAttachment.objects.all()
|
||||||
|
@ -21,7 +21,7 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
build.reference_int = ref
|
build.reference_int = ref
|
||||||
|
@ -1244,13 +1244,13 @@ class BuildItem(models.Model):
|
|||||||
try:
|
try:
|
||||||
# Try to extract the thumbnail
|
# Try to extract the thumbnail
|
||||||
thumb_url = self.stock_item.part.image.thumbnail.url
|
thumb_url = self.stock_item.part.image.thumbnail.url
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
|
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
|
||||||
try:
|
try:
|
||||||
thumb_url = self.bom_item.sub_part.image.thumbnail.url
|
thumb_url = self.bom_item.sub_part.image.thumbnail.url
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if thumb_url is not None:
|
if thumb_url is not None:
|
||||||
|
@ -11,7 +11,7 @@ from rest_framework import serializers
|
|||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
|
||||||
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
|
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.helpers import extract_serial_numbers
|
from InvenTree.helpers import extract_serial_numbers
|
||||||
@ -40,7 +40,7 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
|
|||||||
|
|
||||||
overdue = serializers.BooleanField(required=False, read_only=True)
|
overdue = serializers.BooleanField(required=False, read_only=True)
|
||||||
|
|
||||||
issued_by_detail = UserSerializerBrief(source='issued_by', read_only=True)
|
issued_by_detail = UserSerializer(source='issued_by', read_only=True)
|
||||||
|
|
||||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
|
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
|
||||||
|
|
||||||
@ -860,6 +860,8 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
'filename',
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Background task definitions for the BuildOrder app"""
|
"""Background task definitions for the BuildOrder app"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -8,9 +9,12 @@ from django.template.loader import render_to_string
|
|||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
|
from plugin.events import trigger_event
|
||||||
|
import common.notifications
|
||||||
import build.models
|
import build.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
|
from InvenTree.status_codes import BuildStatus
|
||||||
from InvenTree.ready import isImportingData
|
from InvenTree.ready import isImportingData
|
||||||
|
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
@ -93,8 +97,67 @@ def check_build_stock(build: build.models.Build):
|
|||||||
# Render the HTML message
|
# Render the HTML message
|
||||||
html_message = render_to_string('email/build_order_required_stock.html', context)
|
html_message = render_to_string('email/build_order_required_stock.html', context)
|
||||||
|
|
||||||
subject = "[InvenTree] " + _("Stock required for build order")
|
subject = _("Stock required for build order")
|
||||||
|
|
||||||
recipients = emails.values_list('email', flat=True)
|
recipients = emails.values_list('email', flat=True)
|
||||||
|
|
||||||
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
|
InvenTree.tasks.send_email(subject, '', recipients, html_message=html_message)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_overdue_build_order(bo: build.models.Build):
|
||||||
|
"""Notify appropriate users that a Build has just become 'overdue'"""
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
if bo.issued_by:
|
||||||
|
targets.append(bo.issued_by)
|
||||||
|
|
||||||
|
if bo.responsible:
|
||||||
|
targets.append(bo.responsible)
|
||||||
|
|
||||||
|
name = _('Overdue Build Order')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'order': bo,
|
||||||
|
'name': name,
|
||||||
|
'message': _(f"Build order {bo} is now overdue"),
|
||||||
|
'link': InvenTree.helpers.construct_absolute_url(
|
||||||
|
bo.get_absolute_url(),
|
||||||
|
),
|
||||||
|
'template': {
|
||||||
|
'html': 'email/overdue_build_order.html',
|
||||||
|
'subject': name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_name = 'build.overdue_build_order'
|
||||||
|
|
||||||
|
# Send a notification to the appropriate users
|
||||||
|
common.notifications.trigger_notification(
|
||||||
|
bo,
|
||||||
|
event_name,
|
||||||
|
targets=targets,
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register a matching event to the plugin system
|
||||||
|
trigger_event(event_name, build_order=bo.pk)
|
||||||
|
|
||||||
|
|
||||||
|
def check_overdue_build_orders():
|
||||||
|
"""Check if any outstanding BuildOrders have just become overdue
|
||||||
|
|
||||||
|
- This check is performed daily
|
||||||
|
- Look at the 'target_date' of any outstanding BuildOrder objects
|
||||||
|
- If the 'target_date' expired *yesterday* then the order is just out of date
|
||||||
|
"""
|
||||||
|
|
||||||
|
yesterday = datetime.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
overdue_orders = build.models.Build.objects.filter(
|
||||||
|
target_date=yesterday,
|
||||||
|
status__in=BuildStatus.ACTIVE_CODES
|
||||||
|
)
|
||||||
|
|
||||||
|
for bo in overdue_orders:
|
||||||
|
notify_overdue_build_order(bo)
|
||||||
|
@ -195,7 +195,7 @@ class BuildTest(BuildAPITest):
|
|||||||
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
self.assertEqual(self.build.incomplete_outputs.count(), 0)
|
||||||
|
|
||||||
# Create some more build outputs
|
# Create some more build outputs
|
||||||
for ii in range(10):
|
for _ in range(10):
|
||||||
self.build.create_build_output(10)
|
self.build.create_build_output(10)
|
||||||
|
|
||||||
# Check that we are in a known state
|
# Check that we are in a known state
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
"""Unit tests for the 'build' models"""
|
"""Unit tests for the 'build' models"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from InvenTree import status_codes as status
|
from InvenTree import status_codes as status
|
||||||
|
|
||||||
|
import common.models
|
||||||
|
import build.tasks
|
||||||
from build.models import Build, BuildItem, get_next_build_number
|
from build.models import Build, BuildItem, get_next_build_number
|
||||||
from part.models import Part, BomItem, BomItemSubstitute
|
from part.models import Part, BomItem, BomItemSubstitute
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
@ -14,6 +19,10 @@ from stock.models import StockItem
|
|||||||
class BuildTestBase(TestCase):
|
class BuildTestBase(TestCase):
|
||||||
"""Run some tests to ensure that the Build model is working properly."""
|
"""Run some tests to ensure that the Build model is working properly."""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'users',
|
||||||
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Initialize data to use for these tests.
|
"""Initialize data to use for these tests.
|
||||||
|
|
||||||
@ -84,7 +93,8 @@ class BuildTestBase(TestCase):
|
|||||||
reference=ref,
|
reference=ref,
|
||||||
title="This is a build",
|
title="This is a build",
|
||||||
part=self.assembly,
|
part=self.assembly,
|
||||||
quantity=10
|
quantity=10,
|
||||||
|
issued_by=get_user_model().objects.get(pk=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create some build output (StockItem) objects
|
# Create some build output (StockItem) objects
|
||||||
@ -450,8 +460,6 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
substitutes=True,
|
substitutes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# self.assertTrue(self.build.are_untracked_parts_allocated())
|
|
||||||
|
|
||||||
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
# self.assertEqual(self.build.allocated_stock.count(), 8)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||||
@ -471,3 +479,19 @@ class AutoAllocationTests(BuildTestBase):
|
|||||||
|
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_1), 0)
|
||||||
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
self.assertEqual(self.build.unallocated_quantity(self.bom_item_2), 0)
|
||||||
|
|
||||||
|
def test_overdue_notification(self):
|
||||||
|
"""Test sending of notifications when a build order is overdue."""
|
||||||
|
|
||||||
|
self.build.target_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
self.build.save()
|
||||||
|
|
||||||
|
# Check for overdue orders
|
||||||
|
build.tasks.check_overdue_build_orders()
|
||||||
|
|
||||||
|
message = common.models.NotificationMessage.objects.get(
|
||||||
|
category='build.overdue_build_order',
|
||||||
|
user__id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(message.name, 'Overdue Build Order')
|
||||||
|
@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView):
|
|||||||
'category',
|
'category',
|
||||||
'name',
|
'name',
|
||||||
'read',
|
'read',
|
||||||
|
'creation',
|
||||||
]
|
]
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
@ -27,5 +27,5 @@ class CommonConfig(AppConfig):
|
|||||||
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False):
|
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False):
|
||||||
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
|
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
|
||||||
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
|
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
@ -142,7 +142,7 @@ class FileManager:
|
|||||||
guess = self.guess_header(header, threshold=95)
|
guess = self.guess_header(header, threshold=95)
|
||||||
# Check if already present
|
# Check if already present
|
||||||
guess_exists = False
|
guess_exists = False
|
||||||
for idx, data in enumerate(headers):
|
for _idx, data in enumerate(headers):
|
||||||
if guess == data['guess']:
|
if guess == data['guess']:
|
||||||
guess_exists = True
|
guess_exists = True
|
||||||
break
|
break
|
||||||
|
@ -571,7 +571,7 @@ class BaseInvenTreeSetting(models.Model):
|
|||||||
# If a valid class has been found, see if it has registered an API URL
|
# If a valid class has been found, see if it has registered an API URL
|
||||||
try:
|
try:
|
||||||
return model_class.get_api_url()
|
return model_class.get_api_url()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@ -710,7 +710,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
|
|
||||||
'INVENTREE_INSTANCE': {
|
'INVENTREE_INSTANCE': {
|
||||||
'name': _('Server Instance Name'),
|
'name': _('Server Instance Name'),
|
||||||
'default': 'InvenTree server',
|
'default': 'InvenTree',
|
||||||
'description': _('String descriptor for the server instance'),
|
'description': _('String descriptor for the server instance'),
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -965,12 +965,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
},
|
},
|
||||||
|
|
||||||
'REPORT_ENABLE_TEST_REPORT': {
|
'REPORT_ENABLE_TEST_REPORT': {
|
||||||
'name': _('Test Reports'),
|
'name': _('Enable Test Reports'),
|
||||||
'description': _('Enable generation of test reports'),
|
'description': _('Enable generation of test reports'),
|
||||||
'default': True,
|
'default': True,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'REPORT_ATTACH_TEST_REPORT': {
|
||||||
|
'name': _('Attach Test Reports'),
|
||||||
|
'description': _('When printing a Test Report, attach a copy of the Test Report to the associated Stock Item'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
|
|
||||||
'STOCK_BATCH_CODE_TEMPLATE': {
|
'STOCK_BATCH_CODE_TEMPLATE': {
|
||||||
'name': _('Batch Code Template'),
|
'name': _('Batch Code Template'),
|
||||||
'description': _('Template for generating default batch codes for stock items'),
|
'description': _('Template for generating default batch codes for stock items'),
|
||||||
|
@ -3,11 +3,15 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from common.models import NotificationEntry, NotificationMessage
|
from common.models import NotificationEntry, NotificationMessage
|
||||||
from InvenTree.helpers import inheritors
|
from InvenTree.helpers import inheritors
|
||||||
from InvenTree.ready import isImportingData
|
from InvenTree.ready import isImportingData
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting
|
from plugin.models import NotificationUserSetting
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -266,7 +270,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
if isImportingData():
|
if isImportingData():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Resolve objekt reference
|
# Resolve object reference
|
||||||
obj_ref_value = getattr(obj, obj_ref)
|
obj_ref_value = getattr(obj, obj_ref)
|
||||||
|
|
||||||
# Try with some defaults
|
# Try with some defaults
|
||||||
@ -285,11 +289,33 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"Gathering users for notification '{category}'")
|
logger.info(f"Gathering users for notification '{category}'")
|
||||||
|
|
||||||
# Collect possible targets
|
# Collect possible targets
|
||||||
if not targets:
|
if not targets:
|
||||||
targets = target_fnc(*target_args, **target_kwargs)
|
targets = target_fnc(*target_args, **target_kwargs)
|
||||||
|
|
||||||
|
# Convert list of targets to a list of users
|
||||||
|
# (targets may include 'owner' or 'group' classes)
|
||||||
|
target_users = set()
|
||||||
|
|
||||||
if targets:
|
if targets:
|
||||||
|
for target in targets:
|
||||||
|
# User instance is provided
|
||||||
|
if isinstance(target, get_user_model()):
|
||||||
|
target_users.add(target)
|
||||||
|
# Group instance is provided
|
||||||
|
elif isinstance(target, Group):
|
||||||
|
for user in get_user_model().objects.filter(groups__name=target.name):
|
||||||
|
target_users.add(user)
|
||||||
|
# Owner instance (either 'user' or 'group' is provided)
|
||||||
|
elif isinstance(target, Owner):
|
||||||
|
for owner in target.get_related_owners(include_group=False):
|
||||||
|
target_users.add(owner.owner)
|
||||||
|
# Unhandled type
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown target passed to trigger_notification method: {target}")
|
||||||
|
|
||||||
|
if target_users:
|
||||||
logger.info(f"Sending notification '{category}' for '{str(obj)}'")
|
logger.info(f"Sending notification '{category}' for '{str(obj)}'")
|
||||||
|
|
||||||
# Collect possible methods
|
# Collect possible methods
|
||||||
@ -299,11 +325,12 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
|
|||||||
delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS)
|
delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS)
|
||||||
|
|
||||||
for method in delivery_methods:
|
for method in delivery_methods:
|
||||||
logger.info(f"Triggering method '{method.METHOD_NAME}'")
|
logger.info(f"Triggering notification method '{method.METHOD_NAME}'")
|
||||||
try:
|
try:
|
||||||
deliver_notification(method, obj, category, targets, context)
|
deliver_notification(method, obj, category, target_users, context)
|
||||||
except NotImplementedError as error:
|
except NotImplementedError as error:
|
||||||
raise error
|
# Allow any single notification method to fail, without failing the others
|
||||||
|
logger.error(error)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
|||||||
def get_targets(self):
|
def get_targets(self):
|
||||||
return [1, ]
|
return [1, ]
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError):
|
with self.assertLogs(logger='inventree', level='ERROR'):
|
||||||
self._notification_run(WrongImplementation)
|
self._notification_run(WrongImplementation)
|
||||||
|
|
||||||
|
|
||||||
@ -115,7 +115,7 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
|||||||
def get_targets(self):
|
def get_targets(self):
|
||||||
return [1, ]
|
return [1, ]
|
||||||
|
|
||||||
with self.assertRaises(NotImplementedError):
|
with self.assertLogs(logger='inventree', level='ERROR'):
|
||||||
self._notification_run(WrongImplementation)
|
self._notification_run(WrongImplementation)
|
||||||
|
|
||||||
# A integration test for notifications is provided in test_part.PartNotificationTest
|
# A integration test for notifications is provided in test_part.PartNotificationTest
|
||||||
|
@ -78,7 +78,7 @@ class SettingsTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
# check as_int
|
# check as_int
|
||||||
self.assertEqual(stale_days.as_int(), 0)
|
self.assertEqual(stale_days.as_int(), 0)
|
||||||
self.assertEqual(instance_obj.as_int(), 'InvenTree server') # not an int -> return default
|
self.assertEqual(instance_obj.as_int(), 'InvenTree') # not an int -> return default
|
||||||
|
|
||||||
# check as_bool
|
# check as_bool
|
||||||
self.assertEqual(report_test_obj.as_bool(), True)
|
self.assertEqual(report_test_obj.as_bool(), True)
|
||||||
@ -258,7 +258,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase):
|
|||||||
# Access via the API, and the default value should be received
|
# Access via the API, and the default value should be received
|
||||||
response = self.get(url, expected_code=200)
|
response = self.get(url, expected_code=200)
|
||||||
|
|
||||||
self.assertEqual(response.data['value'], 'InvenTree server')
|
self.assertEqual(response.data['value'], 'InvenTree')
|
||||||
|
|
||||||
# Now, the object should have been created in the DB
|
# Now, the object should have been created in the DB
|
||||||
self.patch(
|
self.patch(
|
||||||
|
@ -495,8 +495,12 @@ class FileManagementAjaxView(AjaxView):
|
|||||||
self.storage.current_step = self.steps.first
|
self.storage.current_step = self.steps.first
|
||||||
return self.renderJsonResponse(request)
|
return self.renderJsonResponse(request)
|
||||||
|
|
||||||
def renderJsonResponse(self, request, form=None, data={}, context=None):
|
def renderJsonResponse(self, request, form=None, data=None, context=None):
|
||||||
"""Always set the right templates before rendering."""
|
"""Always set the right templates before rendering."""
|
||||||
|
# Set default - see B006
|
||||||
|
if data is None:
|
||||||
|
data = {}
|
||||||
|
|
||||||
self.setTemplate()
|
self.setTemplate()
|
||||||
return super().renderJsonResponse(request, form=form, data=data, context=context)
|
return super().renderJsonResponse(request, form=form, data=data, context=context)
|
||||||
|
|
||||||
|
@ -158,6 +158,8 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
'link',
|
'link',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
@ -151,7 +151,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
|
|||||||
try:
|
try:
|
||||||
self.image = Image.open(response.raw).convert()
|
self.image = Image.open(response.raw).convert()
|
||||||
self.image.verify()
|
self.image.verify()
|
||||||
except:
|
except Exception:
|
||||||
form.add_error('url', _("Supplied URL is not a valid image file"))
|
form.add_error('url', _("Supplied URL is not a valid image file"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -363,7 +363,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
# Filter string defined for the StockLocationLabel object
|
# Filter string defined for the StockLocationLabel object
|
||||||
try:
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Skip if there was an error validating the filters...
|
# Skip if there was an error validating the filters...
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
@ -22,8 +22,12 @@ class TestReportTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
list_url = reverse('api-stockitem-testreport-list')
|
list_url = reverse('api-stockitem-testreport-list')
|
||||||
|
|
||||||
def do_list(self, filters={}):
|
def do_list(self, filters=None):
|
||||||
"""Helper function to request list of labels with provided filters"""
|
"""Helper function to request list of labels with provided filters"""
|
||||||
|
# Set default - see B006
|
||||||
|
if filters is None:
|
||||||
|
filters = {}
|
||||||
|
|
||||||
response = self.client.get(self.list_url, filters, format='json')
|
response = self.client.get(self.list_url, filters, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -295,7 +295,7 @@ class PurchaseOrderContextMixin:
|
|||||||
# Pass the purchase order through to the serializer for validation
|
# Pass the purchase order through to the serializer for validation
|
||||||
try:
|
try:
|
||||||
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
context['request'] = self.request
|
context['request'] = self.request
|
||||||
@ -527,7 +527,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
||||||
|
|
||||||
queryset = models.SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
@ -542,7 +542,7 @@ class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class SalesOrderAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""Detail endpoint for SalesOrderAttachment."""
|
"""Detail endpoint for SalesOrderAttachment."""
|
||||||
|
|
||||||
queryset = models.SalesOrderAttachment.objects.all()
|
queryset = models.SalesOrderAttachment.objects.all()
|
||||||
@ -857,7 +857,7 @@ class SalesOrderContextMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
@ -1050,13 +1050,13 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
|
|||||||
ctx['shipment'] = models.SalesOrderShipment.objects.get(
|
ctx['shipment'] = models.SalesOrderShipment.objects.get(
|
||||||
pk=self.kwargs.get('pk', None)
|
pk=self.kwargs.get('pk', None)
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
||||||
|
|
||||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
@ -1071,7 +1071,7 @@ class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class PurchaseOrderAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""Detail endpoint for a PurchaseOrderAttachment."""
|
"""Detail endpoint for a PurchaseOrderAttachment."""
|
||||||
|
|
||||||
queryset = models.PurchaseOrderAttachment.objects.all()
|
queryset = models.PurchaseOrderAttachment.objects.all()
|
||||||
|
@ -20,7 +20,7 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
@ -37,7 +37,7 @@ def build_refs(apps, schema_editor):
|
|||||||
if result and len(result.groups()) == 1:
|
if result and len(result.groups()) == 1:
|
||||||
try:
|
try:
|
||||||
ref = int(result.groups()[0])
|
ref = int(result.groups()[0])
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
ref = 0
|
ref = 0
|
||||||
|
|
||||||
order.reference_int = ref
|
order.reference_int = ref
|
||||||
|
@ -154,7 +154,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
|||||||
|
|
||||||
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
|
notes = MarkdownxField(blank=True, verbose_name=_('Notes'), help_text=_('Order notes'))
|
||||||
|
|
||||||
def get_total_price(self, target_currency=currency_code_default()):
|
def get_total_price(self, target_currency=None):
|
||||||
"""Calculates the total price of all order lines, and converts to the specified target currency.
|
"""Calculates the total price of all order lines, and converts to the specified target currency.
|
||||||
|
|
||||||
If not specified, the default system currency is used.
|
If not specified, the default system currency is used.
|
||||||
@ -162,6 +162,10 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
|
|||||||
If currency conversion fails (e.g. there are no valid conversion rates),
|
If currency conversion fails (e.g. there are no valid conversion rates),
|
||||||
then we simply return zero, rather than attempting some other calculation.
|
then we simply return zero, rather than attempting some other calculation.
|
||||||
"""
|
"""
|
||||||
|
# Set default - see B008
|
||||||
|
if target_currency is None:
|
||||||
|
target_currency = currency_code_default()
|
||||||
|
|
||||||
total = Money(0, target_currency)
|
total = Money(0, target_currency)
|
||||||
|
|
||||||
# gather name reference
|
# gather name reference
|
||||||
|
@ -630,6 +630,8 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
'filename',
|
'filename',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -1348,6 +1350,8 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
'link',
|
'link',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
|
136
InvenTree/order/tasks.py
Normal file
136
InvenTree/order/tasks.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Background tasks for the 'order' app"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import common.notifications
|
||||||
|
import InvenTree.helpers
|
||||||
|
import InvenTree.tasks
|
||||||
|
import order.models
|
||||||
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
from plugin.events import trigger_event
|
||||||
|
|
||||||
|
|
||||||
|
def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
|
||||||
|
"""Notify users that a PurchaseOrder has just become 'overdue'"""
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
if po.created_by:
|
||||||
|
targets.append(po.created_by)
|
||||||
|
|
||||||
|
if po.responsible:
|
||||||
|
targets.append(po.responsible)
|
||||||
|
|
||||||
|
name = _('Overdue Purchase Order')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'order': po,
|
||||||
|
'name': name,
|
||||||
|
'message': _(f'Purchase order {po} is now overdue'),
|
||||||
|
'link': InvenTree.helpers.construct_absolute_url(
|
||||||
|
po.get_absolute_url(),
|
||||||
|
),
|
||||||
|
'template': {
|
||||||
|
'html': 'email/overdue_purchase_order.html',
|
||||||
|
'subject': name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_name = 'order.overdue_purchase_order'
|
||||||
|
|
||||||
|
# Send a notification to the appropriate users
|
||||||
|
common.notifications.trigger_notification(
|
||||||
|
po,
|
||||||
|
event_name,
|
||||||
|
targets=targets,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register a matching event to the plugin system
|
||||||
|
trigger_event(
|
||||||
|
event_name,
|
||||||
|
purchase_order=po.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_overdue_purchase_orders():
|
||||||
|
"""Check if any outstanding PurchaseOrders have just become overdue:
|
||||||
|
|
||||||
|
- This check is performed daily
|
||||||
|
- Look at the 'target_date' of any outstanding PurchaseOrder objects
|
||||||
|
- If the 'target_date' expired *yesterday* then the order is just out of date
|
||||||
|
"""
|
||||||
|
|
||||||
|
yesterday = datetime.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
overdue_orders = order.models.PurchaseOrder.objects.filter(
|
||||||
|
target_date=yesterday,
|
||||||
|
status__in=PurchaseOrderStatus.OPEN
|
||||||
|
)
|
||||||
|
|
||||||
|
for po in overdue_orders:
|
||||||
|
notify_overdue_purchase_order(po)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_overdue_sales_order(so: order.models.SalesOrder):
|
||||||
|
"""Notify appropriate users that a SalesOrder has just become 'overdue'"""
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
if so.created_by:
|
||||||
|
targets.append(so.created_by)
|
||||||
|
|
||||||
|
if so.responsible:
|
||||||
|
targets.append(so.responsible)
|
||||||
|
|
||||||
|
name = _('Overdue Sales Order')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'order': so,
|
||||||
|
'name': name,
|
||||||
|
'message': _(f"Sales order {so} is now overdue"),
|
||||||
|
'link': InvenTree.helpers.construct_absolute_url(
|
||||||
|
so.get_absolute_url(),
|
||||||
|
),
|
||||||
|
'template': {
|
||||||
|
'html': 'email/overdue_sales_order.html',
|
||||||
|
'subject': name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_name = 'order.overdue_sales_order'
|
||||||
|
|
||||||
|
# Send a notification to the appropriate users
|
||||||
|
common.notifications.trigger_notification(
|
||||||
|
so,
|
||||||
|
event_name,
|
||||||
|
targets=targets,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register a matching event to the plugin system
|
||||||
|
trigger_event(
|
||||||
|
event_name,
|
||||||
|
sales_order=so.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_overdue_sales_orders():
|
||||||
|
"""Check if any outstanding SalesOrders have just become overdue
|
||||||
|
|
||||||
|
- This check is performed daily
|
||||||
|
- Look at the 'target_date' of any outstanding SalesOrder objects
|
||||||
|
- If the 'target_date' expired *yesterday* then the order is just out of date
|
||||||
|
"""
|
||||||
|
|
||||||
|
yesterday = datetime.now().date() - timedelta(days=1)
|
||||||
|
|
||||||
|
overdue_orders = order.models.SalesOrder.objects.filter(
|
||||||
|
target_date=yesterday,
|
||||||
|
status__in=SalesOrderStatus.OPEN
|
||||||
|
)
|
||||||
|
|
||||||
|
for po in overdue_orders:
|
||||||
|
notify_overdue_sales_order(po)
|
@ -135,7 +135,7 @@
|
|||||||
},
|
},
|
||||||
label: 'attachment',
|
label: 'attachment',
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
location.reload();
|
$('#attachment-table').bootstrapTable('refresh');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -2,21 +2,29 @@
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
import order.tasks
|
||||||
|
from common.models import InvenTreeSetting, NotificationMessage
|
||||||
from company.models import Company
|
from company.models import Company
|
||||||
from InvenTree import status_codes as status
|
from InvenTree import status_codes as status
|
||||||
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem,
|
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem,
|
||||||
SalesOrderShipment)
|
SalesOrderShipment)
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderTest(TestCase):
|
class SalesOrderTest(TestCase):
|
||||||
"""Run tests to ensure that the SalesOrder model is working correctly."""
|
"""Run tests to ensure that the SalesOrder model is working correctly."""
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'users',
|
||||||
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Initial setup for this set of unit tests"""
|
"""Initial setup for this set of unit tests"""
|
||||||
# Create a Company to ship the goods to
|
# Create a Company to ship the goods to
|
||||||
@ -235,3 +243,20 @@ class SalesOrderTest(TestCase):
|
|||||||
|
|
||||||
# Shipment should have default reference of '1'
|
# Shipment should have default reference of '1'
|
||||||
self.assertEqual('1', order_2.pending_shipments()[0].reference)
|
self.assertEqual('1', order_2.pending_shipments()[0].reference)
|
||||||
|
|
||||||
|
def test_overdue_notification(self):
|
||||||
|
"""Test overdue sales order notification"""
|
||||||
|
|
||||||
|
self.order.created_by = get_user_model().objects.get(pk=3)
|
||||||
|
self.order.responsible = Owner.create(obj=Group.objects.get(pk=2))
|
||||||
|
self.order.target_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
self.order.save()
|
||||||
|
|
||||||
|
# Check for overdue sales orders
|
||||||
|
order.tasks.check_overdue_sales_orders()
|
||||||
|
|
||||||
|
messages = NotificationMessage.objects.filter(
|
||||||
|
category='order.overdue_sales_order',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
@ -3,12 +3,17 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
import common.models
|
||||||
|
import order.tasks
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
from .models import PurchaseOrder, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderLineItem
|
||||||
|
|
||||||
@ -24,7 +29,8 @@ class OrderTest(TestCase):
|
|||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
'stock',
|
'stock',
|
||||||
'order'
|
'order',
|
||||||
|
'users',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_basics(self):
|
def test_basics(self):
|
||||||
@ -197,3 +203,37 @@ class OrderTest(TestCase):
|
|||||||
order.receive_line_item(line, loc, line.quantity, user=None)
|
order.receive_line_item(line, loc, line.quantity, user=None)
|
||||||
|
|
||||||
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)
|
self.assertEqual(order.status, PurchaseOrderStatus.COMPLETE)
|
||||||
|
|
||||||
|
def test_overdue_notification(self):
|
||||||
|
"""Test overdue purchase order notification
|
||||||
|
|
||||||
|
Ensure that a notification is sent when a PurchaseOrder becomes overdue
|
||||||
|
"""
|
||||||
|
po = PurchaseOrder.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Created by 'sam'
|
||||||
|
po.created_by = get_user_model().objects.get(pk=4)
|
||||||
|
|
||||||
|
# Responsible : 'Engineers' group
|
||||||
|
responsible = Owner.create(obj=Group.objects.get(pk=2))
|
||||||
|
po.responsible = responsible
|
||||||
|
|
||||||
|
# Target date = yesterday
|
||||||
|
po.target_date = datetime.now().date() - timedelta(days=1)
|
||||||
|
po.save()
|
||||||
|
|
||||||
|
# Check for overdue purchase orders
|
||||||
|
order.tasks.check_overdue_purchase_orders()
|
||||||
|
|
||||||
|
for user_id in [2, 3, 4]:
|
||||||
|
messages = common.models.NotificationMessage.objects.filter(
|
||||||
|
category='order.overdue_purchase_order',
|
||||||
|
user__id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(messages.exists())
|
||||||
|
|
||||||
|
msg = messages.first()
|
||||||
|
|
||||||
|
self.assertEqual(msg.target_object_id, 1)
|
||||||
|
self.assertEqual(msg.name, 'Overdue Purchase Order')
|
||||||
|
@ -194,7 +194,7 @@ class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
|||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterList(generics.ListAPIView):
|
class CategoryParameterList(generics.ListCreateAPIView):
|
||||||
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||||
|
|
||||||
- GET: Return a list of PartCategoryParameterTemplate objects
|
- GET: Return a list of PartCategoryParameterTemplate objects
|
||||||
@ -235,6 +235,13 @@ class CategoryParameterList(generics.ListAPIView):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
"""Detail endpoint fro the PartCategoryParameterTemplate model"""
|
||||||
|
|
||||||
|
queryset = PartCategoryParameterTemplate.objects.all()
|
||||||
|
serializer_class = part_serializers.CategoryParameterTemplateSerializer
|
||||||
|
|
||||||
|
|
||||||
class CategoryTree(generics.ListAPIView):
|
class CategoryTree(generics.ListAPIView):
|
||||||
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
||||||
|
|
||||||
@ -295,7 +302,7 @@ class PartInternalPriceList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class PartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
|
"""API endpoint for listing (and creating) a PartAttachment (file upload)."""
|
||||||
|
|
||||||
queryset = PartAttachment.objects.all()
|
queryset = PartAttachment.objects.all()
|
||||||
@ -310,7 +317,7 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class PartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""Detail endpoint for PartAttachment model."""
|
"""Detail endpoint for PartAttachment model."""
|
||||||
|
|
||||||
queryset = PartAttachment.objects.all()
|
queryset = PartAttachment.objects.all()
|
||||||
@ -599,7 +606,7 @@ class PartCopyBOM(generics.CreateAPIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
|
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
@ -1035,12 +1042,12 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
|
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
|
||||||
except:
|
except Exception:
|
||||||
manufacturer = None
|
manufacturer = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
supplier = Company.objects.get(pk=request.data.get('supplier', None))
|
supplier = Company.objects.get(pk=request.data.get('supplier', None))
|
||||||
except:
|
except Exception:
|
||||||
supplier = None
|
supplier = None
|
||||||
|
|
||||||
mpn = str(request.data.get('MPN', '')).strip()
|
mpn = str(request.data.get('MPN', '')).strip()
|
||||||
@ -1855,7 +1862,11 @@ part_api_urls = [
|
|||||||
# Base URL for PartCategory API endpoints
|
# Base URL for PartCategory API endpoints
|
||||||
re_path(r'^category/', include([
|
re_path(r'^category/', include([
|
||||||
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
||||||
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
|
||||||
|
re_path(r'^parameters/', include([
|
||||||
|
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
|
||||||
|
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Category detail endpoints
|
# Category detail endpoints
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
|
@ -3,15 +3,12 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from mptt.fields import TreeNodeChoiceField
|
|
||||||
|
|
||||||
from common.forms import MatchItemForm
|
from common.forms import MatchItemForm
|
||||||
from InvenTree.fields import RoundingDecimalFormField
|
from InvenTree.fields import RoundingDecimalFormField
|
||||||
from InvenTree.forms import HelperForm
|
from InvenTree.forms import HelperForm
|
||||||
from InvenTree.helpers import clean_decimal
|
from InvenTree.helpers import clean_decimal
|
||||||
|
|
||||||
from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
from .models import Part, PartInternalPriceBreak, PartSellPriceBreak
|
||||||
PartInternalPriceBreak, PartSellPriceBreak)
|
|
||||||
|
|
||||||
|
|
||||||
class PartImageDownloadForm(HelperForm):
|
class PartImageDownloadForm(HelperForm):
|
||||||
@ -53,35 +50,6 @@ class BomMatchItemForm(MatchItemForm):
|
|||||||
return super().get_special_field(col_guess, row, file_manager)
|
return super().get_special_field(col_guess, row, file_manager)
|
||||||
|
|
||||||
|
|
||||||
class SetPartCategoryForm(forms.Form):
|
|
||||||
"""Form for setting the category of multiple Part objects."""
|
|
||||||
|
|
||||||
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
|
||||||
|
|
||||||
|
|
||||||
class EditCategoryParameterTemplateForm(HelperForm):
|
|
||||||
"""Form for editing a PartCategoryParameterTemplate object."""
|
|
||||||
|
|
||||||
add_to_same_level_categories = forms.BooleanField(required=False,
|
|
||||||
initial=False,
|
|
||||||
help_text=_('Add parameter template to same level categories'))
|
|
||||||
|
|
||||||
add_to_all_categories = forms.BooleanField(required=False,
|
|
||||||
initial=False,
|
|
||||||
help_text=_('Add parameter template to all categories'))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass defines fields for this form"""
|
|
||||||
model = PartCategoryParameterTemplate
|
|
||||||
fields = [
|
|
||||||
'category',
|
|
||||||
'parameter_template',
|
|
||||||
'default_value',
|
|
||||||
'add_to_same_level_categories',
|
|
||||||
'add_to_all_categories',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PartPriceForm(forms.Form):
|
class PartPriceForm(forms.Form):
|
||||||
"""Simple form for viewing part pricing information."""
|
"""Simple form for viewing part pricing information."""
|
||||||
|
|
||||||
|
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal file
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-06-06 00:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0077_alter_bomitem_unique_together'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partrelated',
|
||||||
|
name='part_1',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_1', to='part.part', verbose_name='Part 1'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='partrelated',
|
||||||
|
name='part_2',
|
||||||
|
field=models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_2', to='part.part', verbose_name='Part 2'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='partrelated',
|
||||||
|
unique_together={('part_1', 'part_2')},
|
||||||
|
),
|
||||||
|
]
|
@ -593,7 +593,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
try:
|
try:
|
||||||
latest = int(latest)
|
latest = int(latest)
|
||||||
return latest
|
return latest
|
||||||
except:
|
except Exception:
|
||||||
# not an integer so 0
|
# not an integer so 0
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@ -610,7 +610,7 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
# Attempt to turn into an integer
|
# Attempt to turn into an integer
|
||||||
try:
|
try:
|
||||||
latest = int(latest)
|
latest = int(latest)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if type(latest) is int:
|
if type(latest) is int:
|
||||||
@ -1529,17 +1529,12 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
"""Return the number of supplier parts available for this part."""
|
"""Return the number of supplier parts available for this part."""
|
||||||
return self.supplier_parts.count()
|
return self.supplier_parts.count()
|
||||||
|
|
||||||
@property
|
|
||||||
def has_pricing_info(self, internal=False):
|
|
||||||
"""Return true if there is pricing information for this part."""
|
|
||||||
return self.get_price_range(internal=internal) is not None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_complete_bom_pricing(self):
|
def has_complete_bom_pricing(self):
|
||||||
"""Return true if there is pricing information for each item in the BOM."""
|
"""Return true if there is pricing information for each item in the BOM."""
|
||||||
use_internal = common.models.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
for item in self.get_bom_items().all().select_related('sub_part'):
|
for item in self.get_bom_items().all().select_related('sub_part'):
|
||||||
if not item.sub_part.has_pricing_info(use_internal):
|
if item.sub_part.get_price_range(internal=use_internal) is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -2037,27 +2032,20 @@ class Part(MetadataMixin, MPTTModel):
|
|||||||
return filtered_parts
|
return filtered_parts
|
||||||
|
|
||||||
def get_related_parts(self):
|
def get_related_parts(self):
|
||||||
"""Return list of tuples for all related parts.
|
"""Return a set of all related parts for this part"""
|
||||||
|
related_parts = set()
|
||||||
Includes:
|
|
||||||
- first value is PartRelated object
|
|
||||||
- second value is matching Part object
|
|
||||||
"""
|
|
||||||
related_parts = []
|
|
||||||
|
|
||||||
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
|
related_parts_1 = self.related_parts_1.filter(part_1__id=self.pk)
|
||||||
|
|
||||||
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
related_parts_2 = self.related_parts_2.filter(part_2__id=self.pk)
|
||||||
|
|
||||||
related_parts.append()
|
|
||||||
|
|
||||||
for related_part in related_parts_1:
|
for related_part in related_parts_1:
|
||||||
# Add to related parts list
|
# Add to related parts list
|
||||||
related_parts.append(related_part.part_2)
|
related_parts.add(related_part.part_2)
|
||||||
|
|
||||||
for related_part in related_parts_2:
|
for related_part in related_parts_2:
|
||||||
# Add to related parts list
|
# Add to related parts list
|
||||||
related_parts.append(related_part.part_1)
|
related_parts.add(related_part.part_1)
|
||||||
|
|
||||||
return related_parts
|
return related_parts
|
||||||
|
|
||||||
@ -2283,7 +2271,7 @@ class PartTestTemplate(models.Model):
|
|||||||
|
|
||||||
def validate_template_name(name):
|
def validate_template_name(name):
|
||||||
"""Prevent illegal characters in "name" field for PartParameterTemplate."""
|
"""Prevent illegal characters in "name" field for PartParameterTemplate."""
|
||||||
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"":
|
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"": # noqa: P103
|
||||||
if c in str(name):
|
if c in str(name):
|
||||||
raise ValidationError(_(f"Illegal character in template name ({c})"))
|
raise ValidationError(_(f"Illegal character in template name ({c})"))
|
||||||
|
|
||||||
@ -2383,7 +2371,9 @@ class PartParameter(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class PartCategoryParameterTemplate(models.Model):
|
class PartCategoryParameterTemplate(models.Model):
|
||||||
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
|
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate.
|
||||||
|
|
||||||
|
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
category: Reference to a single PartCategory object
|
category: Reference to a single PartCategory object
|
||||||
@ -2827,44 +2817,35 @@ class BomItemSubstitute(models.Model):
|
|||||||
class PartRelated(models.Model):
|
class PartRelated(models.Model):
|
||||||
"""Store and handle related parts (eg. mating connector, crimps, etc.)."""
|
"""Store and handle related parts (eg. mating connector, crimps, etc.)."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines extra model properties"""
|
||||||
|
unique_together = ('part_1', 'part_2')
|
||||||
|
|
||||||
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
|
part_1 = models.ForeignKey(Part, related_name='related_parts_1',
|
||||||
verbose_name=_('Part 1'), on_delete=models.DO_NOTHING)
|
verbose_name=_('Part 1'), on_delete=models.CASCADE)
|
||||||
|
|
||||||
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
|
part_2 = models.ForeignKey(Part, related_name='related_parts_2',
|
||||||
on_delete=models.DO_NOTHING,
|
on_delete=models.CASCADE,
|
||||||
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return a string representation of this Part-Part relationship"""
|
"""Return a string representation of this Part-Part relationship"""
|
||||||
return f'{self.part_1} <--> {self.part_2}'
|
return f'{self.part_1} <--> {self.part_2}'
|
||||||
|
|
||||||
def validate(self, part_1, part_2):
|
def save(self, *args, **kwargs):
|
||||||
"""Validate that the two parts relationship is unique."""
|
"""Enforce a 'clean' operation when saving a PartRelated instance"""
|
||||||
validate = True
|
self.clean()
|
||||||
|
self.validate_unique()
|
||||||
parts = Part.objects.all()
|
super().save(*args, **kwargs)
|
||||||
related_parts = PartRelated.objects.all()
|
|
||||||
|
|
||||||
# Check if part exist and there are not the same part
|
|
||||||
if (part_1 in parts and part_2 in parts) and (part_1.pk != part_2.pk):
|
|
||||||
# Check if relation exists already
|
|
||||||
for relation in related_parts:
|
|
||||||
if (part_1 == relation.part_1 and part_2 == relation.part_2) \
|
|
||||||
or (part_1 == relation.part_2 and part_2 == relation.part_1):
|
|
||||||
validate = False
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
validate = False
|
|
||||||
|
|
||||||
return validate
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Overwrite clean method to check that relation is unique."""
|
"""Overwrite clean method to check that relation is unique."""
|
||||||
validate = self.validate(self.part_1, self.part_2)
|
|
||||||
|
|
||||||
if not validate:
|
super().clean()
|
||||||
error_message = _('Error creating relationship: check that '
|
|
||||||
'the part is not related to itself '
|
|
||||||
'and that the relationship is unique')
|
|
||||||
|
|
||||||
raise ValidationError(error_message)
|
if self.part_1 == self.part_2:
|
||||||
|
raise ValidationError(_("Part relationship cannot be created between a part and itself"))
|
||||||
|
|
||||||
|
# Check for inverse relationship
|
||||||
|
if PartRelated.objects.filter(part_1=self.part_2, part_2=self.part_1).exists():
|
||||||
|
raise ValidationError(_("Duplicate relationship already exists"))
|
||||||
|
@ -94,6 +94,8 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
|||||||
'link',
|
'link',
|
||||||
'comment',
|
'comment',
|
||||||
'upload_date',
|
'upload_date',
|
||||||
|
'user',
|
||||||
|
'user_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -753,10 +755,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for PartCategoryParameterTemplate."""
|
"""Serializer for the PartCategoryParameterTemplate model."""
|
||||||
|
|
||||||
parameter_template = PartParameterTemplateSerializer(many=False,
|
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template', many=False, read_only=True)
|
||||||
read_only=True)
|
|
||||||
|
|
||||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||||
|
|
||||||
@ -768,6 +769,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
|||||||
'category',
|
'category',
|
||||||
'category_detail',
|
'category_detail',
|
||||||
'parameter_template',
|
'parameter_template',
|
||||||
|
'parameter_template_detail',
|
||||||
'default_value',
|
'default_value',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -902,7 +904,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
|
|||||||
if level != 1:
|
if level != 1:
|
||||||
# Skip this row
|
# Skip this row
|
||||||
return None
|
return None
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Attempt to extract a valid part based on the provided data
|
# Attempt to extract a valid part based on the provided data
|
||||||
@ -954,7 +956,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
|
|||||||
|
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
row['errors']['quantity'] = _('Quantity must be greater than zero')
|
row['errors']['quantity'] = _('Quantity must be greater than zero')
|
||||||
except:
|
except Exception:
|
||||||
row['errors']['quantity'] = _('Invalid quantity')
|
row['errors']['quantity'] = _('Invalid quantity')
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
@ -27,7 +27,7 @@ def notify_low_stock(part: part.models.Part):
|
|||||||
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
|
'link': InvenTree.helpers.construct_absolute_url(part.get_absolute_url()),
|
||||||
'template': {
|
'template': {
|
||||||
'html': 'email/low_stock_notification.html',
|
'html': 'email/low_stock_notification.html',
|
||||||
'subject': "[InvenTree] " + name,
|
'subject': name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,7 +165,7 @@
|
|||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle="dropdown">
|
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle="dropdown">
|
||||||
{% trans "Options" %}
|
<span class='fas fa-tools' title='{% trans "Options" %}'></span>
|
||||||
</button>
|
</button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
@ -378,7 +378,6 @@
|
|||||||
{% else %}category: "null",
|
{% else %}category: "null",
|
||||||
{% endif %}
|
{% endif %}
|
||||||
},
|
},
|
||||||
buttons: ['#part-options'],
|
|
||||||
checkbox: true,
|
checkbox: true,
|
||||||
gridView: true,
|
gridView: true,
|
||||||
},
|
},
|
||||||
|
@ -559,13 +559,13 @@
|
|||||||
|
|
||||||
{% if roles.part.delete %}
|
{% if roles.part.delete %}
|
||||||
$("#part-delete").click(function() {
|
$("#part-delete").click(function() {
|
||||||
launchModalForm(
|
deletePart({{ part.pk }}, {
|
||||||
"{% url 'part-delete' part.id %}",
|
{% if part.category %}
|
||||||
{
|
redirect: '{% url "category-detail" part.category.pk %}',
|
||||||
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
|
{% else %}
|
||||||
no_post: {% if part.active %}true{% else %}false{% endif %},
|
redirect: '{% url "part-index" %}',
|
||||||
}
|
{% endif %}
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -60,6 +60,7 @@
|
|||||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% if min_total_bom_purchase_price %}
|
{% if min_total_bom_purchase_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{% trans 'Unit Purchase Price' %}</strong></td>
|
<td><strong>{% trans 'Unit Purchase Price' %}</strong></td>
|
||||||
@ -75,13 +76,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.has_complete_bom_pricing == False %}
|
{% if not part.has_complete_bom_pricing %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='3'>
|
<td colspan='3'>
|
||||||
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if min_total_bom_price or min_total_bom_purchase_price %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='3'>
|
<td colspan='3'>
|
||||||
@ -122,7 +125,7 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if min_unit_buy_price or min_unit_bom_price %}
|
{% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-danger alert-block'>
|
<div class='alert alert-danger alert-block'>
|
||||||
{% trans 'No pricing information is available for this part.' %}
|
{% trans 'No pricing information is available for this part.' %}
|
||||||
|
@ -1,78 +0,0 @@
|
|||||||
{% extends "modal_form.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block pre_form_content %}
|
|
||||||
|
|
||||||
{% if part.active %}
|
|
||||||
|
|
||||||
<div class='alert alert-block alert-danger'>
|
|
||||||
{% blocktrans with full_name=part.full_name %}Part '<strong>{{full_name}}</strong>' cannot be deleted as it is still marked as <strong>active</strong>.
|
|
||||||
<br>Disable the "Active" part attribute and re-try.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
<div class='alert alert-block alert-danger'>
|
|
||||||
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<strong>{{full_name}}</strong>'?{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if part.used_in_count %}
|
|
||||||
<hr>
|
|
||||||
<p>{% blocktrans with count=part.used_in_count %}This part is used in BOMs for {{count}} other parts. If you delete this part, the BOMs for the following parts will be updated{% endblocktrans %}:
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for child in part.used_in.all %}
|
|
||||||
<li class='list-group-item'>{{ child.part.full_name }} - {{ child.part.description }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if part.stock_items.all|length > 0 %}
|
|
||||||
<hr>
|
|
||||||
<p>{% blocktrans with count=part.stock_items.all|length %}There are {{count}} stock entries defined for this part. If you delete this part, the following stock entries will also be deleted:{% endblocktrans %}
|
|
||||||
<ul class='list-group'>
|
|
||||||
{% for stock in part.stock_items.all %}
|
|
||||||
<li class='list-group-item'>{{ stock }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if part.manufacturer_parts.all|length > 0 %}
|
|
||||||
<hr>
|
|
||||||
<p>{% blocktrans with count=part.manufacturer_parts.all|length %}There are {{count}} manufacturers defined for this part. If you delete this part, the following manufacturer parts will also be deleted:{% endblocktrans %}
|
|
||||||
<ul class='list-group'>
|
|
||||||
{% for spart in part.manufacturer_parts.all %}
|
|
||||||
<li class='list-group-item'>{% if spart.manufacturer %}{{ spart.manufacturer.name }} - {% endif %}{{ spart.MPN }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if part.supplier_parts.all|length > 0 %}
|
|
||||||
<hr>
|
|
||||||
<p>{% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this part. If you delete this part, the following supplier parts will also be deleted:{% endblocktrans %}
|
|
||||||
<ul class='list-group'>
|
|
||||||
{% for spart in part.supplier_parts.all %}
|
|
||||||
{% if spart.supplier %}
|
|
||||||
<li class='list-group-item'>{{ spart.supplier.name }} - {{ spart.SKU }}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if part.serials.all|length > 0 %}
|
|
||||||
<hr>
|
|
||||||
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block form %}
|
|
||||||
{% if not part.active %}
|
|
||||||
{{ block.super }}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
@ -64,9 +64,9 @@
|
|||||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if min_total_bom_purchase_price %}
|
||||||
{% if min_total_bom_purchase_price %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{% trans 'Unit Purchase Price' %}</td>
|
<td>{% trans 'Unit Purchase Price' %}</td>
|
||||||
@ -81,15 +81,17 @@
|
|||||||
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
|
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if part.has_complete_bom_pricing == False %}
|
{% if not part.has_complete_bom_pricing %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='4'>
|
<td colspan='4'>
|
||||||
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if min_total_bom_price or min_total_bom_purchase_price %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan='4'>
|
<td colspan='4'>
|
||||||
@ -131,7 +133,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% if min_unit_buy_price or min_unit_bom_price %}
|
{% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class='alert alert-danger alert-block'>
|
<div class='alert alert-danger alert-block'>
|
||||||
{% trans 'No pricing information is available for this part.' %}
|
{% trans 'No pricing information is available for this part.' %}
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
{% extends "modal_form.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block form %}
|
|
||||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
|
||||||
{% load crispy_forms_tags %}
|
|
||||||
|
|
||||||
<label class='control-label'>Parts</label>
|
|
||||||
<p class='help-block'>{% trans "Set category for the following parts" %}</p>
|
|
||||||
|
|
||||||
<table class='table table-striped'>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Part" %}</th>
|
|
||||||
<th>{% trans "Description" %}</th>
|
|
||||||
<th>{% trans "Category" %}</th>
|
|
||||||
<th>
|
|
||||||
</tr>
|
|
||||||
{% for part in parts %}
|
|
||||||
<tr id='part_row_{{ part.id }}'>
|
|
||||||
<input type='hidden' name='part_id_{{ part.id }}' value='1'/>
|
|
||||||
<td>
|
|
||||||
{% include "hover_image.html" with image=part.image hover=False %}
|
|
||||||
{{ part.full_name }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ part.description }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ part.category.pathstring }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromModalForm()' title='{% trans "Remove part" %}' type='button'>
|
|
||||||
<span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% crispy form %}
|
|
||||||
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
@ -412,7 +412,7 @@ def primitive_to_javascript(primitive):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Wrap with quotes
|
# Wrap with quotes
|
||||||
return format_html("'{}'", primitive)
|
return format_html("'{}'", primitive) # noqa: P103
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
@ -458,7 +458,7 @@ def authorized_owners(group):
|
|||||||
def object_link(url_name, pk, ref):
|
def object_link(url_name, pk, ref):
|
||||||
"""Return highlighted link to object."""
|
"""Return highlighted link to object."""
|
||||||
ref_url = reverse(url_name, kwargs={'pk': pk})
|
ref_url = reverse(url_name, kwargs={'pk': pk})
|
||||||
return mark_safe('<b><a href="{}">{}</a></b>'.format(ref_url, ref))
|
return mark_safe(f'<b><a href="{ref_url}">{ref}</a></b>')
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag()
|
@register.simple_tag()
|
||||||
|
@ -14,6 +14,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
|||||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||||
StockStatus)
|
StockStatus)
|
||||||
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
|
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
|
||||||
|
PartCategoryParameterTemplate, PartParameterTemplate,
|
||||||
PartRelated)
|
PartRelated)
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
|
'params',
|
||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
'company',
|
'company',
|
||||||
@ -40,6 +42,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
'part.delete',
|
'part.delete',
|
||||||
'part_category.change',
|
'part_category.change',
|
||||||
'part_category.add',
|
'part_category.add',
|
||||||
|
'part_category.delete',
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_category_list(self):
|
def test_category_list(self):
|
||||||
@ -94,6 +97,57 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(metadata['water'], 'melon')
|
self.assertEqual(metadata['water'], 'melon')
|
||||||
self.assertEqual(metadata['abc'], 'ABC')
|
self.assertEqual(metadata['abc'], 'ABC')
|
||||||
|
|
||||||
|
def test_category_parameters(self):
|
||||||
|
"""Test that the PartCategoryParameterTemplate API function work"""
|
||||||
|
|
||||||
|
url = reverse('api-part-category-parameter-list')
|
||||||
|
|
||||||
|
response = self.get(url, {}, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
# Add some more category templates via the API
|
||||||
|
n = PartParameterTemplate.objects.count()
|
||||||
|
|
||||||
|
for template in PartParameterTemplate.objects.all():
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'category': 2,
|
||||||
|
'parameter_template': template.pk,
|
||||||
|
'default_value': 'xyz',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total number of category templates should have increased
|
||||||
|
response = self.get(url, {}, expected_code=200)
|
||||||
|
self.assertEqual(len(response.data), 2 + n)
|
||||||
|
|
||||||
|
# Filter by category
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'category': 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), n)
|
||||||
|
|
||||||
|
# Test that we can retrieve individual templates via the API
|
||||||
|
for template in PartCategoryParameterTemplate.objects.all():
|
||||||
|
url = reverse('api-part-category-parameter-detail', kwargs={'pk': template.pk})
|
||||||
|
|
||||||
|
data = self.get(url, {}, expected_code=200).data
|
||||||
|
|
||||||
|
for key in ['pk', 'category', 'category_detail', 'parameter_template', 'parameter_template_detail', 'default_value']:
|
||||||
|
self.assertIn(key, data.keys())
|
||||||
|
|
||||||
|
# Test that we can delete via the API also
|
||||||
|
response = self.delete(url, expected_code=204)
|
||||||
|
|
||||||
|
# There should not be any templates left at this point
|
||||||
|
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||||
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
||||||
@ -1231,7 +1285,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(data['stock_item_count'], 4)
|
self.assertEqual(data['stock_item_count'], 4)
|
||||||
|
|
||||||
# Add some more stock items!!
|
# Add some more stock items!!
|
||||||
for i in range(100):
|
for _ in range(100):
|
||||||
StockItem.objects.create(part=self.part, quantity=5)
|
StockItem.objects.create(part=self.part, quantity=5)
|
||||||
|
|
||||||
# Add another stock item which is assigned to a customer (and shouldn't count)
|
# Add another stock item which is assigned to a customer (and shouldn't count)
|
||||||
@ -1574,7 +1628,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
|
|
||||||
# Create some stock items for this new part
|
# Create some stock items for this new part
|
||||||
for jj in range(ii):
|
for _ in range(ii):
|
||||||
StockItem.objects.create(
|
StockItem.objects.create(
|
||||||
part=variant,
|
part=variant,
|
||||||
location=loc,
|
location=loc,
|
||||||
|
@ -228,7 +228,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
components = Part.objects.filter(component=True)
|
components = Part.objects.filter(component=True)
|
||||||
|
|
||||||
for idx, cmp in enumerate(components):
|
for idx, _ in enumerate(components):
|
||||||
dataset.append([
|
dataset.append([
|
||||||
f"Component {idx}",
|
f"Component {idx}",
|
||||||
10,
|
10,
|
||||||
@ -257,7 +257,7 @@ class BomUploadTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
dataset.headers = ['part_ipn', 'quantity']
|
dataset.headers = ['part_ipn', 'quantity']
|
||||||
|
|
||||||
for idx, cmp in enumerate(components):
|
for idx, _ in enumerate(components):
|
||||||
dataset.append([
|
dataset.append([
|
||||||
f"CMP_{idx}",
|
f"CMP_{idx}",
|
||||||
10,
|
10,
|
||||||
|
@ -15,8 +15,8 @@ from common.notifications import UIMessageNotification, storage
|
|||||||
from InvenTree import version
|
from InvenTree import version
|
||||||
from InvenTree.helpers import InvenTreeTestCase
|
from InvenTree.helpers import InvenTreeTestCase
|
||||||
|
|
||||||
from .models import (Part, PartCategory, PartCategoryStar, PartStar,
|
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
|
||||||
PartTestTemplate, rename_part_image)
|
PartStar, PartTestTemplate, rename_part_image)
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
def test_inventree_instance_name(self):
|
def test_inventree_instance_name(self):
|
||||||
"""Test the 'instance name' setting"""
|
"""Test the 'instance name' setting"""
|
||||||
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree server')
|
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
|
||||||
|
|
||||||
def test_inventree_base_url(self):
|
def test_inventree_base_url(self):
|
||||||
"""Test that the base URL tag returns correctly"""
|
"""Test that the base URL tag returns correctly"""
|
||||||
@ -190,7 +190,7 @@ class PartTest(TestCase):
|
|||||||
try:
|
try:
|
||||||
part.save()
|
part.save()
|
||||||
self.assertTrue(False) # pragma: no cover
|
self.assertTrue(False) # pragma: no cover
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.assertEqual(Part.objects.count(), n + 1)
|
self.assertEqual(Part.objects.count(), n + 1)
|
||||||
@ -280,6 +280,53 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(p.metadata.keys()), 4)
|
self.assertEqual(len(p.metadata.keys()), 4)
|
||||||
|
|
||||||
|
def test_related(self):
|
||||||
|
"""Unit tests for the PartRelated model"""
|
||||||
|
|
||||||
|
# Create a part relationship
|
||||||
|
PartRelated.objects.create(part_1=self.r1, part_2=self.r2)
|
||||||
|
self.assertEqual(PartRelated.objects.count(), 1)
|
||||||
|
|
||||||
|
# Creating a duplicate part relationship should fail
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
PartRelated.objects.create(part_1=self.r1, part_2=self.r2)
|
||||||
|
|
||||||
|
# Creating an 'inverse' duplicate relationship should also fail
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
PartRelated.objects.create(part_1=self.r2, part_2=self.r1)
|
||||||
|
|
||||||
|
# Try to add a self-referential relationship
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
PartRelated.objects.create(part_1=self.r2, part_2=self.r2)
|
||||||
|
|
||||||
|
# Test relation lookup for each part
|
||||||
|
r1_relations = self.r1.get_related_parts()
|
||||||
|
self.assertEqual(len(r1_relations), 1)
|
||||||
|
self.assertIn(self.r2, r1_relations)
|
||||||
|
|
||||||
|
r2_relations = self.r2.get_related_parts()
|
||||||
|
self.assertEqual(len(r2_relations), 1)
|
||||||
|
self.assertIn(self.r1, r2_relations)
|
||||||
|
|
||||||
|
# Delete a part, ensure the relationship also gets deleted
|
||||||
|
self.r1.delete()
|
||||||
|
|
||||||
|
self.assertEqual(PartRelated.objects.count(), 0)
|
||||||
|
self.assertEqual(len(self.r2.get_related_parts()), 0)
|
||||||
|
|
||||||
|
# Add multiple part relationships to self.r2
|
||||||
|
for p in Part.objects.all().exclude(pk=self.r2.pk):
|
||||||
|
PartRelated.objects.create(part_1=p, part_2=self.r2)
|
||||||
|
|
||||||
|
n = Part.objects.count() - 1
|
||||||
|
|
||||||
|
self.assertEqual(PartRelated.objects.count(), n)
|
||||||
|
self.assertEqual(len(self.r2.get_related_parts()), n)
|
||||||
|
|
||||||
|
# Deleting r2 should remove *all* relationships
|
||||||
|
self.r2.delete()
|
||||||
|
self.assertEqual(PartRelated.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateTest(TestCase):
|
class TestTemplateTest(TestCase):
|
||||||
"""Unit test for the TestTemplate class"""
|
"""Unit test for the TestTemplate class"""
|
||||||
|
@ -138,23 +138,3 @@ class PartQRTest(PartViewTestCase):
|
|||||||
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-qr', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class CategoryTest(PartViewTestCase):
|
|
||||||
"""Tests for PartCategory related views."""
|
|
||||||
|
|
||||||
def test_set_category(self):
|
|
||||||
"""Test that the "SetCategory" view works."""
|
|
||||||
url = reverse('part-set-category')
|
|
||||||
|
|
||||||
response = self.client.get(url, {'parts[]': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'part_id_10': True,
|
|
||||||
'part_id_1': True,
|
|
||||||
'part_category': 5
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
@ -11,7 +11,6 @@ from django.urls import include, re_path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
part_detail_urls = [
|
part_detail_urls = [
|
||||||
re_path(r'^delete/?', views.PartDelete.as_view(), name='part-delete'),
|
|
||||||
re_path(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
re_path(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
|
||||||
|
|
||||||
re_path(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
re_path(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
|
||||||
@ -28,12 +27,6 @@ part_detail_urls = [
|
|||||||
re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
|
re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
|
||||||
]
|
]
|
||||||
|
|
||||||
category_parameter_urls = [
|
|
||||||
re_path(r'^new/', views.CategoryParameterTemplateCreate.as_view(), name='category-param-template-create'),
|
|
||||||
re_path(r'^(?P<pid>\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'),
|
|
||||||
re_path(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
|
|
||||||
]
|
|
||||||
|
|
||||||
category_urls = [
|
category_urls = [
|
||||||
|
|
||||||
# Top level subcategory display
|
# Top level subcategory display
|
||||||
@ -42,8 +35,6 @@ category_urls = [
|
|||||||
# Category detail views
|
# Category detail views
|
||||||
re_path(r'(?P<pk>\d+)/', include([
|
re_path(r'(?P<pk>\d+)/', include([
|
||||||
re_path(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
|
re_path(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
|
||||||
re_path(r'^parameters/', include(category_parameter_urls)),
|
|
||||||
|
|
||||||
# Anything else
|
# Anything else
|
||||||
re_path(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
re_path(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||||
]))
|
]))
|
||||||
@ -65,9 +56,6 @@ part_urls = [
|
|||||||
# Part category
|
# Part category
|
||||||
re_path(r'^category/', include(category_urls)),
|
re_path(r'^category/', include(category_urls)),
|
||||||
|
|
||||||
# Change category for multiple parts
|
|
||||||
re_path(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'),
|
|
||||||
|
|
||||||
# Individual part using IPN as slug
|
# Individual part using IPN as slug
|
||||||
re_path(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),
|
re_path(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),
|
||||||
|
|
||||||
|
@ -8,9 +8,6 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import transaction
|
|
||||||
from django.db.utils import IntegrityError
|
|
||||||
from django.forms import HiddenInput
|
|
||||||
from django.shortcuts import HttpResponseRedirect, get_object_or_404
|
from django.shortcuts import HttpResponseRedirect, get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -27,8 +24,8 @@ from common.models import InvenTreeSetting
|
|||||||
from common.views import FileManagementAjaxView, FileManagementFormView
|
from common.views import FileManagementAjaxView, FileManagementFormView
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.views import (AjaxCreateView, AjaxDeleteView, AjaxUpdateView,
|
from InvenTree.views import (AjaxDeleteView, AjaxUpdateView, AjaxView,
|
||||||
AjaxView, InvenTreeRoleMixin, QRCodeView)
|
InvenTreeRoleMixin, QRCodeView)
|
||||||
from order.models import PurchaseOrderLineItem
|
from order.models import PurchaseOrderLineItem
|
||||||
from plugin.views import InvenTreePluginViewMixin
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
@ -36,7 +33,7 @@ from stock.models import StockItem, StockLocation
|
|||||||
from . import forms as part_forms
|
from . import forms as part_forms
|
||||||
from . import settings as part_settings
|
from . import settings as part_settings
|
||||||
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
|
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
|
||||||
from .models import Part, PartCategory, PartCategoryParameterTemplate
|
from .models import Part, PartCategory
|
||||||
|
|
||||||
|
|
||||||
class PartIndex(InvenTreeRoleMixin, ListView):
|
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||||
@ -69,80 +66,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class PartSetCategory(AjaxUpdateView):
|
|
||||||
"""View for settings the part category for multiple parts at once."""
|
|
||||||
|
|
||||||
ajax_template_name = 'part/set_category.html'
|
|
||||||
ajax_form_title = _('Set Part Category')
|
|
||||||
form_class = part_forms.SetPartCategoryForm
|
|
||||||
|
|
||||||
role_required = 'part.change'
|
|
||||||
|
|
||||||
category = None
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Respond to a GET request to this view."""
|
|
||||||
self.request = request
|
|
||||||
|
|
||||||
if 'parts[]' in request.GET:
|
|
||||||
self.parts = Part.objects.filter(id__in=request.GET.getlist('parts[]'))
|
|
||||||
else:
|
|
||||||
self.parts = []
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, form=self.get_form(), context=self.get_context_data())
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
"""Respond to a POST request to this view."""
|
|
||||||
self.parts = []
|
|
||||||
|
|
||||||
for item in request.POST:
|
|
||||||
if item.startswith('part_id_'):
|
|
||||||
pk = item.replace('part_id_', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
part = Part.objects.get(pk=pk)
|
|
||||||
except (Part.DoesNotExist, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.parts.append(part)
|
|
||||||
|
|
||||||
self.category = None
|
|
||||||
|
|
||||||
if 'part_category' in request.POST:
|
|
||||||
pk = request.POST['part_category']
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.category = PartCategory.objects.get(pk=pk)
|
|
||||||
except (PartCategory.DoesNotExist, ValueError):
|
|
||||||
self.category = None
|
|
||||||
|
|
||||||
valid = self.category is not None
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'form_valid': valid,
|
|
||||||
'success': _('Set category for {n} parts').format(n=len(self.parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
with transaction.atomic():
|
|
||||||
for part in self.parts:
|
|
||||||
part.category = self.category
|
|
||||||
part.save()
|
|
||||||
|
|
||||||
return self.renderJsonResponse(request, data=data, form=self.get_form(), context=self.get_context_data())
|
|
||||||
|
|
||||||
def get_context_data(self):
|
|
||||||
"""Return context data for rendering in the form."""
|
|
||||||
ctx = {}
|
|
||||||
|
|
||||||
ctx['parts'] = self.parts
|
|
||||||
ctx['categories'] = PartCategory.objects.all()
|
|
||||||
ctx['category'] = self.category
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
class PartImport(FileManagementFormView):
|
class PartImport(FileManagementFormView):
|
||||||
"""Part: Upload file, match to fields and import parts(using multi-Step form)"""
|
"""Part: Upload file, match to fields and import parts(using multi-Step form)"""
|
||||||
permission_required = 'part.add'
|
permission_required = 'part.add'
|
||||||
@ -620,7 +543,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
|
|||||||
try:
|
try:
|
||||||
self.image = Image.open(response.raw).convert()
|
self.image = Image.open(response.raw).convert()
|
||||||
self.image.verify()
|
self.image.verify()
|
||||||
except:
|
except Exception:
|
||||||
form.add_error('url', _("Supplied URL is not a valid image file"))
|
form.add_error('url', _("Supplied URL is not a valid image file"))
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -762,23 +685,6 @@ class BomDownload(AjaxView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PartDelete(AjaxDeleteView):
|
|
||||||
"""View to delete a Part object."""
|
|
||||||
|
|
||||||
model = Part
|
|
||||||
ajax_template_name = 'part/partial_delete.html'
|
|
||||||
ajax_form_title = _('Confirm Part Deletion')
|
|
||||||
context_object_name = 'part'
|
|
||||||
|
|
||||||
success_url = '/part/'
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
"""Returns custom message once the part deletion has been performed"""
|
|
||||||
return {
|
|
||||||
'danger': _('Part was deleted'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PartPricing(AjaxView):
|
class PartPricing(AjaxView):
|
||||||
"""View for inspecting part pricing information."""
|
"""View for inspecting part pricing information."""
|
||||||
|
|
||||||
@ -984,185 +890,3 @@ class CategoryDelete(AjaxDeleteView):
|
|||||||
return {
|
return {
|
||||||
'danger': _('Part category was deleted'),
|
'danger': _('Part category was deleted'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterTemplateCreate(AjaxCreateView):
|
|
||||||
"""View for creating a new PartCategoryParameterTemplate."""
|
|
||||||
|
|
||||||
model = PartCategoryParameterTemplate
|
|
||||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
|
||||||
ajax_form_title = _('Create Category Parameter Template')
|
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
"""Get initial data for Category."""
|
|
||||||
initials = super().get_initial()
|
|
||||||
|
|
||||||
category_id = self.kwargs.get('pk', None)
|
|
||||||
|
|
||||||
if category_id:
|
|
||||||
try:
|
|
||||||
initials['category'] = PartCategory.objects.get(pk=category_id)
|
|
||||||
except (PartCategory.DoesNotExist, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return initials
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
"""Create a form to upload a new CategoryParameterTemplate.
|
|
||||||
|
|
||||||
- Hide the 'category' field (parent part)
|
|
||||||
- Display parameter templates which are not yet related
|
|
||||||
"""
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
form.fields['category'].widget = HiddenInput()
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get selected category
|
|
||||||
category = self.get_initial()['category']
|
|
||||||
|
|
||||||
# Get existing parameter templates
|
|
||||||
parameters = [template.parameter_template.pk
|
|
||||||
for template in category.get_parameter_templates()]
|
|
||||||
|
|
||||||
# Exclude templates already linked to category
|
|
||||||
updated_choices = []
|
|
||||||
for choice in form.fields["parameter_template"].choices:
|
|
||||||
if (choice[0] not in parameters):
|
|
||||||
updated_choices.append(choice)
|
|
||||||
|
|
||||||
# Update choices for parameter templates
|
|
||||||
form.fields['parameter_template'].choices = updated_choices
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
"""Capture the POST request.
|
|
||||||
|
|
||||||
- If the add_to_all_categories object is set, link parameter template to
|
|
||||||
all categories
|
|
||||||
- If the add_to_same_level_categories object is set, link parameter template to
|
|
||||||
same level categories
|
|
||||||
"""
|
|
||||||
form = self.get_form()
|
|
||||||
|
|
||||||
valid = form.is_valid()
|
|
||||||
|
|
||||||
if valid:
|
|
||||||
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
|
|
||||||
add_to_all_categories = form.cleaned_data['add_to_all_categories']
|
|
||||||
|
|
||||||
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
|
|
||||||
parameter_template = form.cleaned_data['parameter_template']
|
|
||||||
default_value = form.cleaned_data['default_value']
|
|
||||||
|
|
||||||
categories = PartCategory.objects.all()
|
|
||||||
|
|
||||||
if add_to_same_level_categories and not add_to_all_categories:
|
|
||||||
# Get level
|
|
||||||
level = selected_category.level
|
|
||||||
# Filter same level categories
|
|
||||||
categories = categories.filter(level=level)
|
|
||||||
|
|
||||||
if add_to_same_level_categories or add_to_all_categories:
|
|
||||||
# Add parameter template and default value to categories
|
|
||||||
for category in categories:
|
|
||||||
# Skip selected category (will be processed in the post call)
|
|
||||||
if category.pk != selected_category.pk:
|
|
||||||
try:
|
|
||||||
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
|
|
||||||
parameter_template=parameter_template,
|
|
||||||
default_value=default_value)
|
|
||||||
cat_template.save()
|
|
||||||
except IntegrityError:
|
|
||||||
# Parameter template is already linked to category
|
|
||||||
pass
|
|
||||||
|
|
||||||
return super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterTemplateEdit(AjaxUpdateView):
|
|
||||||
"""View for editing a PartCategoryParameterTemplate."""
|
|
||||||
|
|
||||||
model = PartCategoryParameterTemplate
|
|
||||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
|
||||||
ajax_form_title = _('Edit Category Parameter Template')
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
|
||||||
|
|
||||||
- First, attempt lookup based on supplied 'pid' kwarg
|
|
||||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.object
|
|
||||||
|
|
||||||
def get_form(self):
|
|
||||||
"""Create a form to upload a new CategoryParameterTemplate.
|
|
||||||
|
|
||||||
- Hide the 'category' field (parent part)
|
|
||||||
- Display parameter templates which are not yet related
|
|
||||||
"""
|
|
||||||
form = super().get_form()
|
|
||||||
|
|
||||||
form.fields['category'].widget = HiddenInput()
|
|
||||||
form.fields['add_to_all_categories'].widget = HiddenInput()
|
|
||||||
form.fields['add_to_same_level_categories'].widget = HiddenInput()
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get selected category
|
|
||||||
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
|
|
||||||
# Get selected template
|
|
||||||
selected_template = self.get_object().parameter_template
|
|
||||||
|
|
||||||
# Get existing parameter templates
|
|
||||||
parameters = [template.parameter_template.pk
|
|
||||||
for template in category.get_parameter_templates()
|
|
||||||
if template.parameter_template.pk != selected_template.pk]
|
|
||||||
|
|
||||||
# Exclude templates already linked to category
|
|
||||||
updated_choices = []
|
|
||||||
for choice in form.fields["parameter_template"].choices:
|
|
||||||
if (choice[0] not in parameters):
|
|
||||||
updated_choices.append(choice)
|
|
||||||
|
|
||||||
# Update choices for parameter templates
|
|
||||||
form.fields['parameter_template'].choices = updated_choices
|
|
||||||
# Set initial choice to current template
|
|
||||||
form.fields['parameter_template'].initial = selected_template
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return form
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterTemplateDelete(AjaxDeleteView):
|
|
||||||
"""View for deleting an existing PartCategoryParameterTemplate."""
|
|
||||||
|
|
||||||
model = PartCategoryParameterTemplate
|
|
||||||
ajax_form_title = _("Delete Category Parameter Template")
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
|
||||||
|
|
||||||
- First, attempt lookup based on supplied 'pid' kwarg
|
|
||||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self.object
|
|
||||||
|
@ -39,7 +39,7 @@ class PluginAppConfig(AppConfig):
|
|||||||
if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False):
|
if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False):
|
||||||
# make sure all plugins are installed
|
# make sure all plugins are installed
|
||||||
registry.install_plugin_file()
|
registry.install_plugin_file()
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# get plugins and init them
|
# get plugins and init them
|
||||||
|
@ -200,7 +200,7 @@ class ScheduleMixin:
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
|
|
||||||
for key, task in self.scheduled_tasks.items():
|
for key, _ in self.scheduled_tasks.items():
|
||||||
|
|
||||||
task_name = self.get_task_name(key)
|
task_name = self.get_task_name(key)
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
|
|
||||||
|
import common.models
|
||||||
import InvenTree.tasks
|
import InvenTree.tasks
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
from plugin.mixins import BulkNotificationMethod, SettingsMixin
|
||||||
@ -74,6 +75,14 @@ class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin):
|
|||||||
html_message = render_to_string(self.context['template']['html'], self.context)
|
html_message = render_to_string(self.context['template']['html'], self.context)
|
||||||
targets = self.targets.values_list('email', flat=True)
|
targets = self.targets.values_list('email', flat=True)
|
||||||
|
|
||||||
InvenTree.tasks.send_email(self.context['template']['subject'], '', targets, html_message=html_message)
|
# Prefix the 'instance title' to the email subject
|
||||||
|
instance_title = common.models.InvenTreeSetting.get_setting('INVENTREE_INSTANCE')
|
||||||
|
|
||||||
|
subject = self.context['template'].get('subject', '')
|
||||||
|
|
||||||
|
if instance_title:
|
||||||
|
subject = f'[{instance_title}] {subject}'
|
||||||
|
|
||||||
|
InvenTree.tasks.send_email(subject, '', targets, html_message=html_message)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -169,7 +169,7 @@ class GitStatus:
|
|||||||
def get_modules(pkg):
|
def get_modules(pkg):
|
||||||
"""Get all modules in a package."""
|
"""Get all modules in a package."""
|
||||||
context = {}
|
context = {}
|
||||||
for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
|
for loader, name, _ in pkgutil.walk_packages(pkg.__path__):
|
||||||
try:
|
try:
|
||||||
module = loader.find_module(name).load_module(name)
|
module = loader.find_module(name).load_module(name)
|
||||||
pkg_names = getattr(module, '__all__', None)
|
pkg_names = getattr(module, '__all__', None)
|
||||||
|
@ -382,7 +382,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
|
if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_SCHEDULE'):
|
||||||
|
|
||||||
for slug, plugin in plugins:
|
for _, plugin in plugins:
|
||||||
|
|
||||||
if plugin.mixin_enabled('schedule'):
|
if plugin.mixin_enabled('schedule'):
|
||||||
config = plugin.plugin_config()
|
config = plugin.plugin_config()
|
||||||
@ -437,7 +437,7 @@ class PluginsRegistry:
|
|||||||
apps_changed = False
|
apps_changed = False
|
||||||
|
|
||||||
# add them to the INSTALLED_APPS
|
# add them to the INSTALLED_APPS
|
||||||
for slug, plugin in plugins:
|
for _, plugin in plugins:
|
||||||
if plugin.mixin_enabled('app'):
|
if plugin.mixin_enabled('app'):
|
||||||
plugin_path = self._get_plugin_path(plugin)
|
plugin_path = self._get_plugin_path(plugin)
|
||||||
if plugin_path not in settings.INSTALLED_APPS:
|
if plugin_path not in settings.INSTALLED_APPS:
|
||||||
@ -522,7 +522,7 @@ class PluginsRegistry:
|
|||||||
# remove model from admin site
|
# remove model from admin site
|
||||||
try:
|
try:
|
||||||
admin.site.unregister(model)
|
admin.site.unregister(model)
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
models += [model._meta.model_name]
|
models += [model._meta.model_name]
|
||||||
except LookupError: # pragma: no cover
|
except LookupError: # pragma: no cover
|
||||||
|
@ -113,7 +113,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
|
|||||||
'icon': 'fa-user',
|
'icon': 'fa-user',
|
||||||
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
|
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
|
||||||
})
|
})
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return panels
|
return panels
|
||||||
|
@ -57,7 +57,7 @@ def safe_url(view_name, *args, **kwargs):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return reverse(view_name, args=args, kwargs=kwargs)
|
return reverse(view_name, args=args, kwargs=kwargs)
|
||||||
except:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""API functionality for the 'report' app"""
|
"""API functionality for the 'report' app"""
|
||||||
|
|
||||||
from django.core.exceptions import FieldError, ValidationError
|
from django.core.exceptions import FieldError, ValidationError
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template.exceptions import TemplateDoesNotExist
|
from django.template.exceptions import TemplateDoesNotExist
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
@ -15,7 +16,7 @@ import common.models
|
|||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import order.models
|
import order.models
|
||||||
import part.models
|
import part.models
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem, StockItemAttachment
|
||||||
|
|
||||||
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
|
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
|
||||||
SalesOrderReport, TestReport)
|
SalesOrderReport, TestReport)
|
||||||
@ -158,6 +159,18 @@ class PartReportMixin:
|
|||||||
class ReportPrintMixin:
|
class ReportPrintMixin:
|
||||||
"""Mixin for printing reports."""
|
"""Mixin for printing reports."""
|
||||||
|
|
||||||
|
def report_callback(self, object, report, request):
|
||||||
|
"""Callback function for each object/report combination.
|
||||||
|
|
||||||
|
Allows functionality to be performed before returning the consolidated PDF
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
object: The model instance to be printed
|
||||||
|
report: The individual PDF file object
|
||||||
|
request: The request instance associated with this print call
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
def print(self, request, items_to_print):
|
def print(self, request, items_to_print):
|
||||||
"""Print this report template against a number of pre-validated items."""
|
"""Print this report template against a number of pre-validated items."""
|
||||||
if len(items_to_print) == 0:
|
if len(items_to_print) == 0:
|
||||||
@ -182,12 +195,16 @@ class ReportPrintMixin:
|
|||||||
report.object_to_print = item
|
report.object_to_print = item
|
||||||
|
|
||||||
report_name = report.generate_filename(request)
|
report_name = report.generate_filename(request)
|
||||||
|
output = report.render(request)
|
||||||
|
|
||||||
|
# Run report callback for each generated report
|
||||||
|
self.report_callback(item, output, request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
outputs.append(report.render_as_string(request))
|
outputs.append(report.render_as_string(request))
|
||||||
else:
|
else:
|
||||||
outputs.append(report.render(request))
|
outputs.append(output)
|
||||||
except TemplateDoesNotExist as e:
|
except TemplateDoesNotExist as e:
|
||||||
template = str(e)
|
template = str(e)
|
||||||
if not template:
|
if not template:
|
||||||
@ -289,7 +306,7 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
|||||||
# Filter string defined for the report object
|
# Filter string defined for the report object
|
||||||
try:
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(report.filters)
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
@ -326,6 +343,22 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, R
|
|||||||
queryset = TestReport.objects.all()
|
queryset = TestReport.objects.all()
|
||||||
serializer_class = TestReportSerializer
|
serializer_class = TestReportSerializer
|
||||||
|
|
||||||
|
def report_callback(self, item, report, request):
|
||||||
|
"""Callback to (optionally) save a copy of the generated report"""
|
||||||
|
|
||||||
|
if common.models.InvenTreeSetting.get_setting('REPORT_ATTACH_TEST_REPORT'):
|
||||||
|
|
||||||
|
# Construct a PDF file object
|
||||||
|
pdf = report.get_document().write_pdf()
|
||||||
|
pdf_content = ContentFile(pdf, "test_report.pdf")
|
||||||
|
|
||||||
|
StockItemAttachment.objects.create(
|
||||||
|
attachment=pdf_content,
|
||||||
|
stock_item=item,
|
||||||
|
user=request.user,
|
||||||
|
comment=_("Test report")
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Check if valid stock item(s) have been provided."""
|
"""Check if valid stock item(s) have been provided."""
|
||||||
items = self.get_items()
|
items = self.get_items()
|
||||||
@ -528,7 +561,7 @@ class PurchaseOrderReportList(ReportListView, OrderReportMixin):
|
|||||||
# Filter string defined for the report object
|
# Filter string defined for the report object
|
||||||
try:
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(report.filters)
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
@ -607,7 +640,7 @@ class SalesOrderReportList(ReportListView, OrderReportMixin):
|
|||||||
# Filter string defined for the report object
|
# Filter string defined for the report object
|
||||||
try:
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(report.filters)
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
|
@ -75,14 +75,14 @@ class ReportConfig(AppConfig):
|
|||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def create_default_test_reports(self):
|
def create_default_test_reports(self):
|
||||||
"""Create database entries for the default TestReport templates, if they do not already exist."""
|
"""Create database entries for the default TestReport templates, if they do not already exist."""
|
||||||
try:
|
try:
|
||||||
from .models import TestReport
|
from .models import TestReport
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Database is not ready yet
|
# Database is not ready yet
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ class ReportConfig(AppConfig):
|
|||||||
"""Create database entries for the default BuildReport templates (if they do not already exist)"""
|
"""Create database entries for the default BuildReport templates (if they do not already exist)"""
|
||||||
try:
|
try:
|
||||||
from .models import BuildReport
|
from .models import BuildReport
|
||||||
except: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
# Database is not ready yet
|
# Database is not ready yet
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -9,9 +9,9 @@ from django.urls import reverse
|
|||||||
|
|
||||||
import report.models as report_models
|
import report.models as report_models
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
from common.models import InvenTreeUserSetting
|
from common.models import InvenTreeSetting, InvenTreeUserSetting
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem, StockItemAttachment
|
||||||
|
|
||||||
|
|
||||||
class ReportTest(InvenTreeAPITestCase):
|
class ReportTest(InvenTreeAPITestCase):
|
||||||
@ -141,15 +141,28 @@ class TestReportTest(ReportTest):
|
|||||||
# Now print with a valid StockItem
|
# Now print with a valid StockItem
|
||||||
item = StockItem.objects.first()
|
item = StockItem.objects.first()
|
||||||
|
|
||||||
response = self.get(url, {'item': item.pk})
|
response = self.get(url, {'item': item.pk}, expected_code=200)
|
||||||
|
|
||||||
# Response should be a StreamingHttpResponse (PDF file)
|
# Response should be a StreamingHttpResponse (PDF file)
|
||||||
self.assertEqual(type(response), StreamingHttpResponse)
|
self.assertEqual(type(response), StreamingHttpResponse)
|
||||||
|
|
||||||
headers = response.headers
|
headers = response.headers
|
||||||
|
|
||||||
self.assertEqual(headers['Content-Type'], 'application/pdf')
|
self.assertEqual(headers['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
# By default, this should *not* have created an attachment against this stockitem
|
||||||
|
self.assertFalse(StockItemAttachment.objects.filter(stock_item=item).exists())
|
||||||
|
|
||||||
|
# Change the setting, now the test report should be attached automatically
|
||||||
|
InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None)
|
||||||
|
|
||||||
|
response = self.get(url, {'item': item.pk}, expected_code=200)
|
||||||
|
headers = response.headers
|
||||||
|
self.assertEqual(headers['Content-Type'], 'application/pdf')
|
||||||
|
|
||||||
|
# Check that a report has been uploaded
|
||||||
|
attachment = StockItemAttachment.objects.filter(stock_item=item).first()
|
||||||
|
self.assertIsNotNone(attachment)
|
||||||
|
|
||||||
|
|
||||||
class BuildReportTest(ReportTest):
|
class BuildReportTest(ReportTest):
|
||||||
"""Unit test class for the BuildReport model"""
|
"""Unit test class for the BuildReport model"""
|
||||||
|
@ -99,7 +99,7 @@ class StockItemContextMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
|
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return context
|
return context
|
||||||
@ -830,7 +830,7 @@ class StockList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
if part.tree_id is not None:
|
if part.tree_id is not None:
|
||||||
queryset = queryset.filter(part__tree_id=part.tree_id)
|
queryset = queryset.filter(part__tree_id=part.tree_id)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Filter by 'allocated' parts?
|
# Filter by 'allocated' parts?
|
||||||
@ -1043,7 +1043,7 @@ class StockList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class StockAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
|
||||||
"""API endpoint for listing (and creating) a StockItemAttachment (file upload)."""
|
"""API endpoint for listing (and creating) a StockItemAttachment (file upload)."""
|
||||||
|
|
||||||
queryset = StockItemAttachment.objects.all()
|
queryset = StockItemAttachment.objects.all()
|
||||||
@ -1060,7 +1060,7 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockAttachmentDetail(generics.RetrieveUpdateDestroyAPIView, AttachmentMixin):
|
class StockAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""Detail endpoint for StockItemAttachment."""
|
"""Detail endpoint for StockItemAttachment."""
|
||||||
|
|
||||||
queryset = StockItemAttachment.objects.all()
|
queryset = StockItemAttachment.objects.all()
|
||||||
@ -1144,7 +1144,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
|||||||
"""Set context before returning serializer."""
|
"""Set context before returning serializer."""
|
||||||
try:
|
try:
|
||||||
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
@ -1186,12 +1186,12 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
"""Set context before returning serializer."""
|
"""Set context before returning serializer."""
|
||||||
try:
|
try:
|
||||||
kwargs['item_detail'] = str2bool(self.request.query_params.get('item_detail', False))
|
kwargs['item_detail'] = str2bool(self.request.query_params.get('item_detail', False))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
@ -1219,7 +1219,7 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
part = Part.objects.get(pk=deltas['part'])
|
part = Part.objects.get(pk=deltas['part'])
|
||||||
serializer = PartBriefSerializer(part)
|
serializer = PartBriefSerializer(part)
|
||||||
deltas['part_detail'] = serializer.data
|
deltas['part_detail'] = serializer.data
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add location detail
|
# Add location detail
|
||||||
@ -1228,7 +1228,7 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
location = StockLocation.objects.get(pk=deltas['location'])
|
location = StockLocation.objects.get(pk=deltas['location'])
|
||||||
serializer = StockSerializers.LocationSerializer(location)
|
serializer = StockSerializers.LocationSerializer(location)
|
||||||
deltas['location_detail'] = serializer.data
|
deltas['location_detail'] = serializer.data
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add stockitem detail
|
# Add stockitem detail
|
||||||
@ -1237,7 +1237,7 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
|
||||||
serializer = StockSerializers.StockItemSerializer(stockitem)
|
serializer = StockSerializers.StockItemSerializer(stockitem)
|
||||||
deltas['stockitem_detail'] = serializer.data
|
deltas['stockitem_detail'] = serializer.data
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add customer detail
|
# Add customer detail
|
||||||
@ -1246,7 +1246,7 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
customer = Company.objects.get(pk=deltas['customer'])
|
customer = Company.objects.get(pk=deltas['customer'])
|
||||||
serializer = CompanySerializer(customer)
|
serializer = CompanySerializer(customer)
|
||||||
deltas['customer_detail'] = serializer.data
|
deltas['customer_detail'] = serializer.data
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add purchaseorder detail
|
# Add purchaseorder detail
|
||||||
@ -1255,7 +1255,7 @@ class StockTrackingList(generics.ListAPIView):
|
|||||||
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
|
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
|
||||||
serializer = PurchaseOrderSerializer(order)
|
serializer = PurchaseOrderSerializer(order)
|
||||||
deltas['purchaseorder_detail'] = serializer.data
|
deltas['purchaseorder_detail'] = serializer.data
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if request.is_ajax():
|
if request.is_ajax():
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user