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'
hooks:
- id: flake8
additional_dependencies: [
'flake8-bugbear',
'flake8-docstrings',
'flake8-string-format',
'pep8-naming ',
]
- repo: https://github.com/pycqa/isort
rev: '5.10.1'
hooks:

View File

@ -105,8 +105,12 @@ class InvenTreeAPITestCase(UserMixin, APITestCase):
return actions
def get(self, url, data={}, expected_code=200):
def get(self, url, data=None, expected_code=200):
"""Issue a GET request."""
# Set default - see B006
if data is None:
data = {}
response = self.client.get(url, data, format='json')
if expected_code is not None:

View File

@ -2,11 +2,14 @@
# 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
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
- Expose the PartParameterTemplate model to use the API

View File

@ -57,6 +57,7 @@ class InvenTreeConfig(AppConfig):
try:
from django_q.models import Schedule
except AppRegistryNotReady: # pragma: no cover
logger.warning("Cannot start background tasks - app registry not ready")
return
logger.info("Starting background tasks...")
@ -98,6 +99,24 @@ class InvenTreeConfig(AppConfig):
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
"""Update exchange rates each time the server is started.
@ -136,7 +155,7 @@ class InvenTreeConfig(AppConfig):
logger.info("Exchange backend not found - updating")
update = True
except:
except Exception:
# Some other error - potentially the tables are not ready yet
return

View File

@ -43,12 +43,16 @@ class InvenTreeExchange(SimpleExchangeBackend):
context = ssl.create_default_context(cafile=certifi.where())
response = urlopen(url, timeout=5, context=context)
return response.read()
except:
except Exception:
# Returning None here will raise an error upstream
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 default - see B008
if base_currency is None:
base_currency = currency_code_default()
symbols = ','.join(currency_codes())
try:

View File

@ -16,13 +16,12 @@ from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import (AppendedText, Div, PrependedAppendedText,
PrependedText, StrictButton)
from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText)
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout
from common.models import InvenTreeSetting
from part.models import PartCategory
logger = logging.getLogger('inventree')
@ -109,22 +108,6 @@ class HelperForm(forms.ModelForm):
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):
"""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
class CustomSignupForm(SignupForm):
"""Override to use dynamic settings."""

View File

@ -95,7 +95,7 @@ def TestIfImage(img):
try:
Image.open(img).verify()
return True
except:
except Exception:
return False

View File

@ -17,7 +17,7 @@ class Command(BaseCommand):
from part.models import Part
Part.objects.rebuild()
except:
except Exception:
print("Error rebuilding Part objects")
# Part category
@ -26,7 +26,7 @@ class Command(BaseCommand):
from part.models import PartCategory
PartCategory.objects.rebuild()
except:
except Exception:
print("Error rebuilding PartCategory objects")
# StockItem model
@ -35,7 +35,7 @@ class Command(BaseCommand):
from stock.models import StockItem
StockItem.objects.rebuild()
except:
except Exception:
print("Error rebuilding StockItem objects")
# StockLocation model
@ -44,7 +44,7 @@ class Command(BaseCommand):
from stock.models import StockLocation
StockLocation.objects.rebuild()
except:
except Exception:
print("Error rebuilding StockLocation objects")
# Build model
@ -53,5 +53,5 @@ class Command(BaseCommand):
from build.models import Build
Build.objects.rebuild()
except:
except Exception:
print("Error rebuilding Build objects")

View File

@ -137,7 +137,7 @@ class InvenTreeMetadata(SimpleMetadata):
if callable(default):
try:
default = default()
except:
except Exception:
continue
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]):
# 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:
# Return a 401 (Unauthorized) response code for this request

View File

