Merge branch 'master' of https://github.com/inventree/InvenTree into plugin-2037

This commit is contained in:
Matthias 2021-10-15 23:36:19 +02:00
commit 32122102e6
No known key found for this signature in database
GPG Key ID: F50EF5741D33E076
62 changed files with 1679 additions and 606 deletions

View File

@ -34,18 +34,47 @@ class InvenTreeOrderingFilter(OrderingFilter):
Ordering fields should be mapped to separate fields
"""
for idx, field in enumerate(ordering):
ordering_initial = ordering
ordering = []
reverse = False
for field in ordering_initial:
reverse = field.startswith('-')
if field.startswith('-'):
if reverse:
field = field[1:]
reverse = True
# Are aliases defined for this field?
if field in aliases:
ordering[idx] = aliases[field]
alias = aliases[field]
else:
alias = field
"""
Potentially, a single field could be "aliased" to multiple field,
(For example to enforce a particular ordering sequence)
e.g. to filter first by the integer value...
ordering_field_aliases = {
"reference": ["integer_ref", "reference"]
}
"""
if type(alias) is str:
alias = [alias]
elif type(alias) in [list, tuple]:
pass
else:
# Unsupported alias type
continue
for a in alias:
if reverse:
ordering[idx] = '-' + ordering[idx]
a = '-' + a
ordering.append(a)
return ordering

View File

@ -4,10 +4,12 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from django.utils.translation import ugettext_lazy as _
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from django.conf import settings
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
@ -20,6 +22,8 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from part.models import PartCategory
from common.models import InvenTreeSetting
logger = logging.getLogger('inventree')
class HelperForm(forms.ModelForm):
""" Provides simple integration of crispy_forms extension. """
@ -223,11 +227,11 @@ class CustomSignupForm(SignupForm):
# check for two mail fields
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_MAIL_TWICE'):
self.fields["email2"] = forms.EmailField(
label=_("E-mail (again)"),
label=_("Email (again)"),
widget=forms.TextInput(
attrs={
"type": "email",
"placeholder": _("E-mail address confirmation"),
"placeholder": _("Email address confirmation"),
}
),
)
@ -257,10 +261,22 @@ class RegistratonMixin:
Mixin to check if registration should be enabled
"""
def is_open_for_signup(self, request):
if InvenTreeSetting.get_setting('EMAIL_HOST', None) and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
if settings.EMAIL_HOST and InvenTreeSetting.get_setting('LOGIN_ENABLE_REG', True):
return super().is_open_for_signup(request)
return False
def save_user(self, request, user, form, commit=True):
user = super().save_user(request, user, form, commit=commit)
start_group = InvenTreeSetting.get_setting('SIGNUP_GROUP')
if start_group:
try:
group = Group.objects.get(id=start_group)
user.groups.add(group)
except Group.DoesNotExist:
logger.error('The setting `SIGNUP_GROUP` contains an non existant group', start_group)
user.save()
return user
class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
"""
@ -268,7 +284,7 @@ class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter):
"""
def send_mail(self, template_prefix, email, context):
"""only send mail if backend configured"""
if InvenTreeSetting.get_setting('EMAIL_HOST', None):
if settings.EMAIL_HOST:
return super().send_mail(template_prefix, email, context)
return False

View File

@ -1 +0,0 @@
{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1}

View File

