Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-06-07 00:30:04 +10:00
commit fd6179fc9b
129 changed files with 9148 additions and 9622 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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."""

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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):

View File

@ -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'),
] ]

View File

@ -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)))

View File

@ -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

View File

@ -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."""

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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 = [

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView):
'category', 'category',
'name', 'name',
'read', 'read',
'creation',
] ]
search_fields = [ search_fields = [

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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)

View File

@ -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

View File

@ -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(

View File

@ -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)

View File

@ -158,6 +158,8 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
'link', 'link',
'comment', 'comment',
'upload_date', 'upload_date',
'user',
'user_detail',
] ]
read_only_fields = [ read_only_fields = [

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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
View 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)

View File

@ -135,7 +135,7 @@
}, },
label: 'attachment', label: 'attachment',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload(); $('#attachment-table').bootstrapTable('refresh');
} }
} }
); );

View File

@ -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)

View File

@ -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')

View File

@ -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([

View File

@ -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."""

View 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')},
),
]

View File

@ -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"))

View File

@ -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

View File

@ -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,
}, },
} }

View File

@ -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,
}, },

View File

@ -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 %}

View File

@ -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.' %}

View File

@ -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 %}

View File

@ -64,7 +64,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>
@ -83,13 +83,15 @@
{% 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.' %}

View File

@ -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 %}

View File

@ -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()

View File

@ -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,

View File

@ -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,

View File

@ -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"""

View File

@ -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)

View File

@ -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'),

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"""

View File

@ -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