@ -5,17 +5,21 @@ import os
import re
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
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.urls import reverse
from django.utils.translation import gettext_lazy as _
from error_report.models import Error
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey
import InvenTree.helpers
from InvenTree.fields import InvenTreeURLField
from InvenTree.validators import validate_tree_name
@ -133,7 +137,7 @@ def extract_int(reference, clip=0x7fffffff):
ref = result.groups()[0]
try:
ref_int = int(ref)
except:
except Exception:
ref_int = 0
# 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)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except:
except Exception:
raise ValidationError(_("Error renaming file"))
class Meta:
@ -442,3 +446,37 @@ def before_delete_tree_item(sender, instance, using, **kwargs):
for child in instance.children.all():
child.parent = instance.parent
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:
if amount is not None and amount is not empty:
amount = Decimal(amount)
except:
except Exception:
raise ValidationError({
self.field_name: [_("Must be a valid number")],
})
@ -60,29 +60,6 @@ class InvenTreeMoneySerializer(MoneyField):
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):
"""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):
try:
value = value()
except:
except Exception:
continue
data[field_name] = value
@ -150,7 +127,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
if callable(value):
try:
value = value()
except:
except Exception:
continue
initials[field_name] = value
@ -218,6 +195,21 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
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():
"""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
Why? You can't handle the why!
Actually, if the server process is serving the data at 127.0.0.1,
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,
then an attachment which prefixes the "address" of the internal server
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.
"""
user_detail = UserSerializer(source='user', read_only=True, many=False)
attachment = InvenTreeAttachmentSerializerField(
required=False,
allow_null=False,
@ -302,7 +294,7 @@ class InvenTreeDecimalField(serializers.FloatField):
# Convert the value to a string, and then a decimal
try:
return Decimal(str(data))
except:
except Exception:
raise serializers.ValidationError(_("Invalid value"))
@ -423,7 +415,7 @@ class DataFileUploadSerializer(serializers.Serializer):
if self.TARGET_MODEL:
try:
model_fields = self.TARGET_MODEL.get_import_fields()
except:
except Exception:
pass
# Extract a list of valid model field names
@ -515,7 +507,7 @@ class DataFileExtractSerializer(serializers.Serializer):
if self.TARGET_MODEL:
try:
model_fields = self.TARGET_MODEL.get_import_fields()
except:
except Exception:
model_fields = {}
rows = []
@ -568,7 +560,7 @@ class DataFileExtractSerializer(serializers.Serializer):
if self.TARGET_MODEL:
try:
model_fields = self.TARGET_MODEL.get_import_fields()
except:
except Exception:
model_fields = {}
cols_seen = set()

View File

@ -242,7 +242,7 @@ def update_exchange_rates():
# Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return
except: # pragma: no cover
except Exception: # pragma: no cover
# Other error?
return
@ -251,7 +251,7 @@ def update_exchange_rates():
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
except ExchangeBackend.DoesNotExist:
pass
except: # pragma: no cover
except Exception: # pragma: no cover
# Some other error
logger.warning("update_exchange_rates: Database not ready")
return

View File

@ -417,7 +417,7 @@ class CurrencyTests(TestCase):
update_successful = False
# 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()
rates = Rate.objects.all()
@ -469,12 +469,20 @@ class TestSettings(helpers.InvenTreeTestCase):
superuser = True
def in_env_context(self, envs={}):
def in_env_context(self, envs=None):
"""Patch the env to include the given dict."""
# Set default - see B006
if envs is None:
envs = {}
return mock.patch.dict(os.environ, envs)
def run_reload(self, envs={}):
def run_reload(self, envs=None):
"""Helper function to reload InvenTree."""
# Set default - see B006
if envs is None:
envs = {}
from plugin import registry
with self.in_env_context(envs):

View File

@ -37,7 +37,7 @@ from .views import (AppearanceSelectView, CurrencyRefreshView,
CustomSessionDeleteOtherView, CustomSessionDeleteView,
DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
NotificationsView, SearchView, SetPasswordView,
SettingCategorySelectView, SettingsView, auth_request)
SettingsView, auth_request)
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'^currencies-refresh/', CurrencyRefreshView.as_view(), name='settings-currencies-refresh'),
re_path(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
# Catch any other urls
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):
"""Prevent illegal characters in tree item names."""
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"":
for c in "!@#$%^&*'\"\\/[]{}<>,|+=~`\"": # noqa: P103
if c in str(value):
raise ValidationError(_('Illegal character in name ({x})'.format(x=c)))

View File

@ -99,7 +99,7 @@ def inventreeCommitHash():
try:
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
@ -114,5 +114,5 @@ def inventreeCommitDate():
try:
d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip()
return d.split(' ')[0]
except: # pragma: no cover
except Exception: # pragma: no cover
return None

View File

@ -17,8 +17,8 @@ from django.urls import reverse_lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import (CreateView, DeleteView, DetailView, FormView,
ListView, UpdateView)
from django.views.generic import (CreateView, DeleteView, DetailView, ListView,
UpdateView)
from django.views.generic.base import RedirectView, TemplateView
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 users.models import RuleSet, check_user_role
from .forms import (DeleteForm, EditUserForm, SetPasswordForm,
SettingCategorySelectForm)
from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .helpers import str2bool
@ -527,7 +526,7 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
"""Return object matched to the model of the calling class."""
try:
self.object = self.model.objects.get(pk=self.kwargs['pk'])
except:
except Exception:
return None
return self.object
@ -691,14 +690,14 @@ class SettingsView(TemplateView):
try:
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
ctx["rates_updated"] = backend.last_update
except:
except Exception:
ctx["rates_updated"] = None
# load locale stats
STAT_FILE = os.path.abspath(os.path.join(settings.BASE_DIR, 'InvenTree/locale_stats.json'))
try:
ctx["locale_stats"] = json.load(open(STAT_FILE, 'r'))
except:
except Exception:
ctx["locale_stats"] = {}
# Forms and context for allauth
@ -801,40 +800,6 @@ class AppearanceSelectView(RedirectView):
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):
"""View for displaying database statistics."""

View File

@ -223,7 +223,7 @@ class BuildUnallocate(generics.CreateAPIView):
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
ctx['request'] = self.request
@ -243,7 +243,7 @@ class BuildOrderContextMixin:
try:
ctx['build'] = Build.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
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."""
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."""
queryset = BuildOrderAttachment.objects.all()

View File

@ -21,7 +21,7 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except: # pragma: no cover
except Exception: # pragma: no cover
ref = 0
build.reference_int = ref

View File

@ -1244,13 +1244,13 @@ class BuildItem(models.Model):
try:
# Try to extract the thumbnail
thumb_url = self.stock_item.part.image.thumbnail.url
except:
except Exception:
pass
if thumb_url is None and self.bom_item and self.bom_item.sub_part:
try:
thumb_url = self.bom_item.sub_part.image.thumbnail.url
except:
except Exception:
pass
if thumb_url is not None:

View File

@ -11,7 +11,7 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer
from InvenTree.serializers import UserSerializerBrief, ReferenceIndexingSerializerMixin
from InvenTree.serializers import ReferenceIndexingSerializerMixin, UserSerializer
import InvenTree.helpers
from InvenTree.helpers import extract_serial_numbers
@ -40,7 +40,7 @@ class BuildSerializer(ReferenceIndexingSerializerMixin, InvenTreeModelSerializer
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)
@ -860,6 +860,8 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [

View File

@ -1,5 +1,6 @@
"""Background task definitions for the BuildOrder app"""
from datetime import datetime, timedelta
from decimal import Decimal
import logging
@ -8,9 +9,12 @@ from django.template.loader import render_to_string
from allauth.account.models import EmailAddress
from plugin.events import trigger_event
import common.notifications
import build.models
import InvenTree.helpers
import InvenTree.tasks
from InvenTree.status_codes import BuildStatus
from InvenTree.ready import isImportingData
import part.models as part_models
@ -93,8 +97,67 @@ def check_build_stock(build: build.models.Build):
# Render the HTML message
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)
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)
# Create some more build outputs
for ii in range(10):
for _ in range(10):
self.build.create_build_output(10)
# Check that we are in a known state

View File

@ -1,11 +1,16 @@
"""Unit tests for the 'build' models"""
from datetime import datetime, timedelta
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from InvenTree import status_codes as status
import common.models
import build.tasks
from build.models import Build, BuildItem, get_next_build_number
from part.models import Part, BomItem, BomItemSubstitute
from stock.models import StockItem
@ -14,6 +19,10 @@ from stock.models import StockItem
class BuildTestBase(TestCase):
"""Run some tests to ensure that the Build model is working properly."""
fixtures = [
'users',
]
def setUp(self):
"""Initialize data to use for these tests.
@ -84,7 +93,8 @@ class BuildTestBase(TestCase):
reference=ref,
title="This is a build",
part=self.assembly,
quantity=10
quantity=10,
issued_by=get_user_model().objects.get(pk=1),
)
# Create some build output (StockItem) objects
@ -450,8 +460,6 @@ class AutoAllocationTests(BuildTestBase):
substitutes=True,
)
# self.assertTrue(self.build.are_untracked_parts_allocated())
# 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_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_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',
'name',
'read',
'creation',
]
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):
logger.info("Clearing SERVER_RESTART_REQUIRED flag")
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
except:
except Exception:
pass