@ -4,6 +4,7 @@ Generic models which provide extra functionality over base Django model types.
from __future__ import unicode_literals
import re
import os
import logging
@ -43,6 +44,48 @@ def rename_attachment(instance, filename):
return os.path.join(instance.getSubdir(), filename)
class ReferenceIndexingMixin(models.Model):
"""
A mixin for keeping track of numerical copies of the "reference" field.
Here, we attempt to convert a "reference" field value (char) to an integer,
for performing fast natural sorting.
This requires extra database space (due to the extra table column),
but is required as not all supported database backends provide equivalent casting.
This mixin adds a field named 'reference_int'.
- If the 'reference' field can be cast to an integer, it is stored here
- If the 'reference' field *starts* with an integer, it is stored here
- Otherwise, we store zero
"""
class Meta:
abstract = True
def rebuild_reference_field(self):
reference = getattr(self, 'reference', '')
# Default value if we cannot convert to an integer
ref_int = 0
# Look at the start of the string - can it be "integerized"?
result = re.match(r"^(\d+)", reference)
if result and len(result.groups()) == 1:
ref = result.groups()[0]
try:
ref_int = int(ref)
except:
ref_int = 0
self.reference_int = ref_int
reference_int = models.IntegerField(default=0)
class InvenTreeAttachment(models.Model):
""" Provides an abstracted class for managing file attachments.

View File

@ -396,39 +396,6 @@ Q_CLUSTER = {
'sync': False,
}
# Markdownx configuration
# Ref: https://neutronx.github.io/django-markdownx/customization/
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
# Markdownify configuration
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
MARKDOWNIFY_WHITELIST_TAGS = [
'a',
'abbr',
'b',
'blockquote',
'em',
'h1', 'h2', 'h3',
'i',
'img',
'li',
'ol',
'p',
'strong',
'ul'
]
MARKDOWNIFY_WHITELIST_ATTRS = [
'href',
'src',
'alt',
]
MARKDOWNIFY_BLEACH = False
DATABASES = {}
"""
Configure the database backend based on the user-specified values.
@ -495,7 +462,47 @@ logger.info(f"DB_ENGINE: {db_engine}")
logger.info(f"DB_NAME: {db_name}")
logger.info(f"DB_HOST: {db_host}")
DATABASES['default'] = db_config
"""
In addition to base-level database configuration, we may wish to specify specific options to the database backend
Ref: https://docs.djangoproject.com/en/3.2/ref/settings/#std:setting-OPTIONS
"""
# 'OPTIONS' or 'options' can be specified in config.yaml
db_options = db_config.get('OPTIONS', db_config.get('options', {}))
# Specific options for postgres backend
if 'postgres' in db_engine:
from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED, ISOLATION_LEVEL_SERIALIZABLE
# Connection timeout
if 'connect_timeout' not in db_options:
db_options['connect_timeout'] = int(os.getenv('INVENTREE_DB_TIMEOUT', 2))
# Postgres's default isolation level is Read Committed which is
# normally fine, but most developers think the database server is
# actually going to do Serializable type checks on the queries to
# protect against simultaneous changes.
if 'isolation_level' not in db_options:
serializable = _is_true(os.getenv("PG_ISOLATION_SERIALIZABLE", "true"))
db_options['isolation_level'] = ISOLATION_LEVEL_SERIALIZABLE if serializable else ISOLATION_LEVEL_READ_COMMITTED
# Specific options for MySql / MariaDB backend
if 'mysql' in db_engine:
# TODO
pass
# Specific options for sqlite backend
if 'sqlite' in db_engine:
# TODO
pass
# Provide OPTIONS dict back to the database configuration dict
db_config['OPTIONS'] = db_options
DATABASES = {
'default': db_config
}
CACHES = {
'default': {
@ -695,6 +702,37 @@ ACCOUNT_FORMS = {
SOCIALACCOUNT_ADAPTER = 'InvenTree.forms.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.forms.CustomAccountAdapter'
# Markdownx configuration
# Ref: https://neutronx.github.io/django-markdownx/customization/
MARKDOWNX_MEDIA_PATH = datetime.now().strftime('markdownx/%Y/%m/%d')
# Markdownify configuration
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
MARKDOWNIFY_WHITELIST_TAGS = [
'a',
'abbr',
'b',
'blockquote',
'em',
'h1', 'h2', 'h3',
'i',
'img',
'li',
'ol',
'p',
'strong',
'ul'
]
MARKDOWNIFY_WHITELIST_ATTRS = [
'href',
'src',
'alt',
]
MARKDOWNIFY_BLEACH = False
# Plugins
PLUGIN_URL = 'plugin'

View File

@ -455,6 +455,10 @@
-webkit-opacity: 10%;
}
.table-condensed {
font-size: 90%;
}
/* grid display for part images */
.table-img-grid tr {

View File

@ -5,6 +5,7 @@ Custom field validators for InvenTree
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import FieldDoesNotExist
from moneyed import CURRENCIES
@ -156,3 +157,33 @@ def validate_overage(value):
raise ValidationError(
_("Overage must be an integer value or a percentage")
)
def validate_part_name_format(self):
"""
Validate part name format.
Make sure that each template container has a field of Part Model
"""
jinja_template_regex = re.compile('{{.*?}}')
field_name_regex = re.compile('(?<=part\\.)[A-z]+')
for jinja_template in jinja_template_regex.findall(str(self)):
# make sure at least one and only one field is present inside the parser
field_names = field_name_regex.findall(jinja_template)
if len(field_names) < 1:
raise ValidationError({
'value': 'At least one field must be present inside a jinja template container i.e {{}}'
})
# Make sure that the field_name exists in Part model
from part.models import Part
for field_name in field_names:
try:
Part._meta.get_field(field_name)
except FieldDoesNotExist:
raise ValidationError({
'value': f'{field_name} does not exist in Part Model'
})
return True

View File

@ -9,6 +9,10 @@ from .models import Build, BuildItem
class BuildAdmin(ImportExportModelAdmin):
exclude = [
'reference_int',
]
list_display = (
'reference',
'title',

View File

@ -17,11 +17,12 @@ from django_filters import rest_framework as rest_filters
from InvenTree.api import AttachmentMixin
from InvenTree.helpers import str2bool, isNull
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
from .models import Build, BuildItem, BuildOrderAttachment
from .serializers import BuildAttachmentSerializer, BuildSerializer, BuildItemSerializer
from .serializers import BuildAllocationSerializer
from .serializers import BuildAllocationSerializer, BuildUnallocationSerializer
class BuildFilter(rest_filters.FilterSet):
@ -68,7 +69,7 @@ class BuildList(generics.ListCreateAPIView):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
InvenTreeOrderingFilter,
]
ordering_fields = [
@ -83,6 +84,10 @@ class BuildList(generics.ListCreateAPIView):
'responsible',
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
}
search_fields = [
'reference',
'part__name',
@ -184,6 +189,42 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
serializer_class = BuildSerializer
class BuildUnallocate(generics.CreateAPIView):
"""
API endpoint for unallocating stock items from a build order
- The BuildOrder object is specified by the URL
- "output" (StockItem) can optionally be specified
- "bom_item" can optionally be specified
"""
queryset = Build.objects.none()
serializer_class = BuildUnallocationSerializer
def get_build(self):
"""
Returns the BuildOrder associated with this API endpoint
"""
pk = self.kwargs.get('pk', None)
try:
build = Build.objects.get(pk=pk)
except (ValueError, Build.DoesNotExist):
raise ValidationError(_("Matching build order does not exist"))
return build
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['build'] = self.get_build()
ctx['request'] = self.request
return ctx
class BuildAllocate(generics.CreateAPIView):
"""
API endpoint to allocate stock items to a build order
@ -349,6 +390,7 @@ build_api_urls = [
# Build Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^allocate/', BuildAllocate.as_view(), name='api-build-allocate'),
url(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
url(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),

View File

@ -137,32 +137,6 @@ class BuildOutputDeleteForm(HelperForm):
]
class UnallocateBuildForm(HelperForm):
"""
Form for auto-de-allocation of stock from a build
"""
confirm = forms.BooleanField(required=False, label=_('Confirm'), help_text=_('Confirm unallocation of stock'))
output_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput()
)
part_id = forms.IntegerField(
required=False,
widget=forms.HiddenInput(),
)
class Meta:
model = Build
fields = [
'confirm',
'output_id',
'part_id',
]
class CompleteBuildForm(HelperForm):
"""
Form for marking a build as complete

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-10-14 06:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0030_alter_build_reference'),
]
operations = [
migrations.AddField(
model_name='build',
name='reference_int',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 3.2.5 on 2021-10-14 06:32
import re
from django.db import migrations
def build_refs(apps, schema_editor):
"""
Rebuild the integer "reference fields" for existing Build objects
"""
BuildOrder = apps.get_model('build', 'build')
for build in BuildOrder.objects.all():
ref = 0
result = re.match(r"^(\d+)", build.reference)
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except:
ref = 0
build.reference_int = ref
build.save()
def unbuild_refs(apps, schema_editor):
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration):
atomic = False
dependencies = [
('build', '0031_build_reference_int'),
]
operations = [
migrations.RunPython(
build_refs,
reverse_code=unbuild_refs
)
]

View File

@ -28,7 +28,7 @@ from mptt.exceptions import InvalidMove
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
from InvenTree.validators import validate_build_order_reference
from InvenTree.models import InvenTreeAttachment
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
import common.models
@ -69,7 +69,7 @@ def get_next_build_number():
return reference
class Build(MPTTModel):
class Build(MPTTModel, ReferenceIndexingMixin):
""" A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -108,6 +108,8 @@ class Build(MPTTModel):
def save(self, *args, **kwargs):
self.rebuild_reference_field()
try:
super().save(*args, **kwargs)
except InvalidMove:
@ -587,9 +589,13 @@ class Build(MPTTModel):
self.save()
@transaction.atomic
def unallocateOutput(self, output, part=None):
def unallocateStock(self, bom_item=None, output=None):
"""
Unallocate all stock which are allocated against the provided "output" (StockItem)
Unallocate stock from this Build
arguments:
- bom_item: Specify a particular BomItem to unallocate stock against
- output: Specify a particular StockItem (output) to unallocate stock against
"""
allocations = BuildItem.objects.filter(
@ -597,34 +603,8 @@ class Build(MPTTModel):
install_into=output
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateUntracked(self, part=None):
"""
Unallocate all "untracked" stock
"""
allocations = BuildItem.objects.filter(
build=self,
install_into=None
)
if part:
allocations = allocations.filter(stock_item__part=part)
allocations.delete()
@transaction.atomic
def unallocateAll(self):
"""
Deletes all stock allocations for this build.
"""
allocations = BuildItem.objects.filter(build=self)
if bom_item:
allocations = allocations.filter(bom_item=bom_item)
allocations.delete()
@ -720,7 +700,7 @@ class Build(MPTTModel):
raise ValidationError(_("Build output does not match Build Order"))
# Unallocate all build items against the output
self.unallocateOutput(output)
self.unallocateStock(output=output)
# Remove the build output from the database
output.delete()
@ -1153,16 +1133,12 @@ class BuildItem(models.Model):
i) The sub_part points to the same part as the referenced StockItem
ii) The BomItem allows variants and the part referenced by the StockItem
is a variant of the sub_part referenced by the BomItem
iii) The Part referenced by the StockItem is a valid substitute for the BomItem
"""
if self.build and self.build.part == self.bom_item.part:
# Check that the sub_part points to the stock_item (either directly or via a variant)
if self.bom_item.sub_part == self.stock_item.part:
bom_item_valid = True
elif self.bom_item.allow_variants and self.stock_item.part in self.bom_item.sub_part.get_descendants(include_self=False):
bom_item_valid = True
bom_item_valid = self.bom_item.is_stock_item_valid(self.stock_item)
# If the existing BomItem is *not* valid, try to find a match
if not bom_item_valid:

View File

@ -120,6 +120,61 @@ class BuildSerializer(InvenTreeModelSerializer):
]
class BuildUnallocationSerializer(serializers.Serializer):
"""
DRF serializer for unallocating stock from a BuildOrder
Allocated stock can be unallocated with a number of filters:
- output: Filter against a particular build output (blank = untracked stock)
- bom_item: Filter against a particular BOM line item
"""
bom_item = serializers.PrimaryKeyRelatedField(
queryset=BomItem.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('BOM Item'),
)
output = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.filter(
is_building=True,
),
many=False,
allow_null=True,
required=False,
label=_("Build output"),
)
def validate_output(self, stock_item):
# Stock item must point to the same build order!
build = self.context['build']
if stock_item and stock_item.build != build:
raise ValidationError(_("Build output must point to the same build"))
return stock_item
def save(self):
"""
'Save' the serializer data.
This performs the actual unallocation against the build order
"""
build = self.context['build']
data = self.validated_data
build.unallocateStock(
bom_item=data['bom_item'],
output=data['output']
)
class BuildAllocationItemSerializer(serializers.Serializer):
"""
A serializer for allocating a single stock item against a build order

View File

@ -197,7 +197,7 @@
<button id='allocate-selected-items' class='btn btn-success' title='{% trans "Allocate selected items" %}'>
<span class='fas fa-sign-in-alt'></span>
</button>
<div class='filter-list' id='filter-list-build-items'>
<div class='filter-list' id='filter-list-builditems'>
<!-- Empty div for table filters-->
</div>
</div>
@ -462,12 +462,9 @@ $("#btn-auto-allocate").on('click', function() {
});
$('#btn-unallocate').on('click', function() {
launchModalForm(
"{% url 'build-unallocate' build.id %}",
{
success: reloadTable,
}
);
unallocateStock({{ build.id }}, {
table: '#allocation-table-untracked',
});
});
$('#allocate-selected-items').click(function() {

View File

@ -1,15 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block pre_form_content %}
{{ block.super }}
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you wish to unallocate all stock for this build?" %}
<br>
{% trans "All incomplete stock allocations will be removed from the build" %}
</div>
{% endblock %}

View File

@ -118,6 +118,26 @@ class BuildTest(TestCase):
self.stock_3_1 = StockItem.objects.create(part=self.sub_part_3, quantity=1000)
def test_ref_int(self):
"""
Test the "integer reference" field used for natural sorting
"""
for ii in range(10):
build = Build(
reference=f"{ii}_abcde",
quantity=1,
part=self.assembly,
title="Making some parts"
)
self.assertEqual(build.reference_int, 0)
build.save()
# After saving, the integer reference should have been updated
self.assertEqual(build.reference_int, ii)
def test_init(self):
# Perform some basic tests before we start the ball rolling
@ -250,7 +270,7 @@ class BuildTest(TestCase):
self.assertEqual(len(unallocated), 1)
self.build.unallocateUntracked()
self.build.unallocateStock()
unallocated = self.build.unallocatedParts(None)

View File

@ -323,22 +323,3 @@ class TestBuildViews(TestCase):
b = Build.objects.get(pk=1)
self.assertEqual(b.status, 30) # Build status is now CANCELLED
def test_build_unallocate(self):
""" Test the build unallocation view (ajax form) """
url = reverse('build-unallocate', args=(1,))
# Test without confirmation
response = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# Test with confirmation
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])

View File

@ -12,7 +12,6 @@ build_detail_urls = [
url(r'^create-output/', views.BuildOutputCreate.as_view(), name='build-output-create'),
url(r'^delete-output/', views.BuildOutputDelete.as_view(), name='build-output-delete'),
url(r'^complete-output/', views.BuildOutputComplete.as_view(), name='build-output-complete'),
url(r'^unallocate/', views.BuildUnallocate.as_view(), name='build-unallocate'),
url(r'^complete/', views.BuildComplete.as_view(), name='build-complete'),
url(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),

View File

@ -10,14 +10,13 @@ from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from part.models import Part
from .models import Build
from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, extract_serial_numbers, isNull
from InvenTree.helpers import str2bool, extract_serial_numbers
from InvenTree.status_codes import BuildStatus, StockStatus
@ -246,88 +245,6 @@ class BuildOutputDelete(AjaxUpdateView):
}
class BuildUnallocate(AjaxUpdateView):
""" View to un-allocate all parts from a build.
Provides a simple confirmation dialog with a BooleanField checkbox.
"""
model = Build
form_class = forms.UnallocateBuildForm
ajax_form_title = _("Unallocate Stock")
ajax_template_name = "build/unallocate.html"
def get_initial(self):
initials = super().get_initial()
# Pointing to a particular build output?
output = self.get_param('output')
if output:
initials['output_id'] = output
# Pointing to a particular part?
part = self.get_param('part')
if part:
initials['part_id'] = part
return initials
def post(self, request, *args, **kwargs):
build = self.get_object()
form = self.get_form()
confirm = request.POST.get('confirm', False)
output_id = request.POST.get('output_id', None)
if output_id:
# If a "null" output is provided, we are trying to unallocate "untracked" stock
if isNull(output_id):
output = None
else:
try:
output = StockItem.objects.get(pk=output_id)
except (ValueError, StockItem.DoesNotExist):
output = None
part_id = request.POST.get('part_id', None)
try:
part = Part.objects.get(pk=part_id)
except (ValueError, Part.DoesNotExist):
part = None
valid = False
if confirm is False:
form.add_error('confirm', _('Confirm unallocation of build stock'))
form.add_error(None, _('Check the confirmation box'))
else:
valid = True
# Unallocate the entire build
if not output_id:
build.unallocateAll()
# Unallocate a single output
elif output:
build.unallocateOutput(output, part=part)
# Unallocate "untracked" parts
else:
build.unallocateUntracked(part=part)
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data)
class BuildComplete(AjaxUpdateView):
"""
View to mark the build as complete.

View File

@ -12,7 +12,7 @@ import math
import uuid
from django.db import models, transaction
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from django.db.utils import IntegrityError, OperationalError
from django.conf import settings
@ -26,6 +26,7 @@ from django.core.exceptions import ValidationError
import InvenTree.helpers
import InvenTree.fields
import InvenTree.validators
import logging
@ -182,12 +183,9 @@ class BaseInvenTreeSetting(models.Model):
else:
choices = None
"""
TODO:
if type(choices) is function:
if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
"""
return choices
@ -479,6 +477,11 @@ class BaseInvenTreeSetting(models.Model):
return value
def settings_group_options():
"""build up group tuple for settings based on gour choices"""
return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
class InvenTreeSetting(BaseInvenTreeSetting):
"""
An InvenTreeSetting object is a key:value pair used for storing
@ -703,6 +706,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool
},
'PART_NAME_FORMAT': {
'name': _('Part Name Display Format'),
'description': _('Format to display the part name'),
'default': "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}{{ ' | ' if part.revision }}"
"{{ part.revision if part.revision }}",
'validator': InvenTree.validators.validate_part_name_format
},
'REPORT_DEBUG_MODE': {
'name': _('Debug Mode'),
'description': _('Generate reports in debug mode (HTML output)'),
@ -794,43 +805,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': 'PO',
},
# enable/diable ui elements
'BUILD_FUNCTION_ENABLE': {
'name': _('Enable build'),
'description': _('Enable build functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'BUY_FUNCTION_ENABLE': {
'name': _('Enable buy'),
'description': _('Enable buy functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'SELL_FUNCTION_ENABLE': {
'name': _('Enable sell'),
'description': _('Enable sell functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'STOCK_FUNCTION_ENABLE': {
'name': _('Enable stock'),
'description': _('Enable stock functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'SO_FUNCTION_ENABLE': {
'name': _('Enable SO'),
'description': _('Enable SO functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
'PO_FUNCTION_ENABLE': {
'name': _('Enable PO'),
'description': _('Enable PO functionality in InvenTree interface'),
'default': True,
'validator': bool,
},
# login / SSO
'LOGIN_ENABLE_PWD_FORGOT': {
'name': _('Enable password forgot'),
@ -851,7 +825,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'validator': bool,
},
'LOGIN_MAIL_REQUIRED': {
'name': _('E-Mail required'),
'name': _('Email required'),
'description': _('Require user to supply mail on signup'),
'default': False,
'validator': bool,
@ -874,7 +848,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
**settings.INTEGRATION_PLUGIN_SETTINGS
'SIGNUP_GROUP': {
'name': _('Group on signup'),
'description': _('Group new user are asigned on registration'),
'default': '',
'choices': settings_group_options
},
**settings.INTEGRATION_PLUGIN_SETTINGS,
}
class Meta:

View File

@ -136,3 +136,24 @@ class SettingsViewTest(TestCase):
for value in [False, 'False']:
self.post(url, {'value': value}, valid=True)
self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT'))
def test_part_name_format(self):
"""
Try posting some valid and invalid name formats for PART_NAME_FORMAT
"""
setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT')
# test default value
self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}"
"{{ ' | ' if part.revision }}{{ part.revision if part.revision }}")
url = self.get_url(setting.pk)
# Try posting an invalid part name format
invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}']
for invalid_value in invalid_values:
self.post(url, {'value': invalid_value}, valid=False)
# try posting valid value
new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}"
self.post(url, {'value': new_format}, valid=True)

View File

@ -2,10 +2,6 @@
{% load static %}
{% load inventree_extras %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='company-menu-toggle'>
@ -32,7 +28,6 @@
{% endif %}
{% if company.is_manufacturer or company.is_supplier %}
{% if enable_stock %}
<li class='list-group-item' title='{% trans "Stock Items" %}'>
<a href='#' id='select-company-stock' class='nav-toggle'>
<span class='fas fa-boxes sidebar-icon'></span>
@ -40,9 +35,8 @@
</a>
</li>
{% endif %}
{% endif %}
{% if company.is_supplier and enable_po %}
{% if company.is_supplier %}
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
<a href='#' id='select-purchase-orders' class='nav-toggle'>
<span class='fas fa-shopping-cart sidebar-icon'></span>
@ -51,7 +45,7 @@
</li>
{% endif %}
{% if company.is_customer and enable_so %}
{% if company.is_customer %}
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
<a href='#' id='select-sales-orders' class='nav-toggle'>
<span class='fas fa-truck sidebar-icon'></span>

View File

@ -20,6 +20,10 @@ class PurchaseOrderLineItemInlineAdmin(admin.StackedInline):
class PurchaseOrderAdmin(ImportExportModelAdmin):
exclude = [
'reference_int',
]
list_display = (
'reference',
'supplier',
@ -41,6 +45,10 @@ class PurchaseOrderAdmin(ImportExportModelAdmin):
class SalesOrderAdmin(ImportExportModelAdmin):
exclude = [
'reference_int',
]
list_display = (
'reference',
'customer',

View File

@ -152,9 +152,13 @@ class POList(generics.ListCreateAPIView):
filter_backends = [
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
InvenTreeOrderingFilter,
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
}
filter_fields = [
'supplier',
]
@ -504,9 +508,13 @@ class SOList(generics.ListCreateAPIView):
filter_backends = [
rest_filters.DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
InvenTreeOrderingFilter,
]
ordering_field_aliases = {
'reference': ['reference_int', 'reference'],
}
filter_fields = [
'customer',
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.5 on 2021-10-14 06:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0050_alter_purchaseorderlineitem_destination'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='reference_int',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='salesorder',
name='reference_int',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,66 @@
# Generated by Django 3.2.5 on 2021-10-14 06:31
import re
from django.db import migrations
def build_refs(apps, schema_editor):
"""
Rebuild the integer "reference fields" for existing Build objects
"""
PurchaseOrder = apps.get_model('order', 'purchaseorder')
for order in PurchaseOrder.objects.all():
ref = 0
result = re.match(r"^(\d+)", order.reference)
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except:
ref = 0
order.reference_int = ref
order.save()
SalesOrder = apps.get_model('order', 'salesorder')
for order in SalesOrder.objects.all():
ref = 0
result = re.match(r"^(\d+)", order.reference)
if result and len(result.groups()) == 1:
try:
ref = int(result.groups()[0])
except:
ref = 0
order.reference_int = ref
order.save()
def unbuild_refs(apps, schema_editor):
"""
Provided only for reverse migration compatibility
"""
pass
class Migration(migrations.Migration):
dependencies = [
('order', '0051_auto_20211014_0623'),
]
operations = [
migrations.RunPython(
build_refs,
reverse_code=unbuild_refs
)
]

View File

@ -28,7 +28,7 @@ from company.models import Company, SupplierPart
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
from InvenTree.helpers import decimal2string, increment, getSetting
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus, StockHistoryCode
from InvenTree.models import InvenTreeAttachment
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
def get_next_po_number():
@ -89,7 +89,7 @@ def get_next_so_number():
return reference
class Order(models.Model):
class Order(ReferenceIndexingMixin):
""" Abstract model for an order.
Instances of this class:
@ -147,6 +147,9 @@ class Order(models.Model):
return new_ref
def save(self, *args, **kwargs):
self.rebuild_reference_field()
if not self.creation_date:
self.creation_date = datetime.now().date()
@ -531,6 +534,12 @@ class SalesOrder(Order):
return queryset
def save(self, *args, **kwargs):
self.rebuild_reference_field()
super().save(*args, **kwargs)
def __str__(self):
prefix = getSetting('SALESORDER_REFERENCE_PREFIX')

View File

@ -0,0 +1,59 @@
"""
Unit tests for the 'order' model data migrations
"""
from django_test_migrations.contrib.unittest_case import MigratorTestCase
from InvenTree import helpers
class TestForwardMigrations(MigratorTestCase):
"""
Test entire schema migration
"""
migrate_from = ('order', helpers.getOldestMigrationFile('order'))
migrate_to = ('order', helpers.getNewestMigrationFile('order'))
def prepare(self):
"""
Create initial data set
"""
# Create a purchase order from a supplier
Company = self.old_state.apps.get_model('company', 'company')
supplier = Company.objects.create(
name='Supplier A',
description='A great supplier!',
is_supplier=True
)
PurchaseOrder = self.old_state.apps.get_model('order', 'purchaseorder')
# Create some orders
for ii in range(10):
order = PurchaseOrder.objects.create(
supplier=supplier,
reference=f"{ii}-abcde",
description="Just a test order"
)
# Initially, the 'reference_int' field is unavailable
with self.assertRaises(AttributeError):
print(order.reference_int)
def test_ref_field(self):
"""
Test that the 'reference_int' field has been created and is filled out correctly
"""
PurchaseOrder = self.new_state.apps.get_model('order', 'purchaseorder')
for ii in range(10):
order = PurchaseOrder.objects.get(reference=f"{ii}-abcde")
# The integer reference field must have been correctly updated
self.assertEqual(order.reference_int, ii)

View File

@ -27,7 +27,8 @@ from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem
from .models import Part, PartCategory
from .models import BomItem, BomItemSubstitute
from .models import PartParameter, PartParameterTemplate
from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
@ -813,6 +814,27 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
# Exclude specific part ID values?
exclude_id = []
for key in ['exclude_id', 'exclude_id[]']:
if key in params:
exclude_id += params.getlist(key, [])
if exclude_id:
id_values = []
for val in exclude_id:
try:
# pk values must be integer castable
val = int(val)
id_values.append(val)
except ValueError:
pass
queryset = queryset.exclude(pk__in=id_values)
# Exclude part variant tree?
exclude_tree = params.get('exclude_tree', None)
@ -1078,11 +1100,23 @@ class BomList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
if request.is_ajax():
"""
Determine the response type based on the request.
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
@ -1102,7 +1136,7 @@ class BomList(generics.ListCreateAPIView):
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = str2bool(self.request.GET.get('include_pricing', True))
kwargs['include_pricing'] = self.include_pricing()
except AttributeError:
pass
@ -1147,13 +1181,19 @@ class BomList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist):
pass
include_pricing = str2bool(params.get('include_pricing', True))
if include_pricing:
if self.include_pricing():
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""
Determine if pricing information should be included in the response
"""
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
def annotate_pricing(self, queryset):
"""
Add part pricing information to the queryset
@ -1262,6 +1302,35 @@ class BomItemValidate(generics.UpdateAPIView):
return Response(serializer.data)
class BomItemSubstituteList(generics.ListCreateAPIView):
"""
API endpoint for accessing a list of BomItemSubstitute objects
"""
serializer_class = part_serializers.BomItemSubstituteSerializer
queryset = BomItemSubstitute.objects.all()
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'part',
'bom_item',
]
class BomItemSubstituteDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a single BomItemSubstitute object
"""
queryset = BomItemSubstitute.objects.all()
serializer_class = part_serializers.BomItemSubstituteSerializer
part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
@ -1314,6 +1383,16 @@ part_api_urls = [
]
bom_api_urls = [
url(r'^substitute/', include([
# Detail view
url(r'^(?P<pk>\d+)/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'),
# Catch all
url(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),
])),
# BOM Item Detail
url(r'^(?P<pk>\d+)/', include([
url(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.5 on 2021-10-12 23:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0071_alter_partparametertemplate_name'),
]
operations = [
migrations.CreateModel(
name='BomItemSubstitute',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bom_item', models.ForeignKey(help_text='Parent BOM item', on_delete=django.db.models.deletion.CASCADE, related_name='substitutes', to='part.bomitem', verbose_name='BOM Item')),
('part', models.ForeignKey(help_text='Substitute part', limit_choices_to={'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='substitute_items', to='part.part', verbose_name='Part')),
],
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.2.5 on 2021-10-13 10:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('part', '0072_bomitemsubstitute'),
]
operations = [
migrations.AlterModelOptions(
name='bomitemsubstitute',
options={'verbose_name': 'BOM Item Substitute'},
),
migrations.AlterUniqueTogether(
name='bomitemsubstitute',
unique_together={('part', 'bom_item')},
),
]

View File

@ -23,6 +23,8 @@ from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from jinja2 import Template
from markdownx.models import MarkdownxField
from django_cleanup import cleanup
@ -38,6 +40,7 @@ from datetime import datetime
import hashlib
from djmoney.contrib.exchange.models import convert_money
from common.settings import currency_code_default
from common.models import InvenTreeSetting
from InvenTree import helpers
from InvenTree import validators
@ -555,7 +558,9 @@ class Part(MPTTModel):
@property
def full_name(self):
""" Format a 'full name' for this Part.
""" Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in Inventree settings
As a failsafe option, the following is done
- IPN (if not null)
- Part name
@ -564,17 +569,31 @@ class Part(MPTTModel):
Elements are joined by the | character
"""
elements = []
full_name_pattern = InvenTreeSetting.get_setting('PART_NAME_FORMAT')
if self.IPN:
elements.append(self.IPN)
try:
context = {'part': self}
template_string = Template(full_name_pattern)
full_name = template_string.render(context)
elements.append(self.name)
return full_name
if self.revision:
elements.append(self.revision)
except AttributeError as attr_err:
return ' | '.join(elements)
logger.warning(f"exception while trying to create full name for part {self.name}", attr_err)
# Fallback to default format
elements = []
if self.IPN:
elements.append(self.IPN)
elements.append(self.name)
if self.revision:
elements.append(self.revision)
return ' | '.join(elements)
def set_category(self, category):
@ -2333,22 +2352,48 @@ class BomItem(models.Model):
def get_api_url():
return reverse('api-bom-list')
def get_valid_parts_for_allocation(self):
"""
Return a list of valid parts which can be allocated against this BomItem:
- Include the referenced sub_part
- Include any directly specvified substitute parts
- If allow_variants is True, allow all variants of sub_part
"""
# Set of parts we will allow
parts = set()
parts.add(self.sub_part)
# Variant parts (if allowed)
if self.allow_variants:
for variant in self.sub_part.get_descendants(include_self=False):
parts.add(variant)
# Substitute parts
for sub in self.substitutes.all():
parts.add(sub.part)
return parts
def is_stock_item_valid(self, stock_item):
"""
Check if the provided StockItem object is "valid" for assignment against this BomItem
"""
return stock_item.part in self.get_valid_parts_for_allocation()
def get_stock_filter(self):
"""
Return a queryset filter for selecting StockItems which match this BomItem
- Allow stock from all directly specified substitute parts
- If allow_variants is True, allow all part variants
"""
# Target part
part = self.sub_part
if self.allow_variants:
variants = part.get_descendants(include_self=True)
return Q(part__in=[v.pk for v in variants])
else:
return Q(part=part)
return Q(part__in=[part.pk for part in self.get_valid_parts_for_allocation()])
def save(self, *args, **kwargs):
@ -2613,6 +2658,66 @@ class BomItem(models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
class BomItemSubstitute(models.Model):
"""
A BomItemSubstitute provides a specification for alternative parts,
which can be used in a bill of materials.
Attributes:
bom_item: Link to the parent BomItem instance
part: The part which can be used as a substitute
"""
class Meta:
verbose_name = _("BOM Item Substitute")
# Prevent duplication of substitute parts
unique_together = ('part', 'bom_item')
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def validate_unique(self, exclude=None):
"""
Ensure that this BomItemSubstitute is "unique":
- It cannot point to the same "part" as the "sub_part" of the parent "bom_item"
"""
super().validate_unique(exclude=exclude)
if self.part == self.bom_item.sub_part:
raise ValidationError({
"part": _("Substitute part cannot be the same as the master part"),
})
@staticmethod
def get_api_url():
return reverse('api-bom-substitute-list')
bom_item = models.ForeignKey(
BomItem,
on_delete=models.CASCADE,
related_name='substitutes',
verbose_name=_('BOM Item'),
help_text=_('Parent BOM item'),
)
part = models.ForeignKey(
Part,
on_delete=models.CASCADE,
related_name='substitute_items',
verbose_name=_('Part'),
help_text=_('Substitute part'),
limit_choices_to={
'component': True,
}
)
class PartRelated(models.Model):
""" Store and handle related parts (eg. mating connector, crimps, etc.) """

View File

@ -23,7 +23,8 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory,
from .models import (BomItem, BomItemSubstitute,
Part, PartAttachment, PartCategory,
PartParameter, PartParameterTemplate, PartSellPriceBreak,
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
PartInternalPriceBreak)
@ -388,8 +389,27 @@ class PartStarSerializer(InvenTreeModelSerializer):
]
class BomItemSubstituteSerializer(InvenTreeModelSerializer):
"""
Serializer for the BomItemSubstitute class
"""
part_detail = PartBriefSerializer(source='part', read_only=True, many=False)
class Meta:
model = BomItemSubstitute
fields = [
'pk',
'bom_item',
'part',
'part_detail',
]
class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """
"""
Serializer for BomItem object
"""
price_range = serializers.CharField(read_only=True)
@ -397,6 +417,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
substitutes = BomItemSubstituteSerializer(many=True, read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
@ -515,6 +537,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'reference',
'sub_part',
'sub_part_detail',
'substitutes',
'price_range',
'validated',
]

View File

@ -1,11 +1,12 @@
{% load i18n %}
{% load inventree_extras %}
{% if roles.part.change != True and editing_enabled %}
{% if not roles.part.change %}
<div class='alert alert-danger alert-block'>
{% trans "You do not have permission to edit the BOM." %}
</div>
{% else %}
{% endif %}
{% if part.bom_checked_date %}
{% if part.is_bom_valid %}
<div class='alert alert-block alert-info'>
@ -23,42 +24,38 @@
<div id='bom-button-toolbar'>
<div class="btn-group" role="group" aria-label="...">
{% if editing_enabled %}
<button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
<span class='fas fa-trash-alt icon-red'></span>
</button>
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
<span class='fas fa-file-upload'></span>
</button>
{% if part.variant_of %}
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
<span class='fas fa-clone'></span>
</button>
{% endif %}
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span>
</button>
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
<span class='fas fa-check-circle'></span>
</button>
{% elif part.active %}
<!-- Export menu -->
<div class='btn-group'>
<button id='export-options' title='{% trans "Export actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-download'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='download-bom'><span class='fas fa-file-download'></span> {% trans "Export BOM" %}</a></li>
<li><a href='#' id='print-bom-report'><span class='fas fa-file-pdf'></span> {% trans "Print BOM Report" %}</a></li>
</ul>
</div>
{% if roles.part.change %}
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
<span class='fas fa-edit'></span>
</button>
{% if part.is_bom_valid == False %}
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
<span class='fas fa-clipboard-check'></span>
<!-- Action menu -->
<div class='btn-group'>
<button id='bom-actions' title='{% trans "BOM actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-wrench'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='bom-upload'><span class='fas fa-file-upload'></span> {% trans "Upload BOM" %}</a></li>
{% if part.variant_of %}
<li><a href='#' id='bom-duplicate'><span class='fas fa-clone'></span> {% trans "Copy BOM" %}</a></li>
{% endif %}
{% if not part.is_bom_valid %}
<li><a href='#' id='validate-bom'><span class='fas fa-clipboard-check icon-green'></span> {% trans "Validate BOM" %}</a></li>
{% endif %}
<li><a href='#' id='bom-item-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete Items" %}</a></li>
</ul>
</div>
<button class='btn btn-success' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
<span class='fas fa-plus-circle'></span> {% trans "Add BOM Item" %}
</button>
{% endif %}
{% endif %}
{% endif %}
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
<span class='fas fa-file-download'></span>
</button>
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
<span class='fas fa-file-pdf'></span>
</button>
<div class='filter-list' id='filter-list-bom'>
<!-- Empty div (will be filled out with avilable BOM filters) -->
</div>
@ -67,4 +64,3 @@
<table class='table table-bom table-condensed' data-toolbar="#bom-button-toolbar" id='bom-table'>
</table>
{% endif %}

View File

@ -473,7 +473,11 @@
onPanelLoad("bom", function() {
// Load the BOM table data
loadBomTable($("#bom-table"), {
editable: {{ editing_enabled }},
{% if roles.part.change %}
editable: true,
{% else %}
editable: false,
{% endif %}
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,
@ -486,11 +490,6 @@
]
);
{% if editing_enabled %}
$("#editing-finished").click(function() {
location.href = "{% url 'part-detail' part.id %}?display=bom";
});
$('#bom-item-delete').click(function() {
// Get a list of the selected BOM items
@ -559,8 +558,6 @@
});
});
{% else %}
$("#validate-bom").click(function() {
launchModalForm(
"{% url 'bom-validate' part.id %}",
@ -570,10 +567,6 @@
);
});
$("#edit-bom").click(function () {
location.href = "{% url 'part-detail' part.id %}?display=bom&edit=1";
});
$("#download-bom").click(function () {
launchModalForm("{% url 'bom-export' part.id %}",
{
@ -584,8 +577,6 @@
);
});
{% endif %}
$("#print-bom-report").click(function() {
printBomReports([{{ part.pk }}]);
});
@ -629,10 +620,9 @@
});
});
// Load the BOM table data in the pricing view
loadBomTable($("#bom-pricing-table"), {
editable: {{ editing_enabled }},
editable: false,
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,

View File

@ -4,12 +4,6 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% settings_value 'PART_SHOW_RELATED' as show_related %}
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
<ul class='list-group'>
<li class='list-group-item'>
@ -31,14 +25,12 @@
</a>
</li>
{% endif %}
{% if enable_stock %}
<li class='list-group-item' title='{% trans "Stock Items" %}'>
<a href='#' id='select-part-stock' class='nav-toggle'>
<span class='menu-tab-icon fas fa-boxes sidebar-icon'></span>
{% trans "Stock" %}
</a>
</li>
{% endif %}
{% if part.assembly %}
<li class='list-group-item' title='{% trans "Bill of Materials" %}'>
<a href='#' id='select-bom' class='nav-toggle'>
@ -46,7 +38,7 @@
{% trans "Bill of Materials" %}
</a>
</li>
{% if roles.build.view and enable_build %}
{% if roles.build.view %}
<li class='list-group-item ' title='{% trans "Build Orders" %}'>
<a href='#' id='select-build-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-tools sidebar-icon'></span>
@ -63,22 +55,19 @@
</a>
</li>
{% endif %}
{% if enable_buy or enable_sell %}
<li class='list-group-item' title='{% trans "Pricing Information" %}'>
<a href='#' id='select-pricing' class='nav-toggle'>
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
{% trans "Prices" %}
</a>
</li>
{% endif %}
{% if part.purchaseable and roles.purchase_order.view and enable_buy %}
{% if part.purchaseable and roles.purchase_order.view %}
<li class='list-group-item' title='{% trans "Suppliers" %}'>
<a href='#' id='select-suppliers' class='nav-toggle'>
<span class='menu-tab-icon fas fa-building sidebar-icon'></span>
{% trans "Suppliers" %}
</a>
</li>
{% if enable_po %}
<li class='list-group-item' title='{% trans "Purchase Orders" %}'>
<a href='#' id='select-purchase-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-shopping-cart sidebar-icon'></span>
@ -86,8 +75,7 @@
</a>
</li>
{% endif %}
{% endif %}
{% if part.salable and roles.sales_order.view and enable_sell and enable_so %}
{% if part.salable and roles.sales_order.view %}
<li class='list-group-item' title='{% trans "Sales Orders" %}'>
<a href='#' id='select-sales-orders' class='nav-toggle'>
<span class='menu-tab-icon fas fa-truck sidebar-icon'></span>

View File

@ -10,11 +10,6 @@
{% block content %}
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
<div class="panel panel-default panel-inventree">
<!-- Default panel contents -->
<div class="panel-heading"><h3>{{ part.full_name }}</h3></div>
@ -85,12 +80,10 @@
</div>
{% endif %}
{% if part.active %}
{% if enable_buy or enable_sell %}
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
<span id='part-price-icon' class='fas fa-dollar-sign'/>
</button>
{% endif %}
{% if roles.stock.change and enable_stock %}
{% if roles.stock.change %}
<div class='btn-group'>
<button id='stock-actions' title='{% trans "Stock actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-boxes'></span> <span class='caret'></span>
@ -112,13 +105,11 @@
</div>
{% endif %}
{% if part.purchaseable and roles.purchase_order.add %}
{% if enable_buy and enable_po %}
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
<span id='part-order-icon' class='fas fa-shopping-cart'/>
</button>
{% endif %}
{% endif %}
{% endif %}
<!-- Part actions -->
{% if roles.part.add or roles.part.change or roles.part.delete %}
<div class='btn-group'>

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import PIL
@ -11,7 +12,8 @@ from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory
from stock.models import StockItem
from part.models import BomItem, BomItemSubstitute
from stock.models import StockItem, StockLocation
from company.models import Company
from common.models import InvenTreeSetting
@ -273,53 +275,6 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 3)
def test_get_bom_list(self):
""" There should be 4 BomItem objects in the database """
url = reverse('api-bom-list')
response = self.client.get(url, format='json')
self.assertEqual(len(response.data), 5)
def test_get_bom_detail(self):
# Get the detail for a single BomItem
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
data = response.data
data['quantity'] = 57
data['note'] = 'Added a note'
response = self.client.patch(url, data, format='json')
# Check that the quantity was increased and a note added
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note')
def test_add_bom_item(self):
url = reverse('api-bom-list')
data = {
'part': 100,
'sub_part': 4,
'quantity': 777,
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Now try to create a BomItem which points to a non-assembly part (should fail)
data['part'] = 3
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# TODO - Now try to create a BomItem which references itself
data['part'] = 2
data['sub_part'] = 2
response = self.client.post(url, data, format='json')
def test_test_templates(self):
url = reverse('api-part-test-template-list')
@ -926,6 +881,249 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
self.assertEqual(data['stock_item_count'], 105)
class BomItemTest(InvenTreeAPITestCase):
"""
Unit tests for the BomItem API
"""
fixtures = [
'category',
'part',
'location',
'stock',
'bom',
'company',
]
roles = [
'part.add',
'part.change',
'part.delete',
]
def setUp(self):
super().setUp()
def test_bom_list(self):
"""
Tests for the BomItem list endpoint
"""
# How many BOM items currently exist in the database?
n = BomItem.objects.count()
url = reverse('api-bom-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), n)
# Now, filter by part
response = self.get(
url,
data={
'part': 100,
},
expected_code=200
)
print("results:", len(response.data))
def test_get_bom_detail(self):
"""
Get the detail view for a single BomItem object
"""
url = reverse('api-bom-item-detail', kwargs={'pk': 3})
response = self.get(url, expected_code=200)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
data = response.data
data['quantity'] = 57
data['note'] = 'Added a note'
response = self.patch(url, data, expected_code=200)
self.assertEqual(int(float(response.data['quantity'])), 57)
self.assertEqual(response.data['note'], 'Added a note')
def test_add_bom_item(self):
"""
Test that we can create a new BomItem via the API
"""
url = reverse('api-bom-list')
data = {
'part': 100,
'sub_part': 4,
'quantity': 777,
}
self.post(url, data, expected_code=201)
# Now try to create a BomItem which references itself
data['part'] = 100
data['sub_part'] = 100
self.client.post(url, data, expected_code=400)
def test_variants(self):
"""
Tests for BomItem use with variants
"""
stock_url = reverse('api-stock-list')
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
bom_item.allow_variants = True
bom_item.save()
# sub part that the BOM item points to
sub_part = bom_item.sub_part
sub_part.is_template = True
sub_part.save()
# How many stock items are initially available for this part?
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
n_items = len(response.data)
self.assertEqual(n_items, 2)
loc = StockLocation.objects.get(pk=1)
# Now we will create some variant parts and stock
for ii in range(5):
# Create a variant part!
variant = Part.objects.create(
name=f"Variant_{ii}",
description="A variant part",
component=True,
variant_of=sub_part
)
variant.save()
Part.objects.rebuild()
# Create some stock items for this new part
for jj in range(ii):
StockItem.objects.create(
part=variant,
location=loc,
quantity=100
)
# Keep track of running total
n_items += ii
# Now, there should be more stock items available!
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), n_items)
# Now, disallow variant parts in the BomItem
bom_item.allow_variants = False
bom_item.save()
# There should now only be 2 stock items available again
response = self.get(
stock_url,
{
'bom_item': bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), 2)
def test_substitutes(self):
"""
Tests for BomItem substitutes
"""
url = reverse('api-bom-substitute-list')
stock_url = reverse('api-stock-list')
# Initially we have no substitute parts
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 0)
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
# Filter stock items which can be assigned against this stock item
response = self.get(
stock_url,
{
"bom_item": bom_item.pk,
},
expected_code=200
)
n_items = len(response.data)
loc = StockLocation.objects.get(pk=1)
# Let's make some!
for ii in range(5):
sub_part = Part.objects.create(
name=f"Substitute {ii}",
description="A substitute part",
component=True,
is_template=False,
assembly=False
)
# Create a new StockItem for this Part
StockItem.objects.create(
part=sub_part,
quantity=1000,
location=loc,
)
# Now, create an "alternative" for the BOM Item
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# We should be able to filter the API list to just return this new part
response = self.get(url, data={'part': sub_part.pk}, expected_code=200)
self.assertEqual(len(response.data), 1)
# We should also have more stock available to allocate against this BOM item!
response = self.get(
stock_url,
{
"bom_item": bom_item.pk,
},
expected_code=200
)
self.assertEqual(len(response.data), n_items + ii + 1)
# There should now be 5 substitute parts available in the database
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
class PartParameterTest(InvenTreeAPITestCase):
"""
Tests for the ParParameter API

View File

@ -1,8 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import transaction
from django.test import TestCase
import django.core.exceptions as django_exceptions
from decimal import Decimal
from .models import Part, BomItem
from .models import Part, BomItem, BomItemSubstitute
class BomItemTest(TestCase):
@ -130,3 +135,67 @@ class BomItemTest(TestCase):
self.bob.get_bom_price_range(1, internal=True),
(Decimal(27.5), Decimal(87.5))
)
def test_substitutes(self):
"""
Tests for BOM item substitutes
"""
# We will make some subtitute parts for the "orphan" part
bom_item = BomItem.objects.get(
part=self.bob,
sub_part=self.orphan
)
# No substitute parts available
self.assertEqual(bom_item.substitutes.count(), 0)
subs = []
for ii in range(5):
# Create a new part
sub_part = Part.objects.create(
name=f"Orphan {ii}",
description="A substitute part for the orphan part",
component=True,
is_template=False,
assembly=False,
)
subs.append(sub_part)
# Link it as a substitute part
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# Try to link it again (this should fail as it is a duplicate substitute)
with self.assertRaises(django_exceptions.ValidationError):
with transaction.atomic():
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=sub_part
)
# There should be now 5 substitute parts available
self.assertEqual(bom_item.substitutes.count(), 5)
# Try to create a substitute which points to the same sub-part (should fail)
with self.assertRaises(django_exceptions.ValidationError):
BomItemSubstitute.objects.create(
bom_item=bom_item,
part=self.orphan,
)
# Remove one substitute part
bom_item.substitutes.last().delete()
self.assertEqual(bom_item.substitutes.count(), 4)
for sub in subs:
sub.delete()
# The substitution links should have been automatically removed
self.assertEqual(bom_item.substitutes.count(), 0)

View File

@ -87,16 +87,6 @@ class PartDetailTest(PartViewTestCase):
self.assertEqual(response.context['part'].pk, pk)
self.assertEqual(response.context['category'], part.category)
self.assertFalse(response.context['editing_enabled'])
def test_editable(self):
pk = 1
response = self.client.get(reverse('part-detail', args=(pk,)), {'edit': True})
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context['editing_enabled'])
def test_part_detail_from_ipn(self):
"""
Test that we can retrieve a part detail page from part IPN:

View File

@ -404,20 +404,13 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
# Add in some extra context information based on query params
def get_context_data(self, **kwargs):
""" Provide extra context data to template
- If '?editing=True', set 'editing_enabled' context variable
"""
Provide extra context data to template
"""
context = super().get_context_data(**kwargs)
part = self.get_object()
if str2bool(self.request.GET.get('edit', '')):
# Allow BOM editing if the part is active
context['editing_enabled'] = 1 if part.active else 0
else:
context['editing_enabled'] = 0
ctx = part.get_context_data(self.request)
context.update(**ctx)

View File

@ -72,7 +72,9 @@
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group'>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
<span class='fas fa-qrcode'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if roles.stock.change %}

View File

@ -13,8 +13,6 @@
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="BUILD_FUNCTION_ENABLE" icon="fa-check" %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
{% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
</tbody>

View File

@ -14,7 +14,6 @@
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
@ -22,9 +21,11 @@
<td>{% trans 'Signup' %}</td>
<td colspan='4'></td>
</tr>
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
</tbody>
</table>

View File

@ -17,6 +17,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}

View File

@ -11,9 +11,6 @@
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="PO_FUNCTION_ENABLE" icon="fa-check" %}
{% include "InvenTree/settings/setting.html" with key="BUY_FUNCTION_ENABLE" icon="fa-check" %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
</tbody>
</table>

View File

@ -12,9 +12,6 @@
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="SO_FUNCTION_ENABLE" icon="fa-check" %}
{% include "InvenTree/settings/setting.html" with key="SELL_FUNCTION_ENABLE" icon="fa-check" %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
</tbody>
</table>

View File

@ -12,8 +12,6 @@
<table class='table table-striped table-condensed'>
{% include "InvenTree/settings/header.html" %}
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCK_FUNCTION_ENABLE" icon="fa-check" %}
<tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="STOCK_GROUP_BY_PART" icon="fa-layer-group" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" icon="fa-stopwatch" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" icon="fa-calendar" %}

View File

@ -39,12 +39,12 @@
</table>
<div class='panel-heading'>
<h4>{% trans "E-Mail" %}</h4>
<h4>{% trans "Email" %}</h4>
</div>
<div>
{% if user.emailaddress_set.all %}
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p>
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %}
@ -78,19 +78,19 @@
{% else %}
<p><strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
{% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %}
</p>
{% endif %}
{% if can_add_email %}
<br>
<h4>{% trans "Add E-mail Address" %}</h4>
<h4>{% trans "Add Email Address" %}</h4>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %}
{{ add_email_form|crispy }}
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add E-mail" %}</button>
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
</form>
{% endif %}
<br>
@ -220,7 +220,7 @@
{% block js_ready %}
(function() {
var message = "{% trans 'Do you really want to remove the selected e-mail address?' %}";
var message = "{% trans 'Do you really want to remove the selected email address?' %}";
var actions = document.getElementsByName('action_remove');
if (actions.length) {
actions[0].addEventListener("click", function(e) {

View File

@ -3,17 +3,17 @@
{% load i18n %}
{% load account %}
{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}
{% block head_title %}{% trans "Confirm Email Address" %}{% endblock %}
{% block content %}
<h1>{% trans "Confirm E-mail Address" %}</h1>
<h1>{% trans "Confirm Email Address" %}</h1>
{% if confirmation %}
{% user_display confirmation.email_address.user as user_display %}
<p>{% blocktrans with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an e-mail address for user {{ user_display }}.{% endblocktrans %}</p>
<p>{% blocktrans with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an email address for user {{ user_display }}.{% endblocktrans %}</p>
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
{% csrf_token %}
@ -24,7 +24,7 @@
{% url 'account_email' as email_url %}
<p>{% blocktrans %}This e-mail confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new e-mail confirmation request</a>.{% endblocktrans %}</p>
<p>{% blocktrans %}This email confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new email confirmation request</a>.{% endblocktrans %}</p>
{% endif %}

View File

@ -15,7 +15,7 @@
{% endif %}
{% if mail_conf and enable_pwd_forgot %}
<p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}</p>
<p>{% trans "Forgotten your password? Enter your email address below, and we'll send you an email allowing you to reset it." %}</p>
<form method="POST" action="{% url 'account_reset_password' %}" class="password_reset">
{% csrf_token %}

View File

@ -143,6 +143,174 @@ function newPartFromBomWizard(e) {
}
/*
* Launch a modal dialog displaying the "substitute parts" for a particular BomItem
*
* If editable, allows substitutes to be added and deleted
*/
function bomSubstitutesDialog(bom_item_id, substitutes, options={}) {
// Reload data for the parent table
function reloadParentTable() {
if (options.table) {
options.table.bootstrapTable('refresh');
}
}
// Extract a list of all existing "substitute" id values
function getSubstituteIdValues(modal) {
var id_values = [];
$(modal).find('.substitute-row').each(function(el) {
var part = $(this).attr('part');
id_values.push(part);
});
return id_values;
}
function renderSubstituteRow(substitute) {
var pk = substitute.pk;
var part = substitute.part_detail;
var thumb = thumbnailImage(part.thumbnail || part.image);
var buttons = '';
buttons += makeIconButton('fa-times icon-red', 'button-row-remove', pk, '{% trans "Remove substitute part" %}');
// Render a single row
var html = `
<tr id='substitute-row-${pk}' class='substitute-row' part='${substitute.part}'>
<td id='part-${pk}'>
<a href='/part/${part.pk}/'>
${thumb} ${part.full_name}
</a>
</td>
<td id='description-${pk}'><em>${part.description}</em></td>
<td>${buttons}</td>
</tr>
`;
return html;
}
// Construct a table to render the rows
var rows = '';
substitutes.forEach(function(sub) {
rows += renderSubstituteRow(sub);
});
var html = `
<table class='table table-striped table-condensed' id='substitute-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Description" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
html += `
<div class='alert alert-success alert-block'>
{% trans "Select and add a new variant item using the input below" %}
</div>
`;
// Add a callback to remove a row from the table
function addRemoveCallback(modal, element) {
$(modal).find(element).click(function() {
var pk = $(this).attr('pk');
var pre = `
<div class='alert alert-block alert-warning'>
{% trans "Are you sure you wish to remove this substitute part link?" %}
</div>
`;
constructForm(`/api/bom/substitute/${pk}/`, {
method: 'DELETE',
title: '{% trans "Remove Substitute Part" %}',
preFormContent: pre,
confirm: true,
onSuccess: function() {
$(modal).find(`#substitute-row-${pk}`).remove();
reloadParentTable();
}
});
});
}
constructForm('{% url "api-bom-substitute-list" %}', {
method: 'POST',
fields: {
bom_item: {
hidden: true,
value: bom_item_id,
},
part: {
required: false,
adjustFilters: function(query, opts) {
var subs = getSubstituteIdValues(opts.modal);
// Also exclude the "master" part (if provided)
if (options.sub_part) {
subs.push(options.sub_part);
}
if (subs.length > 0) {
query.exclude_id = subs;
}
return query;
}
},
},
preFormContent: html,
cancelText: '{% trans "Close" %}',
submitText: '{% trans "Add Substitute" %}',
title: '{% trans "Edit BOM Item Substitutes" %}',
afterRender: function(fields, opts) {
addRemoveCallback(opts.modal, '.button-row-remove');
},
preventClose: true,
onSuccess: function(response, opts) {
// Clear the form
var field = {
type: 'related field',
};
updateFieldValue('part', null, field, opts);
// Add the new substitute to the table
var row = renderSubstituteRow(response);
$(opts.modal).find('#substitute-table > tbody:last-child').append(row);
// Add a callback to the new button
addRemoveCallback(opts.modal, `#button-row-remove-${response.pk}`);
// Re-enable the "submit" button
$(opts.modal).find('#modal-form-submit').prop('disabled', false);
// Reload the parent BOM table
reloadParentTable();
}
});
}
function loadBomTable(table, options) {
/* Load a BOM table with some configurable options.
*
@ -229,6 +397,14 @@ function loadBomTable(table, options) {
html += makePartIcons(row.sub_part_detail);
if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitutes Available" %}');
}
if (row.allow_variants) {
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
}
// Display an extra icon if this part is an assembly
if (sub_part.assembly) {
var text = `<span title='{% trans "Open subassembly" %}' class='fas fa-stream label-right'></span>`;
@ -300,6 +476,20 @@ function loadBomTable(table, options) {
return renderLink(text, url);
}
});
cols.push({
field: 'substitutes',
title: '{% trans "Substitutes" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
if (row.substitutes && row.substitutes.length > 0) {
return row.substitutes.length;
} else {
return `-`;
}
}
});
if (show_pricing) {
cols.push({
@ -420,18 +610,17 @@ function loadBomTable(table, options) {
if (row.part == options.parent_id) {
var bValidate = `<button title='{% trans "Validate BOM Item" %}' class='bom-validate-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-check-circle icon-blue'/></button>`;
var bValidate = makeIconButton('fa-check-circle icon-green', 'bom-validate-button', row.pk, '{% trans "Validate BOM Item" %}');
var bValid = `<span title='{% trans "This line has been validated" %}' class='fas fa-check-double icon-green'/>`;
var bEdit = `<button title='{% trans "Edit BOM Item" %}' class='bom-edit-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-edit'></span></button>`;
var bSubs = makeIconButton('fa-exchange-alt icon-blue', 'bom-substitutes-button', row.pk, '{% trans "Edit substitute parts" %}');
var bDelt = `<button title='{% trans "Delete BOM Item" %}' class='bom-delete-button btn btn-default btn-glyph' type='button' pk='${row.pk}'><span class='fas fa-trash-alt icon-red'></span></button>`;
var bEdit = makeIconButton('fa-edit icon-blue', 'bom-edit-button', row.pk, '{% trans "Edit BOM Item" %}');
var html = `<div class='btn-group' role='group'>`;
var bDelt = makeIconButton('fa-trash-alt icon-red', 'bom-delete-button', row.pk, '{% trans "Delete BOM Item" %}');
html += bEdit;
html += bDelt;
var html = `<div class='btn-group float-right' role='group' style='min-width: 100px;'>`;
if (!row.validated) {
html += bValidate;
@ -439,6 +628,10 @@ function loadBomTable(table, options) {
html += bValid;
}
html += bEdit;
html += bSubs;
html += bDelt;
html += `</div>`;
return html;
@ -490,6 +683,7 @@ function loadBomTable(table, options) {
treeEnable: !options.editable,
rootParentId: parent_id,
idField: 'pk',
uniqueId: 'pk',
parentIdField: 'parentId',
treeShowField: 'sub_part',
showColumns: true,
@ -566,19 +760,27 @@ function loadBomTable(table, options) {
// In editing mode, attached editables to the appropriate table elements
if (options.editable) {
// Callback for "delete" button
table.on('click', '.bom-delete-button', function() {
var pk = $(this).attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Are you sure you want to delete this BOM item?" %}
</div>`;
constructForm(`/api/bom/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete BOM Item" %}',
preFormContent: html,
onSuccess: function() {
reloadBomTable(table);
}
});
});
// Callback for "edit" button
table.on('click', '.bom-edit-button', function() {
var pk = $(this).attr('pk');
@ -595,6 +797,7 @@ function loadBomTable(table, options) {
});
});
// Callback for "validate" button
table.on('click', '.bom-validate-button', function() {
var pk = $(this).attr('pk');
@ -613,5 +816,22 @@ function loadBomTable(table, options) {
}
);
});
// Callback for "substitutes" button
table.on('click', '.bom-substitutes-button', function() {
var pk = $(this).attr('pk');
var row = table.bootstrapTable('getRowByUniqueId', pk);
var subs = row.substitutes || [];
bomSubstitutesDialog(
pk,
subs,
{
table: table,
sub_part: row.sub_part,
}
);
});
}
}

View File

@ -208,15 +208,10 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
var pk = $(this).attr('pk');
launchModalForm(
`/build/${buildId}/unallocate/`,
{
success: reloadTable,
data: {
output: pk,
}
}
);
unallocateStock(buildId, {
output: pk,
table: table,
});
});
$(panel).find(`#button-output-delete-${outputId}`).click(function() {
@ -236,6 +231,49 @@ function makeBuildOutputActionButtons(output, buildInfo, lines) {
}
/*
* Unallocate stock against a particular build order
*
* Options:
* - output: pk value for a stock item "build output"
* - bom_item: pk value for a particular BOMItem (build item)
*/
function unallocateStock(build_id, options={}) {
var url = `/api/build/${build_id}/unallocate/`;
var html = `
<div class='alert alert-block alert-warning'>
{% trans "Are you sure you wish to unallocate stock items from this build?" %}
</dvi>
`;
constructForm(url, {
method: 'POST',
confirm: true,
preFormContent: html,
fields: {
output: {
hidden: true,
value: options.output,
},
bom_item: {
hidden: true,
value: options.bom_item,
},
},
title: '{% trans "Unallocate Stock Items" %}',
onSuccess: function(response, opts) {
if (options.table) {
// Reload the parent table
$(options.table).bootstrapTable('refresh');
}
}
});
}
function loadBuildOrderAllocationTable(table, options={}) {
/**
* Load a table showing all the BuildOrder allocations for a given part
@ -348,6 +386,17 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
table = `#allocation-table-${outputId}`;
}
// Filters
var filters = loadTableFilters('builditems');
var params = options.params || {};
for (var key in params) {
filters[key] = params[key];
}
setupFilterList('builditems', $(table), options.filterTarget || null);
// If an "output" is specified, then only "trackable" parts are allocated
// Otherwise, only "untrackable" parts are allowed
var trackable = ! !output;
@ -458,17 +507,16 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
// Callback for 'unallocate' button
$(table).find('.button-unallocate').click(function() {
var pk = $(this).attr('pk');
launchModalForm(`/build/${buildId}/unallocate/`,
{
success: reloadTable,
data: {
output: outputId,
part: pk,
}
}
);
// Extract row data from the table
var idx = $(this).closest('tr').attr('data-index');
var row = $(table).bootstrapTable('getData')[idx];
unallocateStock(buildId, {
bom_item: row.pk,
output: outputId == 'untracked' ? null : outputId,
table: table,
});
});
}
@ -726,6 +774,14 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
html += makePartIcons(row.sub_part_detail);
if (row.substitutes && row.substitutes.length > 0) {
html += makeIconBadge('fa-exchange-alt', '{% trans "Substitute parts available" %}');
}
if (row.allow_variants) {
html += makeIconBadge('fa-sitemap', '{% trans "Variant stock allowed" %}');
}
return html;
}
},
@ -1021,12 +1077,12 @@ function allocateStockToBuild(build_id, part_id, bom_items, options={}) {
filters: {
bom_item: bom_item.pk,
in_stock: true,
part_detail: false,
part_detail: true,
location_detail: true,
},
model: 'stockitem',
required: true,
render_part_detail: false,
render_part_detail: true,
render_location_detail: true,
auto_fill: true,
adjustFilters: function(filters) {

View File

@ -283,10 +283,16 @@ function setupFilterList(tableKey, table, target) {
element.append(`<button id='reload-${tableKey}' title='{% trans "Reload data" %}' class='btn btn-default filter-tag'><span class='fas fa-redo-alt'></span></button>`);
// If there are no filters defined for this table, exit now
if (jQuery.isEmptyObject(getAvailableTableFilters(tableKey))) {
return;
}
// If there are filters currently "in use", add them in!
element.append(`<button id='${add}' title='{% trans "Add new filter" %}' class='btn btn-default filter-tag'><span class='fas fa-filter'></span></button>`);
if (Object.keys(filters).length > 0) {
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-trash-alt'></span></button>`);
element.append(`<button id='${clear}' title='{% trans "Clear all filters" %}' class='btn btn-default filter-tag'><span class='fas fa-backspace icon-red'></span></button>`);
}
for (var key in filters) {

View File

@ -1349,7 +1349,7 @@ function initializeRelatedField(field, fields, options) {
// Allow custom run-time filter augmentation
if ('adjustFilters' in field) {
query = field.adjustFilters(query);
query = field.adjustFilters(query, options);
}
return query;

View File

@ -52,8 +52,6 @@ function renderStockItem(name, data, parameters, options) {
if (data.part_detail) {
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
}
var html = '';
var render_part_detail = true;
@ -61,23 +59,10 @@ function renderStockItem(name, data, parameters, options) {
render_part_detail = parameters['render_part_detail'];
}
var part_detail = '';
if (render_part_detail) {
html += `<img src='${image}' class='select2-thumbnail'>`;
html += ` <span>${data.part_detail.full_name || data.part_detail.name}</span>`;
}
html += '<span>';
if (data.serial && data.quantity == 1) {
html += `{% trans "Serial Number" %}: ${data.serial}`;
} else {
html += `{% trans "Quantity" %}: ${data.quantity}`;
}
html += '</span>';
if (render_part_detail && data.part_detail.description) {
html += `<p><small>${data.part_detail.description}</small></p>`;
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
}
var render_stock_id = true;
@ -86,8 +71,10 @@ function renderStockItem(name, data, parameters, options) {
render_stock_id = parameters['render_stock_id'];
}
var stock_id = '';
if (render_stock_id) {
html += `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
stock_id = `<span class='float-right'><small>{% trans "Stock ID" %}: ${data.pk}</small></span>`;
}
var render_location_detail = false;
@ -96,10 +83,28 @@ function renderStockItem(name, data, parameters, options) {
render_location_detail = parameters['render_location_detail'];
}
var location_detail = '';
if (render_location_detail && data.location_detail) {
html += `<span> - ${data.location_detail.name}</span>`;
location_detail = ` - (<em>${data.location_detail.name}</em>)`;
}
var stock_detail = '';
if (data.serial && data.quantity == 1) {
stock_detail = `{% trans "Serial Number" %}: ${data.serial}`;
} else if (data.quantity == 0) {
stock_detail = `<span class='label-form label-red'>{% trans "No Stock"% }</span>`;
} else {
stock_detail = `{% trans "Quantity" %}: ${data.quantity}`;
}
var html = `
<span>
${part_detail}${stock_detail}${location_detail}${stock_id}
</span>
`;
return html;
}
@ -159,21 +164,25 @@ function renderPart(name, data, parameters, options) {
html += ` - <i>${data.description}</i>`;
}
var stock = '';
var extra = '';
// Display available part quantity
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
if (data.in_stock == 0) {
stock = `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
extra += `<span class='label-form label-red'>{% trans "No Stock" %}</span>`;
} else {
stock = `<span class='label-form label-green'>{% trans "In Stock" %}: ${data.in_stock}</span>`;
extra += `<span class='label-form label-green'>{% trans "Stock" %}: ${data.in_stock}</span>`;
}
}
if (!data.active) {
extra += `<span class='label-form label-red'>{% trans "Inactive" %}</span>`;
}
html += `
<span class='float-right'>
<small>
${stock}
${extra}
{% trans "Part ID" %}: ${data.pk}
</small>
</span>`;

View File

@ -592,7 +592,7 @@ function loadPartParameterTable(table, url, options) {
filters[key] = params[key];
}
// setupFilterLsit("#part-parameters", $(table));
// setupFilterList("#part-parameters", $(table));
$(table).inventreeTable({
url: url,
@ -876,23 +876,7 @@ function loadPartTable(table, url, options={}) {
switchable: false,
formatter: function(value, row) {
var name = '';
if (row.IPN) {
name += row.IPN;
name += ' | ';
}
name += value;
if (row.revision) {
name += ' | ';
name += row.revision;
}
if (row.is_template) {
name = '<i>' + name + '</i>';
}
var name = row.full_name;
var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');

View File

@ -4,12 +4,6 @@
{% load i18n %}
{% settings_value 'BARCODE_ENABLE' as barcodes %}
{% settings_value 'BUILD_FUNCTION_ENABLE' as enable_build %}
{% settings_value 'BUY_FUNCTION_ENABLE' as enable_buy %}
{% settings_value 'SELL_FUNCTION_ENABLE' as enable_sell %}
{% settings_value 'STOCK_FUNCTION_ENABLE' as enable_stock %}
{% settings_value 'SO_FUNCTION_ENABLE' as enable_so %}
{% settings_value 'PO_FUNCTION_ENABLE' as enable_po %}
<nav class="navbar navbar-xs navbar-default navbar-fixed-top ">
<div class="container-fluid">
@ -29,32 +23,28 @@
{% if roles.part.view %}
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
{% endif %}
{% if roles.stock.view and enable_stock %}
{% if roles.stock.view %}
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
{% endif %}
{% if roles.build.view and enable_build %}
{% if roles.build.view %}
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
{% endif %}
{% if roles.purchase_order.view and enable_buy %}
{% if roles.purchase_order.view %}
<li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
<ul class='dropdown-menu'>
<li><a href="{% url 'supplier-index' %}"><span class='fas fa-building icon-header'></span>{% trans "Suppliers" %}</a></li>
<li><a href="{% url 'manufacturer-index' %}"><span class='fas fa-industry icon-header'></span>{% trans "Manufacturers" %}</a></li>
{% if enable_po %}
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
{% endif %}
</ul>
</li>
{% endif %}
{% if roles.sales_order.view and enable_sell %}
{% if roles.sales_order.view %}
<li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
<ul class='dropdown-menu'>
<li><a href="{% url 'customer-index' %}"><span class='fas fa-user-tie icon-header'></span>{% trans "Customers" %}</a>
{% if enable_so %}
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
{% endif %}
</ul>
</li>
{% endif %}

View File

@ -81,6 +81,7 @@ class RuleSet(models.Model):
'part': [
'part_part',
'part_bomitem',
'part_bomitemsubstitute',
'part_partattachment',
'part_partsellpricebreak',
'part_partinternalpricebreak',
@ -110,6 +111,7 @@ class RuleSet(models.Model):
'part_part',
'part_partcategory',
'part_bomitem',
'part_bomitemsubstitute',
'build_build',
'build_builditem',
'build_buildorderattachment',

View File

@ -20,6 +20,7 @@ However, powerful business logic works in the background to ensure that stock tr
# Docker
[![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree)
![Docker Build](https://github.com/inventree/inventree/actions/workflows/docker_latest.yaml/badge.svg)
InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details.

View File

@ -1,40 +1,39 @@
# Django framework
# Please keep this list sorted
Django==3.2.5 # Django package
gunicorn>=20.1.0 # Gunicorn web server
pillow==8.3.2 # Image manipulation
djangorestframework==3.12.4 # DRF framework
django-cors-headers==3.2.0 # CORS headers extension for DRF
django-filter==2.4.0 # Extended filtering options
django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-markdownx==3.0.1 # Markdown form fields
django-markdownify==0.8.0 # Markdown rendering
certifi # Certifi is (most likely) installed through one of the requirements above
coreapi==2.3.0 # API documentation
pygments==2.7.4 # Syntax highlighting
django-crispy-forms==1.11.2 # Form helpers
django-import-export==2.5.0 # Data import / export for admin interface
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
flake8==3.8.3 # PEP checking
pep8-naming==0.11.1 # PEP naming convention extension
coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
django-weasyprint==1.0.1 # django weasyprint integration
django-debug-toolbar==2.2 # Debug / profiling toolbar
cryptography==3.4.8 # Cryptography support
django-admin-shell==0.1.2 # Python shell for the admin interface
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
django-money==1.1 # Django app for currency management
certifi # Certifi is (most likely) installed through one of the requirements above
django-allauth==0.45.0 # SSO for external providers via OpenID
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers
django-debug-toolbar==2.2 # Debug / profiling toolbar
django-error-report==0.2.0 # Error report viewer for the admin interface
django-test-migrations==1.1.0 # Unit testing for database migrations
django-filter==2.4.0 # Extended filtering options
django-formtools==2.3 # Form wizard tools
django-import-export==2.5.0 # Data import / export for admin interface
django-markdownify==0.8.0 # Markdown rendering
django-markdownx==3.0.1 # Markdown form fields
django-money==1.1 # Django app for currency management
django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-q==1.3.4 # Background task scheduling
django-sql-utils==0.5.0 # Advanced query annotation / aggregation
django-stdimage==5.1.1 # Advanced ImageField management
django-test-migrations==1.1.0 # Unit testing for database migrations
django-weasyprint==1.0.1 # django weasyprint integration
djangorestframework==3.12.4 # DRF framework
flake8==3.8.3 # PEP checking
gunicorn>=20.1.0 # Gunicorn web server
inventree # Install the latest version of the InvenTree API python library
pep8-naming==0.11.1 # PEP naming convention extension
pillow==8.3.2 # Image manipulation
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
pygments==2.7.4 # Syntax highlighting
python-barcode[images]==0.13.1 # Barcode generator
qrcode[pil]==6.1 # QR code generator
django-q==1.3.4 # Background task scheduling
django-formtools==2.3 # Form wizard tools
django-allauth==0.45.0 # SSO for external providers via OpenID
inventree # Install the latest version of the InvenTree API python library
rapidfuzz==0.7.6 # Fuzzy string matching
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)