mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
fd6179fc9b
@ -18,6 +18,12 @@ repos:
|
||||
rev: '4.0.1'
|
||||
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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -95,7 +95,7 @@ def TestIfImage(img):
|
||||
try:
|
||||
Image.open(img).verify()
|
||||
return True
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
@ -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")
|
||||
|
@ -137,7 +137,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
if callable(default):
|
||||
try:
|
||||
default = default()
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
serializer_info[name]['default'] = default
|
||||
|
@ -98,7 +98,7 @@ class AuthRequiredMiddleware(object):
|
||||
if path not in urls and not any([path.startswith(p) for p in paths_ignore]):
|
||||
# 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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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)))
|
||||
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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 = [
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -274,6 +274,7 @@ class NotificationList(generics.ListAPIView):
|
||||
'category',
|
||||
'name',
|
||||
'read',
|
||||
'creation',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
||||
|
@ -158,6 +158,8 @@ class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
|
||||
'link',
|
||||
'comment',
|
||||
'upload_date',
|
||||
'user',
|
||||
'user_detail',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
136
InvenTree/order/tasks.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Background tasks for the 'order' app"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.notifications
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
import order.models
|
||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||
from plugin.events import trigger_event
|
||||
|
||||
|
||||
def notify_overdue_purchase_order(po: order.models.PurchaseOrder):
|
||||
"""Notify users that a PurchaseOrder has just become 'overdue'"""
|
||||
|
||||
targets = []
|
||||
|
||||
if po.created_by:
|
||||
targets.append(po.created_by)
|
||||
|
||||
if po.responsible:
|
||||
targets.append(po.responsible)
|
||||
|
||||
name = _('Overdue Purchase Order')
|
||||
|
||||
context = {
|
||||
'order': po,
|
||||
'name': name,
|
||||
'message': _(f'Purchase order {po} is now overdue'),
|
||||
'link': InvenTree.helpers.construct_absolute_url(
|
||||
po.get_absolute_url(),
|
||||
),
|
||||
'template': {
|
||||
'html': 'email/overdue_purchase_order.html',
|
||||
'subject': name,
|
||||
}
|
||||
}
|
||||
|
||||
event_name = 'order.overdue_purchase_order'
|
||||
|
||||
# Send a notification to the appropriate users
|
||||
common.notifications.trigger_notification(
|
||||
po,
|
||||
event_name,
|
||||
targets=targets,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Register a matching event to the plugin system
|
||||
trigger_event(
|
||||
event_name,
|
||||
purchase_order=po.pk,
|
||||
)
|
||||
|
||||
|
||||
def check_overdue_purchase_orders():
|
||||
"""Check if any outstanding PurchaseOrders have just become overdue:
|
||||
|
||||
- This check is performed daily
|
||||
- Look at the 'target_date' of any outstanding PurchaseOrder objects
|
||||
- If the 'target_date' expired *yesterday* then the order is just out of date
|
||||
"""
|
||||
|
||||
yesterday = datetime.now().date() - timedelta(days=1)
|
||||
|
||||
overdue_orders = order.models.PurchaseOrder.objects.filter(
|
||||
target_date=yesterday,
|
||||
status__in=PurchaseOrderStatus.OPEN
|
||||
)
|
||||
|
||||
for po in overdue_orders:
|
||||
notify_overdue_purchase_order(po)
|
||||
|
||||
|
||||
def notify_overdue_sales_order(so: order.models.SalesOrder):
|
||||
"""Notify appropriate users that a SalesOrder has just become 'overdue'"""
|
||||
|
||||
targets = []
|
||||
|
||||
if so.created_by:
|
||||
targets.append(so.created_by)
|
||||
|
||||
if so.responsible:
|
||||
targets.append(so.responsible)
|
||||
|
||||
name = _('Overdue Sales Order')
|
||||
|
||||
context = {
|
||||
'order': so,
|
||||
'name': name,
|
||||
'message': _(f"Sales order {so} is now overdue"),
|
||||
'link': InvenTree.helpers.construct_absolute_url(
|
||||
so.get_absolute_url(),
|
||||
),
|
||||
'template': {
|
||||
'html': 'email/overdue_sales_order.html',
|
||||
'subject': name,
|
||||
}
|
||||
}
|
||||
|
||||
event_name = 'order.overdue_sales_order'
|
||||
|
||||
# Send a notification to the appropriate users
|
||||
common.notifications.trigger_notification(
|
||||
so,
|
||||
event_name,
|
||||
targets=targets,
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Register a matching event to the plugin system
|
||||
trigger_event(
|
||||
event_name,
|
||||
sales_order=so.pk,
|
||||
)
|
||||
|
||||
|
||||
def check_overdue_sales_orders():
|
||||
"""Check if any outstanding SalesOrders have just become overdue
|
||||
|
||||
- This check is performed daily
|
||||
- Look at the 'target_date' of any outstanding SalesOrder objects
|
||||
- If the 'target_date' expired *yesterday* then the order is just out of date
|
||||
"""
|
||||
|
||||
yesterday = datetime.now().date() - timedelta(days=1)
|
||||
|
||||
overdue_orders = order.models.SalesOrder.objects.filter(
|
||||
target_date=yesterday,
|
||||
status__in=SalesOrderStatus.OPEN
|
||||
)
|
||||
|
||||
for po in overdue_orders:
|
||||
notify_overdue_sales_order(po)
|
@ -135,7 +135,7 @@
|
||||
},
|
||||
label: 'attachment',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
$('#attachment-table').bootstrapTable('refresh');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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([
|
||||
|
@ -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."""
|
||||
|
||||
|
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal file
28
InvenTree/part/migrations/0078_auto_20220606_0024.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.13 on 2022-06-06 00:24
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0077_alter_bomitem_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='partrelated',
|
||||
name='part_1',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_1', to='part.part', verbose_name='Part 1'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='partrelated',
|
||||
name='part_2',
|
||||
field=models.ForeignKey(help_text='Select Related Part', on_delete=django.db.models.deletion.CASCADE, related_name='related_parts_2', to='part.part', verbose_name='Part 2'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='partrelated',
|
||||
unique_together={('part_1', 'part_2')},
|
||||
),
|
||||
]
|
@ -593,7 +593,7 @@ class Part(MetadataMixin, MPTTModel):
|
||||
try:
|
||||
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"))
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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.' %}
|
||||
|
@ -1,78 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
{% if part.active %}
|
||||
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% blocktrans with full_name=part.full_name %}Part '<strong>{{full_name}}</strong>' cannot be deleted as it is still marked as <strong>active</strong>.
|
||||
<br>Disable the "Active" part attribute and re-try.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% blocktrans with full_name=part.full_name %}Are you sure you want to delete part '<strong>{{full_name}}</strong>'?{% endblocktrans %}
|
||||
</div>
|
||||
|
||||
{% if part.used_in_count %}
|
||||
<hr>
|
||||
<p>{% blocktrans with count=part.used_in_count %}This part is used in BOMs for {{count}} other parts. If you delete this part, the BOMs for the following parts will be updated{% endblocktrans %}:
|
||||
<ul class="list-group">
|
||||
{% for child in part.used_in.all %}
|
||||
<li class='list-group-item'>{{ child.part.full_name }} - {{ child.part.description }}</li>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if part.stock_items.all|length > 0 %}
|
||||
<hr>
|
||||
<p>{% blocktrans with count=part.stock_items.all|length %}There are {{count}} stock entries defined for this part. If you delete this part, the following stock entries will also be deleted:{% endblocktrans %}
|
||||
<ul class='list-group'>
|
||||
{% for stock in part.stock_items.all %}
|
||||
<li class='list-group-item'>{{ stock }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if part.manufacturer_parts.all|length > 0 %}
|
||||
<hr>
|
||||
<p>{% blocktrans with count=part.manufacturer_parts.all|length %}There are {{count}} manufacturers defined for this part. If you delete this part, the following manufacturer parts will also be deleted:{% endblocktrans %}
|
||||
<ul class='list-group'>
|
||||
{% for spart in part.manufacturer_parts.all %}
|
||||
<li class='list-group-item'>{% if spart.manufacturer %}{{ spart.manufacturer.name }} - {% endif %}{{ spart.MPN }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if part.supplier_parts.all|length > 0 %}
|
||||
<hr>
|
||||
<p>{% blocktrans with count=part.supplier_parts.all|length %}There are {{count}} suppliers defined for this part. If you delete this part, the following supplier parts will also be deleted:{% endblocktrans %}
|
||||
<ul class='list-group'>
|
||||
{% for spart in part.supplier_parts.all %}
|
||||
{% if spart.supplier %}
|
||||
<li class='list-group-item'>{{ spart.supplier.name }} - {{ spart.SKU }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if part.serials.all|length > 0 %}
|
||||
<hr>
|
||||
<p>{% blocktrans with count=part.serials.all|length full_name=part.full_name %}There are {{count}} unique parts tracked for '{{full_name}}'. Deleting this part will permanently remove this tracking information.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
{% if not part.active %}
|
||||
{{ block.super }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -64,9 +64,9 @@
|
||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if min_total_bom_purchase_price %}
|
||||
{% if min_total_bom_purchase_price %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Unit Purchase Price' %}</td>
|
||||
@ -81,15 +81,17 @@
|
||||
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.has_complete_bom_pricing == False %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% 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.' %}
|
||||
|
@ -1,43 +0,0 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<label class='control-label'>Parts</label>
|
||||
<p class='help-block'>{% trans "Set category for the following parts" %}</p>
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th>
|
||||
</tr>
|
||||
{% for part in parts %}
|
||||
<tr id='part_row_{{ part.id }}'>
|
||||
<input type='hidden' name='part_id_{{ part.id }}' value='1'/>
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.image hover=False %}
|
||||
{{ part.full_name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ part.description }}
|
||||
</td>
|
||||
<td>
|
||||
{{ part.category.pathstring }}
|
||||
</td>
|
||||
<td>
|
||||
<button class='btn btn-outline-secondary btn-remove' onClick='removeRowFromModalForm()' title='{% trans "Remove part" %}' type='button'>
|
||||
<span row='part_row_{{ part.id }}' class='fas fa-trash-alt icon-red'></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
@ -412,7 +412,7 @@ def primitive_to_javascript(primitive):
|
||||
|
||||
else:
|
||||
# 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()
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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"""
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"""
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user