View File

@ -142,7 +142,7 @@ class FileManager:
guess = self.guess_header(header, threshold=95)
# Check if already present
guess_exists = False
for idx, data in enumerate(headers):
for _idx, data in enumerate(headers):
if guess == data['guess']:
guess_exists = True
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
try:
return model_class.get_api_url()
except:
except Exception:
pass
return None
@ -710,7 +710,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_INSTANCE': {
'name': _('Server Instance Name'),
'default': 'InvenTree server',
'default': 'InvenTree',
'description': _('String descriptor for the server instance'),
},
@ -965,12 +965,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
},
'REPORT_ENABLE_TEST_REPORT': {
'name': _('Test Reports'),
'name': _('Enable Test Reports'),
'description': _('Enable generation of test reports'),
'default': True,
'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': {
'name': _('Batch Code Template'),
'description': _('Template for generating default batch codes for stock items'),

View File

@ -3,11 +3,15 @@
import logging
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 InvenTree.helpers import inheritors
from InvenTree.ready import isImportingData
from plugin import registry
from plugin.models import NotificationUserSetting
from users.models import Owner
logger = logging.getLogger('inventree')
@ -266,7 +270,7 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
if isImportingData():
return
# Resolve objekt reference
# Resolve object reference
obj_ref_value = getattr(obj, obj_ref)
# Try with some defaults
@ -285,11 +289,33 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
return
logger.info(f"Gathering users for notification '{category}'")
# Collect possible targets
if not targets:
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:
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)}'")
# Collect possible methods
@ -299,11 +325,12 @@ def trigger_notification(obj, category=None, obj_ref='pk', **kwargs):
delivery_methods = (delivery_methods - IGNORED_NOTIFICATION_CLS)
for method in delivery_methods:
logger.info(f"Triggering method '{method.METHOD_NAME}'")
logger.info(f"Triggering notification method '{method.METHOD_NAME}'")
try:
deliver_notification(method, obj, category, targets, context)
deliver_notification(method, obj, category, target_users, context)
except NotImplementedError as error:
raise error
# Allow any single notification method to fail, without failing the others
logger.error(error)
except Exception as error:
logger.error(error)

View File

@ -93,7 +93,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
def get_targets(self):
return [1, ]
with self.assertRaises(NotImplementedError):
with self.assertLogs(logger='inventree', level='ERROR'):
self._notification_run(WrongImplementation)
@ -115,7 +115,7 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
def get_targets(self):
return [1, ]
with self.assertRaises(NotImplementedError):
with self.assertLogs(logger='inventree', level='ERROR'):
self._notification_run(WrongImplementation)
# A integration test for notifications is provided in test_part.PartNotificationTest

View File

@ -78,7 +78,7 @@ class SettingsTest(InvenTreeTestCase):
# check as_int
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
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
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
self.patch(

View File

@ -495,8 +495,12 @@ class FileManagementAjaxView(AjaxView):
self.storage.current_step = self.steps.first
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."""
# Set default - see B006
if data is None:
data = {}
self.setTemplate()
return super().renderJsonResponse(request, form=form, data=data, context=context)

View File

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

View File

@ -151,7 +151,7 @@ class CompanyImageDownloadFromURL(AjaxUpdateView):
try:
self.image = Image.open(response.raw).convert()
self.image.verify()
except:
except Exception:
form.add_error('url', _("Supplied URL is not a valid image file"))
return

View File

@ -363,7 +363,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
# Filter string defined for the StockLocationLabel object
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except: # pragma: no cover
except Exception: # pragma: no cover
# Skip if there was an error validating the filters...
continue

View File

@ -22,8 +22,12 @@ class TestReportTests(InvenTreeAPITestCase):
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"""
# Set default - see B006
if filters is None:
filters = {}
response = self.client.get(self.list_url, filters, format='json')
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
try:
context['order'] = models.PurchaseOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
context['request'] = self.request
@ -527,7 +527,7 @@ class PurchaseOrderExtraLineDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.PurchaseOrderExtraLineSerializer
class SalesOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
class SalesOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
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."""
queryset = models.SalesOrderAttachment.objects.all()
@ -857,7 +857,7 @@ class SalesOrderContextMixin:
try:
ctx['order'] = models.SalesOrder.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
return ctx
@ -1050,13 +1050,13 @@ class SalesOrderShipmentComplete(generics.CreateAPIView):
ctx['shipment'] = models.SalesOrderShipment.objects.get(
pk=self.kwargs.get('pk', None)
)
except:
except Exception:
pass
return ctx
class PurchaseOrderAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
class PurchaseOrderAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
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."""
queryset = models.PurchaseOrderAttachment.objects.all()

View File

@ -20,7 +20,7 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except: # pragma: no cover
except Exception: # pragma: no cover
ref = 0
order.reference_int = ref
@ -37,7 +37,7 @@ def build_refs(apps, schema_editor):
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except: # pragma: no cover
except Exception: # pragma: no cover
ref = 0
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'))
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.
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),
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)
# gather name reference

View File

@ -630,6 +630,8 @@ class PurchaseOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'filename',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
@ -1348,6 +1350,8 @@ class SalesOrderAttachmentSerializer(InvenTreeAttachmentSerializer):
'link',
'comment',
'upload_date',
'user',
'user_detail',
]
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',
success: function(data, status, xhr) {
location.reload();
$('#attachment-table').bootstrapTable('refresh');
}
}
);

View File

@ -2,21 +2,29 @@
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.test import TestCase
from common.models import InvenTreeSetting
import order.tasks
from common.models import InvenTreeSetting, NotificationMessage
from company.models import Company
from InvenTree import status_codes as status
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem,
SalesOrderShipment)
from part.models import Part
from stock.models import StockItem
from users.models import Owner
class SalesOrderTest(TestCase):
"""Run tests to ensure that the SalesOrder model is working correctly."""
fixtures = [
'users',
]
def setUp(self):
"""Initial setup for this set of unit tests"""
# Create a Company to ship the goods to
@ -235,3 +243,20 @@ class SalesOrderTest(TestCase):
# Shipment should have default reference of '1'
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
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
import common.models
import order.tasks
from company.models import SupplierPart
from InvenTree.status_codes import PurchaseOrderStatus
from part.models import Part
from stock.models import StockLocation
from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderLineItem
@ -24,7 +29,8 @@ class OrderTest(TestCase):
'part',
'location',
'stock',
'order'
'order',
'users',
]
def test_basics(self):
@ -197,3 +203,37 @@ class OrderTest(TestCase):
order.receive_line_item(line, loc, line.quantity, user=None)
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()
class CategoryParameterList(generics.ListAPIView):
class CategoryParameterList(generics.ListCreateAPIView):
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
- GET: Return a list of PartCategoryParameterTemplate objects
@ -235,6 +235,13 @@ class CategoryParameterList(generics.ListAPIView):
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):
"""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)."""
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."""
queryset = PartAttachment.objects.all()
@ -599,7 +606,7 @@ class PartCopyBOM(generics.CreateAPIView):
try:
ctx['part'] = Part.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
return ctx
@ -1035,12 +1042,12 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
try:
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
except:
except Exception:
manufacturer = None
try:
supplier = Company.objects.get(pk=request.data.get('supplier', None))
except:
except Exception:
supplier = None
mpn = str(request.data.get('MPN', '')).strip()
@ -1855,7 +1862,11 @@ part_api_urls = [
# Base URL for PartCategory API endpoints
re_path(r'^category/', include([
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
re_path(r'^(?P<pk>\d+)/', include([

View File

@ -3,15 +3,12 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from mptt.fields import TreeNodeChoiceField
from common.forms import MatchItemForm
from InvenTree.fields import RoundingDecimalFormField
from InvenTree.forms import HelperForm
from InvenTree.helpers import clean_decimal
from .models import (Part, PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartSellPriceBreak)
from .models import Part, PartInternalPriceBreak, PartSellPriceBreak
class PartImageDownloadForm(HelperForm):
@ -53,35 +50,6 @@ class BomMatchItemForm(MatchItemForm):
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):
"""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:
latest = int(latest)
return latest
except:
except Exception:
# not an integer so 0
return 0
@ -610,7 +610,7 @@ class Part(MetadataMixin, MPTTModel):
# Attempt to turn into an integer
try:
latest = int(latest)
except:
except Exception:
pass
if type(latest) is int:
@ -1529,17 +1529,12 @@ class Part(MetadataMixin, MPTTModel):
"""Return the number of supplier parts available for this part."""
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
def has_complete_bom_pricing(self):
"""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'):
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 True
@ -2037,27 +2032,20 @@ class Part(MetadataMixin, MPTTModel):
return filtered_parts
def get_related_parts(self):
"""Return list of tuples for all related parts.
Includes:
- first value is PartRelated object
- second value is matching Part object
"""
related_parts = []
"""Return a set of all related parts for this part"""
related_parts = set()
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.append()
for related_part in related_parts_1:
# 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:
# Add to related parts list
related_parts.append(related_part.part_1)
related_parts.add(related_part.part_1)
return related_parts
@ -2283,7 +2271,7 @@ class PartTestTemplate(models.Model):
def validate_template_name(name):
"""Prevent illegal characters in "name" field for PartParameterTemplate."""
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"":
for c in "!@#$%^&*()<>{}[].,?/\\|~`_+-=\'\"": # noqa: P103
if c in str(name):
raise ValidationError(_(f"Illegal character in template name ({c})"))
@ -2383,7 +2371,9 @@ class PartParameter(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:
category: Reference to a single PartCategory object
@ -2827,44 +2817,35 @@ class BomItemSubstitute(models.Model):
class PartRelated(models.Model):
"""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',
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',
on_delete=models.DO_NOTHING,
on_delete=models.CASCADE,
verbose_name=_('Part 2'), help_text=_('Select Related Part'))
def __str__(self):
"""Return a string representation of this Part-Part relationship"""
return f'{self.part_1} <--> {self.part_2}'
def validate(self, part_1, part_2):
"""Validate that the two parts relationship is unique."""
validate = True
parts = Part.objects.all()
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 save(self, *args, **kwargs):
"""Enforce a 'clean' operation when saving a PartRelated instance"""
self.clean()
self.validate_unique()
super().save(*args, **kwargs)
def clean(self):
"""Overwrite clean method to check that relation is unique."""
validate = self.validate(self.part_1, self.part_2)
if not validate:
error_message = _('Error creating relationship: check that '
'the part is not related to itself '
'and that the relationship is unique')
super().clean()
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',
'comment',
'upload_date',
'user',
'user_detail',
]
read_only_fields = [
@ -753,10 +755,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
"""Serializer for PartCategoryParameterTemplate."""
"""Serializer for the PartCategoryParameterTemplate model."""
parameter_template = PartParameterTemplateSerializer(many=False,
read_only=True)
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template', many=False, read_only=True)
category_detail = CategorySerializer(source='category', many=False, read_only=True)
@ -768,6 +769,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
'category',
'category_detail',
'parameter_template',
'parameter_template_detail',
'default_value',
]
@ -902,7 +904,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
if level != 1:
# Skip this row
return None
except:
except Exception:
pass
# Attempt to extract a valid part based on the provided data
@ -954,7 +956,7 @@ class BomImportExtractSerializer(DataFileExtractSerializer):
if quantity <= 0:
row['errors']['quantity'] = _('Quantity must be greater than zero')
except:
except Exception:
row['errors']['quantity'] = _('Invalid quantity')
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()),
'template': {
'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'>
<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>
<ul class='dropdown-menu'>
{% if roles.part.change %}
@ -378,7 +378,6 @@
{% else %}category: "null",
{% endif %}
},
buttons: ['#part-options'],
checkbox: true,
gridView: true,
},

View File

@ -559,13 +559,13 @@
{% if roles.part.delete %}
$("#part-delete").click(function() {
launchModalForm(
"{% url 'part-delete' part.id %}",
{
redirect: {% if part.category %}"{% url 'category-detail' part.category.id %}"{% else %}"{% url 'part-index' %}"{% endif %},
no_post: {% if part.active %}true{% else %}false{% endif %},
}
);
deletePart({{ part.pk }}, {
{% if part.category %}
redirect: '{% url "category-detail" part.category.pk %}',
{% else %}
redirect: '{% url "part-index" %}',
{% endif %}
});
});
{% endif %}

View File

@ -60,6 +60,7 @@
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if min_total_bom_purchase_price %}
<tr>
<td><strong>{% trans 'Unit Purchase Price' %}</strong></td>
@ -75,13 +76,15 @@
{% endif %}
{% endif %}
{% if part.has_complete_bom_pricing == False %}
{% if not part.has_complete_bom_pricing %}
<tr>
<td colspan='3'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %}
<tr>
<td colspan='3'>
@ -122,7 +125,7 @@
</table>
{% 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 %}
<div class='alert alert-danger alert-block'>
{% 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>
</tr>
{% endif %}
{% endif %}
{% if min_total_bom_purchase_price %}
<tr>
@ -83,13 +83,15 @@
{% endif %}
{% endif %}
{% if part.has_complete_bom_pricing == False %}
{% if not part.has_complete_bom_pricing %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %}
<tr>
<td colspan='4'>
@ -131,7 +133,7 @@
{% endif %}
</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 %}
<div class='alert alert-danger alert-block'>
{% 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:
# Wrap with quotes
return format_html("'{}'", primitive)
return format_html("'{}'", primitive) # noqa: P103
@register.filter
@ -458,7 +458,7 @@ def authorized_owners(group):
def object_link(url_name, pk, ref):
"""Return highlighted link to object."""
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()

View File

@ -14,6 +14,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
StockStatus)
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
PartCategoryParameterTemplate, PartParameterTemplate,
PartRelated)
from stock.models import StockItem, StockLocation
@ -24,6 +25,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'params',
'location',
'bom',
'company',
@ -40,6 +42,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
'part.delete',
'part_category.change',
'part_category.add',
'part_category.delete',
]
def test_category_list(self):
@ -94,6 +97,57 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
self.assertEqual(metadata['water'], 'melon')
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):
"""Tests for the various OPTIONS endpoints in the /part/ API.
@ -1231,7 +1285,7 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['stock_item_count'], 4)
# Add some more stock items!!
for i in range(100):
for _ in range(100):
StockItem.objects.create(part=self.part, quantity=5)
# Add another stock item which is assigned to a customer (and shouldn't count)
@ -1574,7 +1628,7 @@ class BomItemTest(InvenTreeAPITestCase):
Part.objects.rebuild()
# Create some stock items for this new part
for jj in range(ii):
for _ in range(ii):
StockItem.objects.create(
part=variant,
location=loc,

View File

@ -228,7 +228,7 @@ class BomUploadTest(InvenTreeAPITestCase):
components = Part.objects.filter(component=True)
for idx, cmp in enumerate(components):
for idx, _ in enumerate(components):
dataset.append([
f"Component {idx}",
10,
@ -257,7 +257,7 @@ class BomUploadTest(InvenTreeAPITestCase):
dataset.headers = ['part_ipn', 'quantity']
for idx, cmp in enumerate(components):
for idx, _ in enumerate(components):
dataset.append([
f"CMP_{idx}",
10,

View File

@ -15,8 +15,8 @@ from common.notifications import UIMessageNotification, storage
from InvenTree import version
from InvenTree.helpers import InvenTreeTestCase
from .models import (Part, PartCategory, PartCategoryStar, PartStar,
PartTestTemplate, rename_part_image)
from .models import (Part, PartCategory, PartCategoryStar, PartRelated,
PartStar, PartTestTemplate, rename_part_image)
from .templatetags import inventree_extras
@ -44,7 +44,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_inventree_instance_name(self):
"""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):
"""Test that the base URL tag returns correctly"""
@ -190,7 +190,7 @@ class PartTest(TestCase):
try:
part.save()
self.assertTrue(False) # pragma: no cover
except:
except Exception:
pass
self.assertEqual(Part.objects.count(), n + 1)
@ -280,6 +280,53 @@ class PartTest(TestCase):
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):
"""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')
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
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'^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'),
]
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 = [
# Top level subcategory display
@ -42,8 +35,6 @@ category_urls = [
# Category detail views
re_path(r'(?P<pk>\d+)/', include([
re_path(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
re_path(r'^parameters/', include(category_parameter_urls)),
# Anything else
re_path(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
]))
@ -65,9 +56,6 @@ part_urls = [
# Part category
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
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.core.exceptions import ValidationError
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.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -27,8 +24,8 @@ from common.models import InvenTreeSetting
from common.views import FileManagementAjaxView, FileManagementFormView
from company.models import SupplierPart
from InvenTree.helpers import str2bool
from InvenTree.views import (AjaxCreateView, AjaxDeleteView, AjaxUpdateView,
AjaxView, InvenTreeRoleMixin, QRCodeView)
from InvenTree.views import (AjaxDeleteView, AjaxUpdateView, AjaxView,
InvenTreeRoleMixin, QRCodeView)
from order.models import PurchaseOrderLineItem
from plugin.views import InvenTreePluginViewMixin
from stock.models import StockItem, StockLocation
@ -36,7 +33,7 @@ from stock.models import StockItem, StockLocation
from . import forms as part_forms
from . import settings as part_settings
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
from .models import Part, PartCategory, PartCategoryParameterTemplate
from .models import Part, PartCategory
class PartIndex(InvenTreeRoleMixin, ListView):
@ -69,80 +66,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
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):
"""Part: Upload file, match to fields and import parts(using multi-Step form)"""
permission_required = 'part.add'
@ -620,7 +543,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
try:
self.image = Image.open(response.raw).convert()
self.image.verify()
except:
except Exception:
form.add_error('url', _("Supplied URL is not a valid image file"))
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):
"""View for inspecting part pricing information."""
@ -984,185 +890,3 @@ class CategoryDelete(AjaxDeleteView):
return {
'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):
# make sure all plugins are installed
registry.install_plugin_file()
except: # pragma: no cover
except Exception: # pragma: no cover
pass
# get plugins and init them

View File

@ -200,7 +200,7 @@ class ScheduleMixin:
try:
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)

View File

@ -5,6 +5,7 @@ from django.utils.translation import ugettext_lazy as _
from allauth.account.models import EmailAddress
import common.models
import InvenTree.tasks
from plugin import InvenTreePlugin
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)
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

View File

@ -169,7 +169,7 @@ class GitStatus:
def get_modules(pkg):
"""Get all modules in a package."""
context = {}
for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
for loader, name, _ in pkgutil.walk_packages(pkg.__path__):
try:
module = loader.find_module(name).load_module(name)
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'):
for slug, plugin in plugins:
for _, plugin in plugins:
if plugin.mixin_enabled('schedule'):
config = plugin.plugin_config()
@ -437,7 +437,7 @@ class PluginsRegistry:
apps_changed = False
# add them to the INSTALLED_APPS
for slug, plugin in plugins:
for _, plugin in plugins:
if plugin.mixin_enabled('app'):
plugin_path = self._get_plugin_path(plugin)
if plugin_path not in settings.INSTALLED_APPS:
@ -522,7 +522,7 @@ class PluginsRegistry:
# remove model from admin site
try:
admin.site.unregister(model)
except: # pragma: no cover
except Exception: # pragma: no cover
pass
models += [model._meta.model_name]
except LookupError: # pragma: no cover

View File

@ -113,7 +113,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
'icon': 'fa-user',
'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
return panels

View File

@ -57,7 +57,7 @@ def safe_url(view_name, *args, **kwargs):
"""
try:
return reverse(view_name, args=args, kwargs=kwargs)
except:
except Exception:
return None

View File

@ -1,6 +1,7 @@
"""API functionality for the 'report' app"""
from django.core.exceptions import FieldError, ValidationError
from django.core.files.base import ContentFile
from django.http import HttpResponse
from django.template.exceptions import TemplateDoesNotExist
from django.urls import include, path, re_path
@ -15,7 +16,7 @@ import common.models
import InvenTree.helpers
import order.models
import part.models
from stock.models import StockItem
from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
SalesOrderReport, TestReport)
@ -158,6 +159,18 @@ class PartReportMixin:
class ReportPrintMixin:
"""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):
"""Print this report template against a number of pre-validated items."""
if len(items_to_print) == 0:
@ -182,12 +195,16 @@ class ReportPrintMixin:
report.object_to_print = item
report_name = report.generate_filename(request)
output = report.render(request)
# Run report callback for each generated report
self.report_callback(item, output, request)
try:
if debug_mode:
outputs.append(report.render_as_string(request))
else:
outputs.append(report.render(request))
outputs.append(output)
except TemplateDoesNotExist as e:
template = str(e)
if not template:
@ -289,7 +306,7 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin):
# Filter string defined for the report object
try:
filters = InvenTree.helpers.validateFilterString(report.filters)
except:
except Exception:
continue
for item in items:
@ -326,6 +343,22 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, R
queryset = TestReport.objects.all()
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):
"""Check if valid stock item(s) have been provided."""
items = self.get_items()
@ -528,7 +561,7 @@ class PurchaseOrderReportList(ReportListView, OrderReportMixin):
# Filter string defined for the report object
try:
filters = InvenTree.helpers.validateFilterString(report.filters)
except:
except Exception:
continue
for o in orders:
@ -607,7 +640,7 @@ class SalesOrderReportList(ReportListView, OrderReportMixin):
# Filter string defined for the report object
try:
filters = InvenTree.helpers.validateFilterString(report.filters)
except:
except Exception:
continue
for o in orders:

View File

@ -75,14 +75,14 @@ class ReportConfig(AppConfig):
enabled=True
)
except:
except Exception:
pass
def create_default_test_reports(self):
"""Create database entries for the default TestReport templates, if they do not already exist."""
try:
from .models import TestReport
except: # pragma: no cover
except Exception: # pragma: no cover
# Database is not ready yet
return
@ -101,7 +101,7 @@ class ReportConfig(AppConfig):
"""Create database entries for the default BuildReport templates (if they do not already exist)"""
try:
from .models import BuildReport
except: # pragma: no cover
except Exception: # pragma: no cover
# Database is not ready yet
return

View File

@ -9,9 +9,9 @@ from django.urls import reverse
import report.models as report_models
from build.models import Build
from common.models import InvenTreeUserSetting
from common.models import InvenTreeSetting, InvenTreeUserSetting
from InvenTree.api_tester import InvenTreeAPITestCase
from stock.models import StockItem
from stock.models import StockItem, StockItemAttachment
class ReportTest(InvenTreeAPITestCase):
@ -141,15 +141,28 @@ class TestReportTest(ReportTest):
# Now print with a valid StockItem
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)
self.assertEqual(type(response), StreamingHttpResponse)
headers = response.headers
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):
"""Unit test class for the BuildReport model"""

View File

@ -99,7 +99,7 @@ class StockItemContextMixin:
try:
context['item'] = StockItem.objects.get(pk=self.kwargs.get('pk', None))
except:
except Exception:
pass
return context
@ -830,7 +830,7 @@ class StockList(APIDownloadMixin, generics.ListCreateAPIView):
if part.tree_id is not None:
queryset = queryset.filter(part__tree_id=part.tree_id)
except:
except Exception:
pass
# 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)."""
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."""
queryset = StockItemAttachment.objects.all()
@ -1144,7 +1144,7 @@ class StockItemTestResultList(generics.ListCreateAPIView):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
except:
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
@ -1186,12 +1186,12 @@ class StockTrackingList(generics.ListAPIView):
"""Set context before returning serializer."""
try:
kwargs['item_detail'] = str2bool(self.request.query_params.get('item_detail', False))
except:
except Exception:
pass
try:
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
except:
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
@ -1219,7 +1219,7 @@ class StockTrackingList(generics.ListAPIView):
part = Part.objects.get(pk=deltas['part'])
serializer = PartBriefSerializer(part)
deltas['part_detail'] = serializer.data
except:
except Exception:
pass
# Add location detail
@ -1228,7 +1228,7 @@ class StockTrackingList(generics.ListAPIView):
location = StockLocation.objects.get(pk=deltas['location'])
serializer = StockSerializers.LocationSerializer(location)
deltas['location_detail'] = serializer.data
except:
except Exception:
pass
# Add stockitem detail
@ -1237,7 +1237,7 @@ class StockTrackingList(generics.ListAPIView):
stockitem = StockItem.objects.get(pk=deltas['stockitem'])
serializer = StockSerializers.StockItemSerializer(stockitem)
deltas['stockitem_detail'] = serializer.data
except:
except Exception:
pass
# Add customer detail
@ -1246,7 +1246,7 @@ class StockTrackingList(generics.ListAPIView):
customer = Company.objects.get(pk=deltas['customer'])
serializer = CompanySerializer(customer)
deltas['customer_detail'] = serializer.data
except:
except Exception:
pass
# Add purchaseorder detail
@ -1255,7 +1255,7 @@ class StockTrackingList(generics.ListAPIView):
order = PurchaseOrder.objects.get(pk=deltas['purchaseorder'])
serializer = PurchaseOrderSerializer(order)
deltas['purchaseorder_detail'] = serializer.data
except:
except Exception:
pass
if request.is_ajax():

Some files were not shown because too many files have changed in this diff Show More