mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'master' of github.com:inventree/InvenTree into multi_part_forms
This commit is contained in:
commit
10eb69caf9
2
.github/workflows/docker_build.yaml
vendored
2
.github/workflows/docker_build.yaml
vendored
@ -8,7 +8,7 @@ on:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
2
.github/workflows/mysql.yaml
vendored
2
.github/workflows/mysql.yaml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
ports:
|
||||
- 3306:3306
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
2
.github/workflows/postgresql.yaml
vendored
2
.github/workflows/postgresql.yaml
vendored
@ -5,7 +5,7 @@ name: PostgreSQL
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
@ -83,7 +83,7 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
self.assertEqual(response.status_code, code)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def post(self, url, data):
|
||||
"""
|
||||
Issue a POST request
|
||||
|
@ -71,7 +71,7 @@ def status_codes(request):
|
||||
def user_roles(request):
|
||||
"""
|
||||
Return a map of the current roles assigned to the user.
|
||||
|
||||
|
||||
Roles are denoted by their simple names, and then the permission type.
|
||||
|
||||
Permissions can be access as follows:
|
||||
|
@ -17,5 +17,5 @@ class InvenTreeManualExchangeBackend(BaseExchangeBackend):
|
||||
"""
|
||||
Do not get any rates...
|
||||
"""
|
||||
|
||||
|
||||
return {}
|
||||
|
@ -102,5 +102,5 @@ class RoundingDecimalField(models.DecimalField):
|
||||
}
|
||||
|
||||
defaults.update(kwargs)
|
||||
|
||||
|
||||
return super().formfield(**kwargs)
|
||||
|
@ -35,7 +35,7 @@ def generateTestKey(test_name):
|
||||
"""
|
||||
Generate a test 'key' for a given test name.
|
||||
This must not have illegal chars as it will be used for dict lookup in a template.
|
||||
|
||||
|
||||
Tests must be named such that they will have unique keys.
|
||||
"""
|
||||
|
||||
@ -102,7 +102,7 @@ def TestIfImageURL(url):
|
||||
'.tif', '.tiff',
|
||||
'.webp', '.gif',
|
||||
]
|
||||
|
||||
|
||||
|
||||
def str2bool(text, test=True):
|
||||
""" Test if a string 'looks' like a boolean value.
|
||||
@ -137,10 +137,10 @@ def isNull(text):
|
||||
"""
|
||||
Test if a string 'looks' like a null value.
|
||||
This is useful for querying the API against a null key.
|
||||
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
|
||||
Returns:
|
||||
True if the text looks like a null value
|
||||
"""
|
||||
@ -157,7 +157,7 @@ def normalize(d):
|
||||
d = Decimal(d)
|
||||
|
||||
d = d.normalize()
|
||||
|
||||
|
||||
# Ref: https://docs.python.org/3/library/decimal.html
|
||||
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
|
||||
|
||||
@ -165,14 +165,14 @@ def normalize(d):
|
||||
def increment(n):
|
||||
"""
|
||||
Attempt to increment an integer (or a string that looks like an integer!)
|
||||
|
||||
|
||||
e.g.
|
||||
|
||||
001 -> 002
|
||||
2 -> 3
|
||||
AB01 -> AB02
|
||||
QQQ -> QQQ
|
||||
|
||||
|
||||
"""
|
||||
|
||||
value = str(n).strip()
|
||||
@ -314,7 +314,7 @@ def MakeBarcode(object_name, object_pk, object_data={}, **kwargs):
|
||||
|
||||
def GetExportFormats():
|
||||
""" Return a list of allowable file formats for exporting data """
|
||||
|
||||
|
||||
return [
|
||||
'csv',
|
||||
'tsv',
|
||||
@ -327,7 +327,7 @@ def GetExportFormats():
|
||||
|
||||
def DownloadFile(data, filename, content_type='application/text'):
|
||||
""" Create a dynamic file for the user to download.
|
||||
|
||||
|
||||
Args:
|
||||
data: Raw file data (string or bytes)
|
||||
filename: Filename for the file download
|
||||
@ -525,7 +525,7 @@ def addUserPermission(user, permission):
|
||||
"""
|
||||
Shortcut function for adding a certain permission to a user.
|
||||
"""
|
||||
|
||||
|
||||
perm = Permission.objects.get(codename=permission)
|
||||
user.user_permissions.add(perm)
|
||||
|
||||
@ -576,7 +576,7 @@ def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||
continue
|
||||
|
||||
num = int(f.split('_')[0])
|
||||
|
||||
|
||||
if oldest_file is None or num < oldest_num:
|
||||
oldest_num = num
|
||||
oldest_file = f
|
||||
@ -585,7 +585,7 @@ def getOldestMigrationFile(app, exclude_extension=True, ignore_initial=True):
|
||||
oldest_file = oldest_file.replace('.py', '')
|
||||
|
||||
return oldest_file
|
||||
|
||||
|
||||
|
||||
def getNewestMigrationFile(app, exclude_extension=True):
|
||||
"""
|
||||
|
@ -77,12 +77,20 @@ class AuthRequiredMiddleware(object):
|
||||
if request.path_info == reverse_lazy('logout'):
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
|
||||
login = reverse_lazy('login')
|
||||
path = request.path_info
|
||||
|
||||
if not request.path_info == login and not request.path_info.startswith('/api/'):
|
||||
# List of URL endpoints we *do not* want to redirect to
|
||||
urls = [
|
||||
reverse_lazy('login'),
|
||||
reverse_lazy('logout'),
|
||||
reverse_lazy('admin:login'),
|
||||
reverse_lazy('admin:logout'),
|
||||
]
|
||||
|
||||
if path not in urls and not path.startswith('/api/'):
|
||||
# Save the 'next' parameter to pass through to the login view
|
||||
|
||||
return redirect('%s?next=%s' % (login, request.path))
|
||||
return redirect('%s?next=%s' % (reverse_lazy('login'), request.path))
|
||||
|
||||
# Code to be executed for each request/response after
|
||||
# the view is called.
|
||||
|
@ -129,7 +129,7 @@ class InvenTreeTree(MPTTModel):
|
||||
|
||||
Here an 'item' is considered to be the 'leaf' at the end of each branch,
|
||||
and the exact nature here will depend on the class implementation.
|
||||
|
||||
|
||||
The default implementation returns zero
|
||||
"""
|
||||
return 0
|
||||
|
@ -17,7 +17,7 @@ class RolePermission(permissions.BasePermission):
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
|
||||
|
||||
Specify the required "role" using the role_required attribute.
|
||||
|
||||
e.g.
|
||||
|
@ -44,7 +44,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
In addition to running validators on the serializer fields,
|
||||
this class ensures that the underlying model is also validated.
|
||||
"""
|
||||
|
||||
|
||||
# Run any native validation checks first (may throw an ValidationError)
|
||||
data = super(serializers.ModelSerializer, self).validate(data)
|
||||
|
||||
|
@ -507,7 +507,7 @@
|
||||
padding-right: 6px;
|
||||
padding-top: 3px;
|
||||
padding-bottom: 2px;
|
||||
};
|
||||
}
|
||||
|
||||
.panel-heading .badge {
|
||||
float: right;
|
||||
@ -568,7 +568,7 @@
|
||||
}
|
||||
|
||||
.media {
|
||||
//padding-top: 15px;
|
||||
/* padding-top: 15px; */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@ -594,8 +594,8 @@
|
||||
width: 160px;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
//top: 0;
|
||||
//left: 0;
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
overflow-x: hidden;
|
||||
padding-top: 20px;
|
||||
padding-right: 25px;
|
||||
@ -826,7 +826,7 @@ input[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
z-index: 5000;
|
||||
pointer-events: none; // Prevent this div from blocking links underneath
|
||||
pointer-events: none; /* Prevent this div from blocking links underneath */
|
||||
}
|
||||
|
||||
.alert {
|
||||
@ -936,4 +936,15 @@ input[type="submit"] {
|
||||
|
||||
input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control {
|
||||
line-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.clip-btn {
|
||||
font-size: 10px;
|
||||
padding: 0px 6px;
|
||||
color: var(--label-grey);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.clip-btn:hover {
|
||||
background: var(--label-grey);
|
||||
}
|
||||
|
7
InvenTree/InvenTree/static/script/clipboard.min.js
vendored
Normal file
7
InvenTree/InvenTree/static/script/clipboard.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,3 +1,14 @@
|
||||
function attachClipboard(selector) {
|
||||
|
||||
new ClipboardJS(selector, {
|
||||
text: function(trigger) {
|
||||
var content = trigger.parentElement.parentElement.textContent;
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function inventreeDocReady() {
|
||||
/* Run this function when the HTML document is loaded.
|
||||
* This will be called for every page that extends "base.html"
|
||||
@ -48,6 +59,10 @@ function inventreeDocReady() {
|
||||
no_post: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize clipboard-buttons
|
||||
attachClipboard('.clip-btn');
|
||||
|
||||
}
|
||||
|
||||
function isFileTransfer(transfer) {
|
||||
|
@ -64,7 +64,7 @@ def is_email_configured():
|
||||
|
||||
if not settings.EMAIL_HOST_USER:
|
||||
configured = False
|
||||
|
||||
|
||||
# Display warning unless in test mode
|
||||
if not settings.TESTING:
|
||||
logger.warning("EMAIL_HOST_USER is not configured")
|
||||
|
@ -16,7 +16,7 @@ class StatusCode:
|
||||
# If the key cannot be found, pass it back
|
||||
if key not in cls.options.keys():
|
||||
return key
|
||||
|
||||
|
||||
value = cls.options.get(key, key)
|
||||
color = cls.colors.get(key, 'grey')
|
||||
|
||||
|
@ -119,7 +119,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
self.assertNotIn('add', roles[rule])
|
||||
self.assertNotIn('change', roles[rule])
|
||||
self.assertNotIn('delete', roles[rule])
|
||||
|
||||
|
||||
def test_with_superuser(self):
|
||||
"""
|
||||
Superuser should have *all* roles assigned
|
||||
|
@ -37,7 +37,7 @@ class ScheduledTaskTests(TestCase):
|
||||
# Attempt to schedule the same task again
|
||||
InvenTree.tasks.schedule_task(task, schedule_type=Schedule.MINUTES, minutes=5)
|
||||
self.assertEqual(self.get_tasks(task).count(), 1)
|
||||
|
||||
|
||||
# But the 'minutes' should have been updated
|
||||
t = Schedule.objects.get(func=task)
|
||||
self.assertEqual(t.minutes, 5)
|
||||
|
@ -97,7 +97,7 @@ class TestHelpers(TestCase):
|
||||
self.assertEqual(helpers.getMediaUrl('xx/yy.png'), '/media/xx/yy.png')
|
||||
|
||||
def testDecimal2String(self):
|
||||
|
||||
|
||||
self.assertEqual(helpers.decimal2string(Decimal('1.2345000')), '1.2345')
|
||||
self.assertEqual(helpers.decimal2string('test'), 'test')
|
||||
|
||||
@ -205,7 +205,7 @@ class TestMPTT(TestCase):
|
||||
child = StockLocation.objects.get(pk=5)
|
||||
|
||||
parent.parent = child
|
||||
|
||||
|
||||
with self.assertRaises(InvalidMove):
|
||||
parent.save()
|
||||
|
||||
@ -223,7 +223,7 @@ class TestMPTT(TestCase):
|
||||
drawer.save()
|
||||
|
||||
self.assertNotEqual(tree, drawer.tree_id)
|
||||
|
||||
|
||||
|
||||
class TestSerialNumberExtraction(TestCase):
|
||||
""" Tests for serial number extraction code """
|
||||
|
@ -81,7 +81,7 @@ settings_urls = [
|
||||
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
|
||||
url(r'^appearance/?', AppearanceSelectView.as_view(), name='settings-appearance'),
|
||||
url(r'^i18n/?', include('django.conf.urls.i18n')),
|
||||
|
||||
|
||||
url(r'^global/?', SettingsView.as_view(template_name='InvenTree/settings/global.html'), name='settings-global'),
|
||||
url(r'^report/?', SettingsView.as_view(template_name='InvenTree/settings/report.html'), name='settings-report'),
|
||||
url(r'^category/?', SettingCategorySelectView.as_view(), name='settings-category'),
|
||||
@ -137,7 +137,7 @@ urlpatterns = [
|
||||
|
||||
url(r'^login/?', auth_views.LoginView.as_view(), name='login'),
|
||||
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logged_out.html'), name='logout'),
|
||||
|
||||
|
||||
url(r'^settings/', include(settings_urls)),
|
||||
|
||||
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||
|
@ -130,7 +130,7 @@ def validate_overage(value):
|
||||
|
||||
if i < 0:
|
||||
raise ValidationError(_("Overage value must not be negative"))
|
||||
|
||||
|
||||
# Looks like an integer!
|
||||
return True
|
||||
except ValueError:
|
||||
|
@ -176,7 +176,7 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
||||
|
||||
if role not in RuleSet.RULESET_NAMES:
|
||||
raise ValueError(f"Role '{role}' is not a valid role")
|
||||
|
||||
|
||||
if permission not in RuleSet.RULESET_PERMISSIONS:
|
||||
raise ValueError(f"Permission '{permission}' is not a valid permission")
|
||||
|
||||
@ -223,7 +223,7 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
|
||||
Return the 'permission_class' required for the current View.
|
||||
|
||||
Must be one of:
|
||||
|
||||
|
||||
- view
|
||||
- change
|
||||
- add
|
||||
@ -389,7 +389,7 @@ class QRCodeView(AjaxView):
|
||||
"""
|
||||
|
||||
ajax_template_name = "qr_code.html"
|
||||
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.pk = self.kwargs['pk']
|
||||
@ -398,7 +398,7 @@ class QRCodeView(AjaxView):
|
||||
def get_qr_data(self):
|
||||
""" Returns the text object to render to a QR code.
|
||||
The actual rendering will be handled by the template """
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def get_context_data(self):
|
||||
@ -406,7 +406,7 @@ class QRCodeView(AjaxView):
|
||||
|
||||
Explicity passes the parameter 'qr_data'
|
||||
"""
|
||||
|
||||
|
||||
context = {}
|
||||
|
||||
qr = self.get_qr_data()
|
||||
@ -415,7 +415,7 @@ class QRCodeView(AjaxView):
|
||||
context['qr_data'] = qr
|
||||
else:
|
||||
context['error_msg'] = 'Error generating QR code'
|
||||
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -507,7 +507,7 @@ class AjaxUpdateView(AjaxMixin, UpdateView):
|
||||
"""
|
||||
|
||||
super(UpdateView, self).get(request, *args, **kwargs)
|
||||
|
||||
|
||||
return self.renderJsonResponse(request, self.get_form(), context=self.get_context_data())
|
||||
|
||||
def save(self, object, form, **kwargs):
|
||||
@ -673,7 +673,7 @@ class SetPasswordView(AjaxUpdateView):
|
||||
|
||||
p1 = request.POST.get('enter_password', '')
|
||||
p2 = request.POST.get('confirm_password', '')
|
||||
|
||||
|
||||
if valid:
|
||||
# Passwords must match
|
||||
|
||||
@ -712,7 +712,7 @@ class IndexView(TemplateView):
|
||||
# Generate a list of orderable parts which have stock below their minimum values
|
||||
# TODO - Is there a less expensive way to get these from the database
|
||||
# context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
|
||||
|
||||
|
||||
# Generate a list of assembly parts which have stock below their minimum values
|
||||
# TODO - Is there a less expensive way to get these from the database
|
||||
# context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
|
||||
@ -752,7 +752,7 @@ class DynamicJsView(TemplateView):
|
||||
|
||||
template_name = ""
|
||||
content_type = 'text/javascript'
|
||||
|
||||
|
||||
|
||||
class SettingsView(TemplateView):
|
||||
""" View for configuring User settings
|
||||
@ -830,7 +830,7 @@ class AppearanceSelectView(FormView):
|
||||
|
||||
if form.is_valid():
|
||||
theme_selected = form.cleaned_data['name']
|
||||
|
||||
|
||||
# Set color theme to form selection
|
||||
user_theme.name = theme_selected
|
||||
user_theme.save()
|
||||
@ -893,7 +893,7 @@ class DatabaseStatsView(AjaxView):
|
||||
# Part stats
|
||||
ctx['part_count'] = Part.objects.count()
|
||||
ctx['part_cat_count'] = PartCategory.objects.count()
|
||||
|
||||
|
||||
# Stock stats
|
||||
ctx['stock_item_count'] = StockItem.objects.count()
|
||||
ctx['stock_loc_count'] = StockLocation.objects.count()
|
||||
|
@ -73,7 +73,7 @@ class BarcodeScan(APIView):
|
||||
|
||||
# A plugin has been found!
|
||||
if plugin is not None:
|
||||
|
||||
|
||||
# Try to associate with a stock item
|
||||
item = plugin.getStockItem()
|
||||
|
||||
@ -133,7 +133,7 @@ class BarcodeScan(APIView):
|
||||
class BarcodeAssign(APIView):
|
||||
"""
|
||||
Endpoint for assigning a barcode to a stock item.
|
||||
|
||||
|
||||
- This only works if the barcode is not already associated with an object in the database
|
||||
- If the barcode does not match an object, then the barcode hash is assigned to the StockItem
|
||||
"""
|
||||
@ -178,7 +178,7 @@ class BarcodeAssign(APIView):
|
||||
|
||||
# Matching plugin was found
|
||||
if plugin is not None:
|
||||
|
||||
|
||||
hash = plugin.hash()
|
||||
response['hash'] = hash
|
||||
response['plugin'] = plugin.name
|
||||
@ -234,7 +234,7 @@ class BarcodeAssign(APIView):
|
||||
barcode_api_urls = [
|
||||
|
||||
url(r'^link/$', BarcodeAssign.as_view(), name='api-barcode-link'),
|
||||
|
||||
|
||||
# Catch-all performs barcode 'scan'
|
||||
url(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
|
||||
]
|
||||
|
@ -21,7 +21,7 @@ def hash_barcode(barcode_data):
|
||||
|
||||
HACK: Remove any 'non printable' characters from the hash,
|
||||
as it seems browers will remove special control characters...
|
||||
|
||||
|
||||
TODO: Work out a way around this!
|
||||
"""
|
||||
|
||||
|
@ -92,7 +92,7 @@ class BarcodeAPITest(APITestCase):
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
self.assertIn('stockitem', data)
|
||||
|
||||
pk = data['stockitem']['pk']
|
||||
@ -121,7 +121,7 @@ class BarcodeAPITest(APITestCase):
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
self.assertIn('success', data)
|
||||
|
||||
hash = data['hash']
|
||||
|
@ -20,7 +20,7 @@ from .serializers import BuildSerializer, BuildItemSerializer
|
||||
|
||||
class BuildList(generics.ListCreateAPIView):
|
||||
""" API endpoint for accessing a list of Build objects.
|
||||
|
||||
|
||||
- GET: Return list of objects (with filters)
|
||||
- POST: Create a new Build object
|
||||
"""
|
||||
@ -65,7 +65,7 @@ class BuildList(generics.ListCreateAPIView):
|
||||
queryset = BuildSerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
@ -118,7 +118,7 @@ class Build(MPTTModel):
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
|
||||
|
||||
reference = models.CharField(
|
||||
unique=True,
|
||||
max_length=64,
|
||||
@ -168,7 +168,7 @@ class Build(MPTTModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('SalesOrder to which this build is allocated')
|
||||
)
|
||||
|
||||
|
||||
take_from = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Source Location'),
|
||||
@ -177,7 +177,7 @@ class Build(MPTTModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('Select location to take stock from for this build (leave blank to take from any stock location)')
|
||||
)
|
||||
|
||||
|
||||
destination = models.ForeignKey(
|
||||
'stock.StockLocation',
|
||||
verbose_name=_('Destination Location'),
|
||||
@ -207,7 +207,7 @@ class Build(MPTTModel):
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text=_('Build status code')
|
||||
)
|
||||
|
||||
|
||||
batch = models.CharField(
|
||||
verbose_name=_('Batch Code'),
|
||||
max_length=100,
|
||||
@ -215,9 +215,9 @@ class Build(MPTTModel):
|
||||
null=True,
|
||||
help_text=_('Batch code for this build output')
|
||||
)
|
||||
|
||||
|
||||
creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date'))
|
||||
|
||||
|
||||
target_date = models.DateField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Target completion date'),
|
||||
@ -251,7 +251,7 @@ class Build(MPTTModel):
|
||||
help_text=_('User responsible for this build order'),
|
||||
related_name='builds_responsible',
|
||||
)
|
||||
|
||||
|
||||
link = InvenTree.fields.InvenTreeURLField(
|
||||
verbose_name=_('External Link'),
|
||||
blank=True, help_text=_('Link to external URL')
|
||||
@ -272,7 +272,7 @@ class Build(MPTTModel):
|
||||
else:
|
||||
descendants = self.get_descendants(include_self=True)
|
||||
Build.objects.filter(parent__pk__in=[d.pk for d in descendants])
|
||||
|
||||
|
||||
def sub_build_count(self, cascade=True):
|
||||
"""
|
||||
Return the number of sub builds under this one.
|
||||
@ -295,7 +295,7 @@ class Build(MPTTModel):
|
||||
query = query.filter(Build.OVERDUE_FILTER)
|
||||
|
||||
return query.exists()
|
||||
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
"""
|
||||
@ -441,7 +441,7 @@ class Build(MPTTModel):
|
||||
|
||||
# Extract the "most recent" build order reference
|
||||
builds = cls.objects.exclude(reference=None)
|
||||
|
||||
|
||||
if not builds.exists():
|
||||
return None
|
||||
|
||||
@ -543,7 +543,7 @@ class Build(MPTTModel):
|
||||
- The sub_item in the BOM line must *not* be trackable
|
||||
- There is only a single stock item available (which has not already been allocated to this build)
|
||||
- The stock item has an availability greater than zero
|
||||
|
||||
|
||||
Returns:
|
||||
A list object containing the StockItem objects to be allocated (and the quantities).
|
||||
Each item in the list is a dict as follows:
|
||||
@ -648,7 +648,7 @@ class Build(MPTTModel):
|
||||
"""
|
||||
Deletes all stock allocations for this build.
|
||||
"""
|
||||
|
||||
|
||||
allocations = BuildItem.objects.filter(build=self)
|
||||
|
||||
allocations.delete()
|
||||
@ -1145,7 +1145,7 @@ class BuildItem(models.Model):
|
||||
"""
|
||||
|
||||
self.validate_unique()
|
||||
|
||||
|
||||
super().clean()
|
||||
|
||||
errors = {}
|
||||
@ -1159,7 +1159,7 @@ class BuildItem(models.Model):
|
||||
# Allocated part must be in the BOM for the master part
|
||||
if self.stock_item.part not in self.build.part.getRequiredParts(recursive=False):
|
||||
errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'").format(p=self.build.part.full_name)]
|
||||
|
||||
|
||||
# Allocated quantity cannot exceed available stock quantity
|
||||
if self.quantity > self.stock_item.quantity:
|
||||
errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})").format(
|
||||
|
@ -86,7 +86,7 @@
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
$("#btn-order-parts").click(function() {
|
||||
launchModalForm("/order/purchase-order/order-parts/", {
|
||||
data: {
|
||||
@ -94,7 +94,7 @@
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -230,5 +230,5 @@ src="{% static 'img/blank_image.png' %}"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
@ -17,9 +17,9 @@
|
||||
<div class='col-sm-6'>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||
<div class='btn-group'>
|
||||
@ -66,7 +66,7 @@
|
||||
|
||||
<script type='text/javascript'>
|
||||
function loadOrderEvents(calendar) {
|
||||
|
||||
|
||||
var start = startDate(calendar);
|
||||
var end = endDate(calendar);
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
var prefix = '{% settings_value "BUILDORDER_REFERENCE_PREFIX" %}';
|
||||
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
|
||||
|
||||
var order = response[idx];
|
||||
|
||||
var date = order.creation_date;
|
||||
@ -155,7 +155,7 @@ $('#view-calendar').click(function() {
|
||||
$(".fixed-table-pagination").hide();
|
||||
$(".columns-right").hide();
|
||||
$(".search").hide();
|
||||
|
||||
|
||||
$("#build-order-calendar").show();
|
||||
$("#view-list").show();
|
||||
|
||||
@ -166,7 +166,7 @@ $("#view-list").click(function() {
|
||||
// Hide the calendar view, show the list view
|
||||
$("#build-order-calendar").hide();
|
||||
$("#view-list").hide();
|
||||
|
||||
|
||||
$(".fixed-table-pagination").show();
|
||||
$(".columns-right").show();
|
||||
$(".search").show();
|
||||
|
@ -17,7 +17,7 @@
|
||||
</li>
|
||||
|
||||
{% if build.active %}
|
||||
|
||||
|
||||
<li class='list-group-item {% if tab == "allocate" %}active{% endif %}' title='{% trans "Allocate Stock" %}'>
|
||||
<a href='{% url "build-allocate" build.id %}'>
|
||||
<span class='fas fa-tools'></span>
|
||||
|
@ -20,11 +20,11 @@
|
||||
<hr>
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
@ -30,7 +30,7 @@ class BuildAPITest(InvenTreeAPITestCase):
|
||||
'build.change',
|
||||
'build.add'
|
||||
]
|
||||
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
@ -54,7 +54,7 @@ class BuildListTest(BuildAPITest):
|
||||
|
||||
builds = self.get(self.url, data={'active': True})
|
||||
self.assertEqual(len(builds.data), 1)
|
||||
|
||||
|
||||
builds = self.get(self.url, data={'status': BuildStatus.COMPLETE})
|
||||
self.assertEqual(len(builds.data), 4)
|
||||
|
||||
|
@ -114,7 +114,7 @@ class BuildTest(TestCase):
|
||||
# Perform some basic tests before we start the ball rolling
|
||||
|
||||
self.assertEqual(StockItem.objects.count(), 6)
|
||||
|
||||
|
||||
# Build is PENDING
|
||||
self.assertEqual(self.build.status, status.BuildStatus.PENDING)
|
||||
|
||||
@ -142,7 +142,7 @@ class BuildTest(TestCase):
|
||||
|
||||
# Create a BuiltItem which points to an invalid StockItem
|
||||
b = BuildItem(stock_item=stock, build=self.build, quantity=10)
|
||||
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
b.save()
|
||||
|
||||
@ -339,7 +339,7 @@ class BuildTest(TestCase):
|
||||
self.assertTrue(self.build.can_complete)
|
||||
|
||||
self.build.complete_build(None)
|
||||
|
||||
|
||||
self.assertEqual(self.build.status, status.BuildStatus.COMPLETE)
|
||||
|
||||
# the original BuildItem objects should have been deleted!
|
||||
@ -351,12 +351,12 @@ class BuildTest(TestCase):
|
||||
# This stock item has been depleted!
|
||||
with self.assertRaises(StockItem.DoesNotExist):
|
||||
StockItem.objects.get(pk=self.stock_1_1.pk)
|
||||
|
||||
|
||||
# This stock item has *not* been depleted
|
||||
x = StockItem.objects.get(pk=self.stock_2_1.pk)
|
||||
|
||||
self.assertEqual(x.quantity, 4970)
|
||||
|
||||
|
||||
# And 10 new stock items created for the build output
|
||||
outputs = StockItem.objects.filter(build=self.build)
|
||||
|
||||
|
@ -251,7 +251,7 @@ class TestBuildViews(TestCase):
|
||||
content = str(response.content)
|
||||
|
||||
self.assertIn(build.title, content)
|
||||
|
||||
|
||||
def test_build_create(self):
|
||||
""" Test the build creation view (ajax form) """
|
||||
|
||||
@ -260,7 +260,7 @@ class TestBuildViews(TestCase):
|
||||
# Create build without specifying part
|
||||
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
# Create build with valid part
|
||||
response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@ -281,7 +281,7 @@ class TestBuildViews(TestCase):
|
||||
# Get the page in editing mode
|
||||
response = self.client.get(url, {'edit': 1})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
def test_build_item_create(self):
|
||||
""" Test the BuildItem creation view (ajax form) """
|
||||
|
||||
@ -305,7 +305,7 @@ class TestBuildViews(TestCase):
|
||||
|
||||
def test_build_item_edit(self):
|
||||
""" Test the BuildItem edit view (ajax form) """
|
||||
|
||||
|
||||
# TODO
|
||||
# url = reverse('build-item-edit')
|
||||
pass
|
||||
@ -323,7 +323,7 @@ class TestBuildViews(TestCase):
|
||||
# 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'])
|
||||
|
||||
@ -353,7 +353,7 @@ class TestBuildViews(TestCase):
|
||||
# Test with confirmation, invalid location
|
||||
response = self.client.post(url, {'confirm': 1, 'location': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertFalse(data['form_valid'])
|
||||
|
||||
@ -365,7 +365,7 @@ class TestBuildViews(TestCase):
|
||||
# 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'])
|
||||
|
||||
@ -393,7 +393,7 @@ class TestBuildViews(TestCase):
|
||||
|
||||
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)
|
||||
|
@ -159,7 +159,7 @@ class BuildOutputCreate(AjaxUpdateView):
|
||||
|
||||
if quantity:
|
||||
build = self.get_object()
|
||||
|
||||
|
||||
# Check that requested output don't exceed build remaining quantity
|
||||
maximum_output = int(build.remaining - build.incomplete_count)
|
||||
if quantity > maximum_output:
|
||||
@ -318,7 +318,7 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
form_class = forms.UnallocateBuildForm
|
||||
ajax_form_title = _("Unallocate Stock")
|
||||
ajax_template_name = "build/unallocate.html"
|
||||
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial()
|
||||
@ -341,7 +341,7 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
|
||||
build = self.get_object()
|
||||
form = self.get_form()
|
||||
|
||||
|
||||
confirm = request.POST.get('confirm', False)
|
||||
|
||||
output_id = request.POST.get('output_id', None)
|
||||
@ -382,7 +382,7 @@ class BuildUnallocate(AjaxUpdateView):
|
||||
# Unallocate "untracked" parts
|
||||
else:
|
||||
build.unallocateUntracked(part=part)
|
||||
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
@ -401,7 +401,7 @@ class BuildComplete(AjaxUpdateView):
|
||||
|
||||
model = Build
|
||||
form_class = forms.CompleteBuildForm
|
||||
|
||||
|
||||
ajax_form_title = _('Complete Build Order')
|
||||
ajax_template_name = 'build/complete.html'
|
||||
|
||||
@ -437,9 +437,9 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
context_object_name = "build"
|
||||
ajax_form_title = _("Complete Build Output")
|
||||
ajax_template_name = "build/complete_output.html"
|
||||
|
||||
|
||||
def get_form(self):
|
||||
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
form = super().get_form()
|
||||
@ -500,7 +500,7 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
|
||||
- If the part being built has a default location, pre-select that location
|
||||
"""
|
||||
|
||||
|
||||
initials = super().get_initial()
|
||||
build = self.get_object()
|
||||
|
||||
@ -585,7 +585,7 @@ class BuildOutputComplete(AjaxUpdateView):
|
||||
location=location,
|
||||
status=stock_status,
|
||||
)
|
||||
|
||||
|
||||
def get_data(self):
|
||||
""" Provide feedback data back to the form """
|
||||
return {
|
||||
@ -600,7 +600,7 @@ class BuildNotes(InvenTreeRoleMixin, UpdateView):
|
||||
context_object_name = 'build'
|
||||
template_name = 'build/notes.html'
|
||||
model = Build
|
||||
|
||||
|
||||
# Override the default permission role for this View
|
||||
role_required = 'build.view'
|
||||
|
||||
@ -612,7 +612,7 @@ class BuildNotes(InvenTreeRoleMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
ctx['editing'] = str2bool(self.request.GET.get('edit', ''))
|
||||
|
||||
return ctx
|
||||
@ -746,7 +746,7 @@ class BuildCreate(AjaxCreateView):
|
||||
|
||||
class BuildUpdate(AjaxUpdateView):
|
||||
""" View for editing a Build object """
|
||||
|
||||
|
||||
model = Build
|
||||
form_class = forms.EditBuildForm
|
||||
context_object_name = 'build'
|
||||
@ -804,7 +804,7 @@ class BuildItemDelete(AjaxDeleteView):
|
||||
ajax_template_name = 'build/delete_build_item.html'
|
||||
ajax_form_title = _('Unallocate Stock')
|
||||
context_object_name = 'item'
|
||||
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'danger': _('Removed parts from build allocation')
|
||||
@ -826,7 +826,7 @@ class BuildItemCreate(AjaxCreateView):
|
||||
|
||||
# The "part" which is being allocated to the output
|
||||
part = None
|
||||
|
||||
|
||||
available_stock = None
|
||||
|
||||
def get_context_data(self):
|
||||
@ -906,7 +906,7 @@ class BuildItemCreate(AjaxCreateView):
|
||||
if part_id:
|
||||
try:
|
||||
self.part = Part.objects.get(pk=part_id)
|
||||
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
|
||||
@ -958,7 +958,7 @@ class BuildItemCreate(AjaxCreateView):
|
||||
|
||||
# Reference to a StockItem object
|
||||
item = None
|
||||
|
||||
|
||||
# Reference to a Build object
|
||||
build = None
|
||||
|
||||
@ -999,7 +999,7 @@ class BuildItemCreate(AjaxCreateView):
|
||||
quantity = float(quantity)
|
||||
elif required_quantity is not None:
|
||||
quantity = required_quantity
|
||||
|
||||
|
||||
item_id = self.get_param('item')
|
||||
|
||||
# If the request specifies a particular StockItem
|
||||
@ -1035,7 +1035,7 @@ class BuildItemEdit(AjaxUpdateView):
|
||||
ajax_template_name = 'build/edit_build_item.html'
|
||||
form_class = forms.EditBuildItemForm
|
||||
ajax_form_title = _('Edit Stock Allocation')
|
||||
|
||||
|
||||
def get_data(self):
|
||||
return {
|
||||
'info': _('Updated Build Item'),
|
||||
@ -1068,7 +1068,7 @@ class BuildAttachmentCreate(AjaxCreateView):
|
||||
model = BuildOrderAttachment
|
||||
form_class = forms.EditBuildAttachmentForm
|
||||
ajax_form_title = _('Add Build Order Attachment')
|
||||
|
||||
|
||||
def save(self, form, **kwargs):
|
||||
"""
|
||||
Add information on the user that uploaded the attachment
|
||||
@ -1105,7 +1105,7 @@ class BuildAttachmentCreate(AjaxCreateView):
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['build'].widget = HiddenInput()
|
||||
|
||||
|
||||
return form
|
||||
|
||||
|
||||
|
@ -9,7 +9,7 @@ from .models import InvenTreeSetting
|
||||
|
||||
|
||||
class SettingsAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
list_display = ('key', 'value')
|
||||
|
||||
|
||||
|
@ -42,7 +42,7 @@ class InvenTreeSetting(models.Model):
|
||||
The key of each item is the name of the value as it appears in the database.
|
||||
|
||||
Each global setting has the following parameters:
|
||||
|
||||
|
||||
- name: Translatable string name of the setting (required)
|
||||
- description: Translatable string description of the setting (required)
|
||||
- default: Default value (optional)
|
||||
@ -414,7 +414,7 @@ class InvenTreeSetting(models.Model):
|
||||
# Evaluate the function (we expect it will return a list of tuples...)
|
||||
return choices()
|
||||
"""
|
||||
|
||||
|
||||
return choices
|
||||
|
||||
@classmethod
|
||||
@ -524,7 +524,7 @@ class InvenTreeSetting(models.Model):
|
||||
# Enforce standard boolean representation
|
||||
if setting.is_bool():
|
||||
value = InvenTree.helpers.str2bool(value)
|
||||
|
||||
|
||||
setting.value = str(value)
|
||||
setting.save()
|
||||
|
||||
@ -666,7 +666,7 @@ class InvenTreeSetting(models.Model):
|
||||
|
||||
if validator == int:
|
||||
return True
|
||||
|
||||
|
||||
if type(validator) in [list, tuple]:
|
||||
for v in validator:
|
||||
if v == int:
|
||||
@ -677,7 +677,7 @@ class InvenTreeSetting(models.Model):
|
||||
def as_int(self):
|
||||
"""
|
||||
Return the value of this setting converted to a boolean value.
|
||||
|
||||
|
||||
If an error occurs, return the default value
|
||||
"""
|
||||
|
||||
@ -687,7 +687,7 @@ class InvenTreeSetting(models.Model):
|
||||
value = self.default_value()
|
||||
|
||||
return value
|
||||
|
||||
|
||||
|
||||
class PriceBreak(models.Model):
|
||||
"""
|
||||
|
@ -19,7 +19,7 @@ def currency_code_default():
|
||||
|
||||
if code not in CURRENCIES:
|
||||
code = 'USD'
|
||||
|
||||
|
||||
return code
|
||||
|
||||
|
||||
|
@ -117,7 +117,7 @@ class SettingsViewTest(TestCase):
|
||||
"""
|
||||
Test for binary value
|
||||
"""
|
||||
|
||||
|
||||
setting = InvenTreeSetting.get_setting_object('PART_COMPONENT')
|
||||
|
||||
self.assertTrue(setting.as_bool())
|
||||
|
@ -19,7 +19,7 @@ class SettingsTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
user = get_user_model()
|
||||
|
||||
|
||||
self.user = user.objects.create_user('username', 'user@email.com', 'password')
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
@ -55,7 +55,7 @@ class SettingEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
|
||||
setting = self.get_object()
|
||||
|
||||
choices = setting.choices()
|
||||
|
@ -41,7 +41,7 @@ class CompanyList(generics.ListCreateAPIView):
|
||||
queryset = CompanySerializer.annotate_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
@ -116,7 +116,7 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
@ -167,7 +167,7 @@ class ManufacturerPartList(generics.ListCreateAPIView):
|
||||
'part__name',
|
||||
'part__description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of ManufacturerPart object
|
||||
@ -255,7 +255,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
kwargs['supplier_detail'] = str2bool(self.request.query_params.get('supplier_detail', None))
|
||||
except AttributeError:
|
||||
@ -270,7 +270,7 @@ class SupplierPartList(generics.ListCreateAPIView):
|
||||
kwargs['pretty'] = str2bool(self.request.query_params.get('pretty', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
@ -24,7 +24,7 @@
|
||||
name: A customer
|
||||
description: A company that we sell things to!
|
||||
is_customer: True
|
||||
|
||||
|
||||
- model: company.company
|
||||
pk: 5
|
||||
fields:
|
||||
|
@ -158,7 +158,7 @@ class EditSupplierPartForm(HelperForm):
|
||||
empty_choice = [('', '----------')]
|
||||
|
||||
manufacturers = [(manufacturer.id, manufacturer.name) for manufacturer in Company.objects.filter(is_manufacturer=True)]
|
||||
|
||||
|
||||
return empty_choice + manufacturers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -149,7 +149,7 @@ class Company(models.Model):
|
||||
def currency_code(self):
|
||||
"""
|
||||
Return the currency code associated with this company.
|
||||
|
||||
|
||||
- If the currency code is invalid, use the default currency
|
||||
- If the currency code is not specified, use the default currency
|
||||
"""
|
||||
@ -184,7 +184,7 @@ class Company(models.Model):
|
||||
return getMediaUrl(self.image.thumbnail.url)
|
||||
else:
|
||||
return getBlankThumbnail()
|
||||
|
||||
|
||||
@property
|
||||
def manufactured_part_count(self):
|
||||
""" The number of parts manufactured by this company """
|
||||
@ -299,7 +299,7 @@ class ManufacturerPart(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('part', 'manufacturer', 'MPN')
|
||||
|
||||
|
||||
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
||||
related_name='manufacturer_parts',
|
||||
verbose_name=_('Base Part'),
|
||||
@ -308,7 +308,7 @@ class ManufacturerPart(models.Model):
|
||||
},
|
||||
help_text=_('Select part'),
|
||||
)
|
||||
|
||||
|
||||
manufacturer = models.ForeignKey(
|
||||
Company,
|
||||
on_delete=models.CASCADE,
|
||||
@ -356,7 +356,7 @@ class ManufacturerPart(models.Model):
|
||||
if not manufacturer_part:
|
||||
manufacturer_part = ManufacturerPart(part=part, manufacturer=manufacturer, MPN=mpn, description=description, link=link)
|
||||
manufacturer_part.save()
|
||||
|
||||
|
||||
return manufacturer_part
|
||||
|
||||
def __str__(self):
|
||||
@ -411,7 +411,7 @@ class SupplierPart(models.Model):
|
||||
MPN = kwargs.pop('MPN')
|
||||
else:
|
||||
MPN = None
|
||||
|
||||
|
||||
if manufacturer or MPN:
|
||||
if not self.manufacturer_part:
|
||||
# Create ManufacturerPart
|
||||
@ -426,7 +426,7 @@ class SupplierPart(models.Model):
|
||||
manufacturer_part_id = self.manufacturer_part.id
|
||||
except AttributeError:
|
||||
manufacturer_part_id = None
|
||||
|
||||
|
||||
if manufacturer_part_id:
|
||||
try:
|
||||
(manufacturer_part, created) = ManufacturerPart.objects.update_or_create(part=self.part,
|
||||
@ -501,7 +501,7 @@ class SupplierPart(models.Model):
|
||||
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
|
||||
packaging = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Packaging'), help_text=_('Part packaging'))
|
||||
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Order multiple'))
|
||||
|
||||
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
||||
@ -603,7 +603,7 @@ class SupplierPart(models.Model):
|
||||
|
||||
if self.manufacturer_string:
|
||||
s = s + ' | ' + self.manufacturer_string
|
||||
|
||||
|
||||
return s
|
||||
|
||||
|
||||
|
@ -51,7 +51,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
||||
return queryset
|
||||
|
||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
||||
|
||||
|
||||
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
parts_supplied = serializers.IntegerField(read_only=True)
|
||||
@ -157,9 +157,9 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('pretty_name')
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(queryset=Company.objects.filter(is_supplier=True))
|
||||
|
||||
|
||||
manufacturer = serializers.PrimaryKeyRelatedField(source='manufacturer_part.manufacturer', read_only=True)
|
||||
|
||||
|
||||
MPN = serializers.StringRelatedField(source='manufacturer_part.MPN')
|
||||
|
||||
manufacturer_part = ManufacturerPartSerializer(read_only=True)
|
||||
|
@ -75,5 +75,5 @@
|
||||
{% endblock %}
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
{% endblock %}
|
@ -6,7 +6,7 @@
|
||||
{% trans "Are you sure you want to delete the following Manufacturer Parts?" %}
|
||||
</div>
|
||||
{% for part in parts %}
|
||||
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
@ -17,7 +17,7 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tr>
|
||||
<input type='hidden' name='manufacturer-part-{{ part.id}}' value='manufacturer-part-{{ part.id }}'/>
|
||||
|
||||
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.part.image %}
|
||||
{{ part.part.full_name }}
|
||||
|
@ -33,6 +33,6 @@
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
@ -53,7 +53,7 @@ $('#supplier-create').click(function () {
|
||||
});
|
||||
|
||||
$("#supplier-part-delete").click(function() {
|
||||
|
||||
|
||||
var selections = $("#supplier-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
@ -24,7 +24,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if company.is_supplier or company.is_manufacturer %}
|
||||
<li class='list-group-item {% if tab == "supplier_parts" %}active{% endif %}' title='{% trans "Supplied Parts" %}'>
|
||||
<a href='{% url "company-detail-supplier-parts" company.id %}'>
|
||||
|
@ -18,11 +18,11 @@
|
||||
{% if editing %}
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
@ -43,5 +43,5 @@ $("#edit-notes").click(function() {
|
||||
location.href = "{% url 'company-notes' company.id %}?edit=1";
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
@ -12,7 +12,7 @@
|
||||
{% for part in parts %}
|
||||
<tr>
|
||||
<input type='hidden' name='supplier-part-{{ part.id}}' value='supplier-part-{{ part.id }}'/>
|
||||
|
||||
|
||||
<td>
|
||||
{% include "hover_image.html" with image=part.part.image %}
|
||||
{{ part.part.full_name }}
|
||||
|
@ -43,6 +43,6 @@
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<table class='table table-striped table-condensed po-table' id='purchase-order-table' data-toolbar='#button-bar'>
|
||||
</table>
|
||||
|
||||
|
@ -90,7 +90,7 @@ $('#price-break-table').inventreeTable({
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
|
@ -48,7 +48,7 @@ class TestManufacturerField(MigratorTestCase):
|
||||
- Company object (supplier)
|
||||
- SupplierPart object
|
||||
"""
|
||||
|
||||
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
Company = self.old_state.apps.get_model('company', 'company')
|
||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||
@ -123,7 +123,7 @@ class TestManufacturerPart(MigratorTestCase):
|
||||
- Company object (supplier)
|
||||
- SupplierPart object
|
||||
"""
|
||||
|
||||
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
Company = self.old_state.apps.get_model('company', 'company')
|
||||
SupplierPart = self.old_state.apps.get_model('company', 'supplierpart')
|
||||
@ -220,7 +220,7 @@ class TestManufacturerPart(MigratorTestCase):
|
||||
|
||||
# Check on the SupplierPart objects
|
||||
SupplierPart = self.new_state.apps.get_model('company', 'supplierpart')
|
||||
|
||||
|
||||
supplier_parts = SupplierPart.objects.all()
|
||||
self.assertEqual(supplier_parts.count(), 6)
|
||||
|
||||
@ -229,10 +229,10 @@ class TestManufacturerPart(MigratorTestCase):
|
||||
|
||||
# Check on the ManufacturerPart objects
|
||||
ManufacturerPart = self.new_state.apps.get_model('company', 'manufacturerpart')
|
||||
|
||||
|
||||
manufacturer_parts = ManufacturerPart.objects.all()
|
||||
self.assertEqual(manufacturer_parts.count(), 4)
|
||||
|
||||
|
||||
manufacturer_part = manufacturer_parts.first()
|
||||
self.assertEqual(manufacturer_part.MPN, 'MUR-CAP-123456')
|
||||
|
||||
@ -293,7 +293,7 @@ class TestCurrencyMigration(MigratorTestCase):
|
||||
self.assertIsNone(pb.price)
|
||||
|
||||
def test_currency_migration(self):
|
||||
|
||||
|
||||
PB = self.new_state.apps.get_model('company', 'supplierpricebreak')
|
||||
|
||||
for pb in PB.objects.all():
|
||||
|
@ -30,7 +30,7 @@ class CompanyViewTestBase(TestCase):
|
||||
|
||||
# Create a user
|
||||
user = get_user_model()
|
||||
|
||||
|
||||
self.user = user.objects.create_user(
|
||||
username='username',
|
||||
email='user@email.com',
|
||||
@ -83,7 +83,7 @@ class SupplierPartViewTests(CompanyViewTestBase):
|
||||
def test_supplier_part_create(self):
|
||||
"""
|
||||
Test the SupplierPartCreate view.
|
||||
|
||||
|
||||
This view allows some additional functionality,
|
||||
specifically it allows the user to create a single-quantity price break
|
||||
automatically, when saving the new SupplierPart model.
|
||||
@ -171,7 +171,7 @@ class SupplierPartViewTests(CompanyViewTestBase):
|
||||
'confirm_delete': True
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertEqual(n - 2, SupplierPart.objects.count())
|
||||
@ -213,7 +213,7 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
|
||||
"""
|
||||
Test the ManufacturerPartCreate view.
|
||||
"""
|
||||
|
||||
|
||||
url = reverse('manufacturer-part-create')
|
||||
|
||||
# First check that we can GET the form
|
||||
@ -252,7 +252,7 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
|
||||
"""
|
||||
Test that the SupplierPartCreate view creates Manufacturer Part.
|
||||
"""
|
||||
|
||||
|
||||
url = reverse('supplier-part-create')
|
||||
|
||||
# First check that we can GET the form
|
||||
@ -297,7 +297,7 @@ class ManufacturerPartViewTests(CompanyViewTestBase):
|
||||
'confirm_delete': True
|
||||
},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the ManufacturerPart was deleted
|
||||
|
@ -71,7 +71,7 @@ class CompanySimpleTest(TestCase):
|
||||
acme = Company.objects.get(pk=1)
|
||||
appel = Company.objects.get(pk=2)
|
||||
zerg = Company.objects.get(pk=3)
|
||||
|
||||
|
||||
self.assertTrue(acme.has_parts)
|
||||
self.assertEqual(acme.supplied_part_count, 4)
|
||||
|
||||
@ -82,7 +82,7 @@ class CompanySimpleTest(TestCase):
|
||||
self.assertEqual(zerg.supplied_part_count, 2)
|
||||
|
||||
def test_price_breaks(self):
|
||||
|
||||
|
||||
self.assertTrue(self.acme0001.has_price_breaks)
|
||||
self.assertTrue(self.acme0002.has_price_breaks)
|
||||
self.assertTrue(self.zergm312.has_price_breaks)
|
||||
@ -121,7 +121,7 @@ class CompanySimpleTest(TestCase):
|
||||
pmin, pmax = m2x4.get_price_range(5)
|
||||
self.assertEqual(pmin, 35)
|
||||
self.assertEqual(pmax, 37.5)
|
||||
|
||||
|
||||
m3x12 = Part.objects.get(name='M3x12 SHCS')
|
||||
|
||||
self.assertEqual(m3x12.get_price_info(0.3), Decimal('2.4'))
|
||||
@ -187,14 +187,14 @@ class ManufacturerPartSimpleTest(TestCase):
|
||||
# Create a manufacturer part
|
||||
self.part = Part.objects.get(pk=1)
|
||||
manufacturer = Company.objects.get(pk=1)
|
||||
|
||||
|
||||
self.mp = ManufacturerPart.create(
|
||||
part=self.part,
|
||||
manufacturer=manufacturer,
|
||||
mpn='PART_NUMBER',
|
||||
description='THIS IS A MANUFACTURER PART',
|
||||
)
|
||||
|
||||
|
||||
# Create a supplier part
|
||||
supplier = Company.objects.get(pk=5)
|
||||
supplier_part = SupplierPart.objects.create(
|
||||
|
@ -55,7 +55,7 @@ price_break_urls = [
|
||||
|
||||
manufacturer_part_detail_urls = [
|
||||
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
|
||||
|
||||
|
||||
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
|
||||
|
||||
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
|
||||
|
@ -96,7 +96,7 @@ class CompanyIndex(InvenTreeRoleMixin, ListView):
|
||||
if self.request.path == item:
|
||||
context = lookup[item]
|
||||
break
|
||||
|
||||
|
||||
if context is None:
|
||||
context = default
|
||||
|
||||
@ -279,7 +279,7 @@ class CompanyCreate(AjaxCreateView):
|
||||
|
||||
if url == reverse('supplier-create'):
|
||||
return _("Create new Supplier")
|
||||
|
||||
|
||||
if url == reverse('manufacturer-create'):
|
||||
return _('Create new Manufacturer')
|
||||
|
||||
@ -298,7 +298,7 @@ class CompanyCreate(AjaxCreateView):
|
||||
initials['is_supplier'] = True
|
||||
initials['is_customer'] = False
|
||||
initials['is_manufacturer'] = False
|
||||
|
||||
|
||||
elif url == reverse('manufacturer-create'):
|
||||
initials['is_manufacturer'] = True
|
||||
initials['is_supplier'] = True
|
||||
@ -319,7 +319,7 @@ class CompanyCreate(AjaxCreateView):
|
||||
|
||||
class CompanyDelete(AjaxDeleteView):
|
||||
""" View for deleting a Company object """
|
||||
|
||||
|
||||
model = Company
|
||||
success_url = '/company/'
|
||||
ajax_template_name = 'company/delete.html'
|
||||
@ -415,7 +415,7 @@ class ManufacturerPartCreate(AjaxCreateView):
|
||||
initials['manufacturer'] = Company.objects.get(pk=manufacturer_id)
|
||||
except (ValueError, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
initials['part'] = Part.objects.get(pk=part_id)
|
||||
@ -427,7 +427,7 @@ class ManufacturerPartCreate(AjaxCreateView):
|
||||
|
||||
class ManufacturerPartDelete(AjaxDeleteView):
|
||||
""" Delete view for removing a ManufacturerPart.
|
||||
|
||||
|
||||
ManufacturerParts can be deleted using a variety of 'selectors'.
|
||||
|
||||
- ?part=<pk> -> Delete a single ManufacturerPart object
|
||||
@ -561,7 +561,7 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
initials = super(SupplierPartEdit, self).get_initial().copy()
|
||||
|
||||
supplier_part = self.get_object()
|
||||
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
@ -686,7 +686,7 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
initials['MPN'] = manufacturer_part_obj.MPN
|
||||
except (ValueError, ManufacturerPart.DoesNotExist, Part.DoesNotExist, Company.DoesNotExist):
|
||||
pass
|
||||
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
initials['part'] = Part.objects.get(pk=part_id)
|
||||
@ -703,13 +703,13 @@ class SupplierPartCreate(AjaxCreateView):
|
||||
|
||||
if currency_code:
|
||||
initials['single_pricing'] = ('', currency)
|
||||
|
||||
|
||||
return initials
|
||||
|
||||
|
||||
class SupplierPartDelete(AjaxDeleteView):
|
||||
""" Delete view for removing a SupplierPart.
|
||||
|
||||
|
||||
SupplierParts can be deleted using a variety of 'selectors'.
|
||||
|
||||
- ?part=<pk> -> Delete a single SupplierPart object
|
||||
@ -840,7 +840,7 @@ class PriceBreakCreate(AjaxCreateView):
|
||||
|
||||
# Extract the currency object associated with the code
|
||||
currency = CURRENCIES.get(currency_code, None)
|
||||
|
||||
|
||||
if currency:
|
||||
initials['price'] = [1.0, currency]
|
||||
|
||||
|
@ -11,7 +11,7 @@ database:
|
||||
# or specify database options using environment variables
|
||||
|
||||
# Refer to the django documentation for full list of options
|
||||
|
||||
|
||||
# --- Available options: ---
|
||||
# ENGINE: Database engine. Selection from:
|
||||
# - sqlite3
|
||||
@ -114,7 +114,7 @@ allowed_hosts:
|
||||
cors:
|
||||
# CORS_ORIGIN_ALLOW_ALL - If True, the whitelist will not be used and all origins will be accepted.
|
||||
allow_all: True
|
||||
|
||||
|
||||
# CORS_ORIGIN_WHITELIST - A list of origins that are authorized to make cross-site HTTP requests. Defaults to []
|
||||
# whitelist:
|
||||
# - https://example.com
|
||||
|
@ -159,7 +159,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
"""
|
||||
Filter the StockItem label queryset.
|
||||
"""
|
||||
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# List of StockItem objects to match against
|
||||
@ -178,7 +178,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
|
||||
# Keep track of which labels match every specified stockitem
|
||||
valid_label_ids = set()
|
||||
|
||||
|
||||
for label in queryset.all():
|
||||
|
||||
matches = True
|
||||
@ -293,7 +293,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
"""
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
|
||||
# List of StockLocation objects to match against
|
||||
locations = self.get_locations()
|
||||
|
||||
|
@ -139,7 +139,7 @@ class LabelConfig(AppConfig):
|
||||
except:
|
||||
# Database might not yet be ready
|
||||
return
|
||||
|
||||
|
||||
src_dir = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)),
|
||||
'templates',
|
||||
|
@ -44,7 +44,7 @@ def rename_label(instance, filename):
|
||||
|
||||
|
||||
def validate_stock_item_filters(filters):
|
||||
|
||||
|
||||
filters = validateFilterString(filters, model=stock.models.StockItem)
|
||||
|
||||
return filters
|
||||
@ -82,7 +82,7 @@ class LabelTemplate(models.Model):
|
||||
|
||||
# Each class of label files will be stored in a separate subdirectory
|
||||
SUBDIR = "label"
|
||||
|
||||
|
||||
# Object we will be printing against (will be filled out later)
|
||||
object_to_print = None
|
||||
|
||||
|
@ -40,7 +40,7 @@ class TestReportTests(InvenTreeAPITestCase):
|
||||
return response.data
|
||||
|
||||
def test_list(self):
|
||||
|
||||
|
||||
response = self.do_list()
|
||||
|
||||
# TODO - Add some report templates to the fixtures
|
||||
|
@ -64,7 +64,7 @@ class CancelSalesOrderForm(HelperForm):
|
||||
fields = [
|
||||
'confirm',
|
||||
]
|
||||
|
||||
|
||||
|
||||
class ShipSalesOrderForm(HelperForm):
|
||||
|
||||
|
@ -309,7 +309,7 @@ class PurchaseOrder(Order):
|
||||
"""
|
||||
A PurchaseOrder can only be cancelled under the following circumstances:
|
||||
"""
|
||||
|
||||
|
||||
return self.status in [
|
||||
PurchaseOrderStatus.PLACED,
|
||||
PurchaseOrderStatus.PENDING
|
||||
@ -378,7 +378,7 @@ class PurchaseOrder(Order):
|
||||
|
||||
# Has this order been completed?
|
||||
if len(self.pending_line_items()) == 0:
|
||||
|
||||
|
||||
self.received_by = user
|
||||
self.complete_order() # This will save the model
|
||||
|
||||
@ -419,7 +419,7 @@ class SalesOrder(Order):
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
|
||||
# Construct a queryset for "completed" orders within the range
|
||||
completed = Q(status__in=SalesOrderStatus.COMPLETE) & Q(shipment_date__gte=min_date) & Q(shipment_date__lte=max_date)
|
||||
|
||||
@ -495,7 +495,7 @@ class SalesOrder(Order):
|
||||
for line in self.lines.all():
|
||||
if not line.is_fully_allocated():
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
def is_over_allocated(self):
|
||||
@ -590,11 +590,11 @@ class SalesOrderAttachment(InvenTreeAttachment):
|
||||
|
||||
class OrderLineItem(models.Model):
|
||||
""" Abstract model for an order line item
|
||||
|
||||
|
||||
Attributes:
|
||||
quantity: Number of items
|
||||
note: Annotation for the item
|
||||
|
||||
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
@ -603,13 +603,13 @@ class OrderLineItem(models.Model):
|
||||
quantity = RoundingDecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1, verbose_name=_('Quantity'), help_text=_('Item quantity'))
|
||||
|
||||
reference = models.CharField(max_length=100, blank=True, verbose_name=_('Reference'), help_text=_('Line item reference'))
|
||||
|
||||
|
||||
notes = models.CharField(max_length=500, blank=True, verbose_name=_('Notes'), help_text=_('Line item notes'))
|
||||
|
||||
|
||||
class PurchaseOrderLineItem(OrderLineItem):
|
||||
""" Model for a purchase order line item.
|
||||
|
||||
|
||||
Attributes:
|
||||
order: Reference to a PurchaseOrder object
|
||||
|
||||
@ -637,7 +637,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
def get_base_part(self):
|
||||
""" Return the base-part for the line item """
|
||||
return self.part.part
|
||||
|
||||
|
||||
# TODO - Function callback for when the SupplierPart is deleted?
|
||||
|
||||
part = models.ForeignKey(
|
||||
|
@ -61,7 +61,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
return queryset
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='supplier', many=False, read_only=True)
|
||||
|
||||
|
||||
line_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
status_text = serializers.CharField(source='get_status_display', read_only=True)
|
||||
@ -70,7 +70,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'issue_date',
|
||||
@ -89,7 +89,7 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
'target_date',
|
||||
'notes',
|
||||
]
|
||||
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
@ -110,10 +110,10 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
received = serializers.FloatField()
|
||||
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -144,7 +144,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrderAttachment
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
@ -270,7 +270,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
if allocations is not True:
|
||||
self.fields.pop('allocations')
|
||||
|
||||
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
|
||||
@ -310,7 +310,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = SalesOrderAttachment
|
||||
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'order',
|
||||
|
@ -44,7 +44,7 @@ $("#new-attachment").click(function() {
|
||||
|
||||
$("#attachment-table").on('click', '.attachment-edit-button', function() {
|
||||
var button = $(this);
|
||||
|
||||
|
||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/edit/`;
|
||||
|
||||
launchModalForm(url, {
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% extends "modal_delete_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Are you sure you wish to delete this line item?
|
||||
{% trans "Are you sure you wish to delete this line item?" %}
|
||||
{% endblock %}
|
@ -193,11 +193,11 @@ $("#po-table").inventreeTable({
|
||||
});
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
|
||||
|
||||
if (rowA.received == 0 && rowB.received == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
|
||||
var progressA = parseFloat(rowA.received) / rowA.quantity;
|
||||
var progressB = parseFloat(rowB.received) / rowB.quantity;
|
||||
|
||||
|
@ -83,7 +83,7 @@
|
||||
var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
|
||||
|
||||
var color = '#4c68f5';
|
||||
|
||||
|
||||
if (order.complete_date) {
|
||||
color = '#25c235';
|
||||
} else if (order.overdue) {
|
||||
@ -143,7 +143,7 @@ $('#view-calendar').click(function() {
|
||||
$(".columns-right").hide();
|
||||
$(".search").hide();
|
||||
$('#filter-list-salesorder').hide();
|
||||
|
||||
|
||||
$("#purchase-order-calendar").show();
|
||||
$("#view-list").show();
|
||||
|
||||
@ -154,7 +154,7 @@ $("#view-list").click(function() {
|
||||
// Hide the calendar view, show the list view
|
||||
$("#purchase-order-calendar").hide();
|
||||
$("#view-list").hide();
|
||||
|
||||
|
||||
$(".fixed-table-pagination").show();
|
||||
$(".columns-right").show();
|
||||
$(".search").show();
|
||||
|
@ -51,13 +51,13 @@ $("#new-so-line").click(function() {
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
function showAllocationSubTable(index, row, element) {
|
||||
// Construct a table showing stock items which have been allocated against this line item
|
||||
|
||||
|
||||
var html = `<div class='sub-table'><table class='table table-striped table-condensed' id='allocation-table-${row.pk}'></table></div>`;
|
||||
|
||||
element.html(html);
|
||||
|
||||
var lineItem = row;
|
||||
|
||||
|
||||
var table = $(`#allocation-table-${row.pk}`);
|
||||
|
||||
table.bootstrapTable({
|
||||
@ -70,7 +70,7 @@ function showAllocationSubTable(index, row, element) {
|
||||
title: '{% trans "Quantity" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
var text = '';
|
||||
|
||||
|
||||
if (row.serial != null && row.quantity == 1) {
|
||||
text = `{% trans "Serial Number" %}: ${row.serial}`;
|
||||
} else {
|
||||
@ -91,10 +91,10 @@ function showAllocationSubTable(index, row, element) {
|
||||
field: 'buttons',
|
||||
title: '{% trans "Actions" %}',
|
||||
formatter: function(value, row, index, field) {
|
||||
|
||||
|
||||
var html = "<div class='btn-group float-right' role='group'>";
|
||||
var pk = row.pk;
|
||||
|
||||
|
||||
{% if order.status == SalesOrderStatus.PENDING %}
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
|
||||
@ -256,11 +256,11 @@ $("#so-lines-table").inventreeTable({
|
||||
var A = rowA.fulfilled;
|
||||
var B = rowB.fulfilled;
|
||||
{% endif %}
|
||||
|
||||
|
||||
if (A == 0 && B == 0) {
|
||||
return (rowA.quantity > rowB.quantity) ? 1 : -1;
|
||||
}
|
||||
|
||||
|
||||
var progressA = parseFloat(A) / rowA.quantity;
|
||||
var progressB = parseFloat(B) / rowB.quantity;
|
||||
|
||||
@ -279,7 +279,7 @@ $("#so-lines-table").inventreeTable({
|
||||
var html = `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
var pk = row.pk;
|
||||
|
||||
|
||||
if (row.part) {
|
||||
var part = row.part_detail;
|
||||
|
||||
@ -292,14 +292,14 @@ $("#so-lines-table").inventreeTable({
|
||||
if (part.purchaseable) {
|
||||
html += makeIconButton('fa-shopping-cart', 'button-buy', row.part, '{% trans "Purchase stock" %}');
|
||||
}
|
||||
|
||||
|
||||
if (part.assembly) {
|
||||
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build stock" %}');
|
||||
}
|
||||
|
||||
html += makeIconButton('fa-dollar-sign icon-green', 'button-price', pk, '{% trans "Calculate price" %}');
|
||||
}
|
||||
|
||||
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
|
||||
|
||||
|
@ -152,7 +152,7 @@ $("#view-list").click(function() {
|
||||
// Hide the calendar view, show the list view
|
||||
$("#sales-order-calendar").hide();
|
||||
$("#view-list").hide();
|
||||
|
||||
|
||||
$(".fixed-table-pagination").show();
|
||||
$(".columns-right").show();
|
||||
$(".search").show();
|
||||
|
@ -94,7 +94,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
url = '/api/order/po/1/'
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
@ -109,7 +109,7 @@ class PurchaseOrderTest(OrderTest):
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
"""
|
||||
|
@ -73,7 +73,7 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
def test_add_duplicate_line_item(self):
|
||||
# Adding a duplicate line item to a SalesOrder is accepted
|
||||
|
||||
|
||||
for ii in range(1, 5):
|
||||
SalesOrderLineItem.objects.create(order=self.order, part=self.part, quantity=ii)
|
||||
|
||||
@ -107,7 +107,7 @@ class SalesOrderTest(TestCase):
|
||||
self.assertTrue(self.order.is_fully_allocated())
|
||||
self.assertTrue(self.line.is_fully_allocated())
|
||||
self.assertEqual(self.line.allocated_quantity(), 50)
|
||||
|
||||
|
||||
def test_order_cancel(self):
|
||||
# Allocate line items then cancel the order
|
||||
|
||||
@ -154,7 +154,7 @@ class SalesOrderTest(TestCase):
|
||||
|
||||
for item in outputs.all():
|
||||
self.assertEqual(item.quantity, 25)
|
||||
|
||||
|
||||
self.assertEqual(sa.sales_order, None)
|
||||
self.assertEqual(sb.sales_order, None)
|
||||
|
||||
@ -162,7 +162,7 @@ class SalesOrderTest(TestCase):
|
||||
self.assertEqual(SalesOrderAllocation.objects.count(), 0)
|
||||
|
||||
self.assertEqual(self.order.status, status.SalesOrderStatus.SHIPPED)
|
||||
|
||||
|
||||
self.assertTrue(self.order.is_fully_allocated())
|
||||
self.assertTrue(self.line.is_fully_allocated())
|
||||
self.assertEqual(self.line.fulfilled_quantity(), 50)
|
||||
|
@ -17,7 +17,7 @@ import json
|
||||
|
||||
|
||||
class OrderViewTestCase(TestCase):
|
||||
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
@ -193,7 +193,7 @@ class POTests(OrderViewTestCase):
|
||||
# Test without confirmation
|
||||
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
data = json.loads(response.content)
|
||||
|
||||
self.assertFalse(data['form_valid'])
|
||||
@ -221,7 +221,7 @@ class POTests(OrderViewTestCase):
|
||||
|
||||
# GET the form (pass the correct info)
|
||||
response = self.client.get(url, {'order': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
|
||||
post_data = {
|
||||
'part': 100,
|
||||
'quantity': 45,
|
||||
@ -303,7 +303,7 @@ class TestPOReceive(OrderViewTestCase):
|
||||
self.client.get(self.url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
|
||||
def test_receive_lines(self):
|
||||
|
||||
|
||||
post_data = {
|
||||
}
|
||||
|
||||
@ -330,7 +330,7 @@ class TestPOReceive(OrderViewTestCase):
|
||||
|
||||
# Receive negative number
|
||||
post_data['line-1'] = -100
|
||||
|
||||
|
||||
self.post(post_data, validate=False)
|
||||
|
||||
# Receive 75 items
|
||||
|
@ -36,7 +36,7 @@ class OrderTest(TestCase):
|
||||
self.assertEqual(order.get_absolute_url(), '/order/purchase-order/1/')
|
||||
|
||||
self.assertEqual(str(order), 'PO0001 - ACME')
|
||||
|
||||
|
||||
line = PurchaseOrderLineItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(str(line), "100 x ACME0001 from ACME (for PO0001 - ACME)")
|
||||
@ -113,7 +113,7 @@ class OrderTest(TestCase):
|
||||
|
||||
# Try to order a supplier part from the wrong supplier
|
||||
sku = SupplierPart.objects.get(SKU='ZERG-WIDGET')
|
||||
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
order.add_line_item(sku, 99)
|
||||
|
||||
@ -153,7 +153,7 @@ class OrderTest(TestCase):
|
||||
|
||||
with self.assertRaises(django_exceptions.ValidationError):
|
||||
order.receive_line_item(line, loc, 'not a number', user=None)
|
||||
|
||||
|
||||
# Receive the rest of the items
|
||||
order.receive_line_item(line, loc, 50, user=None)
|
||||
|
||||
|
@ -157,7 +157,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
|
||||
"""
|
||||
Save the user that uploaded the attachment
|
||||
"""
|
||||
|
||||
|
||||
attachment = form.save(commit=False)
|
||||
attachment.user = self.request.user
|
||||
attachment.save()
|
||||
@ -335,7 +335,7 @@ class PurchaseOrderCreate(AjaxCreateView):
|
||||
|
||||
order = form.save(commit=False)
|
||||
order.created_by = self.request.user
|
||||
|
||||
|
||||
return super().save(form)
|
||||
|
||||
|
||||
@ -370,7 +370,7 @@ class SalesOrderCreate(AjaxCreateView):
|
||||
|
||||
order = form.save(commit=False)
|
||||
order.created_by = self.request.user
|
||||
|
||||
|
||||
return super().save(form)
|
||||
|
||||
|
||||
@ -419,7 +419,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
||||
form_class = order_forms.CancelPurchaseOrderForm
|
||||
|
||||
def validate(self, order, form, **kwargs):
|
||||
|
||||
|
||||
confirm = str2bool(form.cleaned_data.get('confirm', False))
|
||||
|
||||
if not confirm:
|
||||
@ -541,11 +541,11 @@ class SalesOrderShip(AjaxUpdateView):
|
||||
|
||||
order = self.get_object()
|
||||
self.object = order
|
||||
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
confirm = str2bool(request.POST.get('confirm', False))
|
||||
|
||||
|
||||
valid = False
|
||||
|
||||
if not confirm:
|
||||
@ -1025,7 +1025,7 @@ class OrderParts(AjaxView):
|
||||
|
||||
for supplier in self.suppliers:
|
||||
supplier.order_items = []
|
||||
|
||||
|
||||
suppliers[supplier.name] = supplier
|
||||
|
||||
for part in self.parts:
|
||||
@ -1046,9 +1046,9 @@ class OrderParts(AjaxView):
|
||||
supplier.selected_purchase_order = orders.first().id
|
||||
else:
|
||||
supplier.selected_purchase_order = None
|
||||
|
||||
|
||||
suppliers[supplier.name] = supplier
|
||||
|
||||
|
||||
suppliers[supplier.name].order_items.append(part)
|
||||
|
||||
self.suppliers = [suppliers[key] for key in suppliers.keys()]
|
||||
@ -1066,7 +1066,7 @@ class OrderParts(AjaxView):
|
||||
if 'stock[]' in self.request.GET:
|
||||
|
||||
stock_id_list = self.request.GET.getlist('stock[]')
|
||||
|
||||
|
||||
""" Get a list of all the parts associated with the stock items.
|
||||
- Base part must be purchaseable.
|
||||
- Return a set of corresponding Part IDs
|
||||
@ -1109,7 +1109,7 @@ class OrderParts(AjaxView):
|
||||
parts = build.required_parts
|
||||
|
||||
for part in parts:
|
||||
|
||||
|
||||
# If ordering from a Build page, ignore parts that we have enough of
|
||||
if part.quantity_to_order <= 0:
|
||||
continue
|
||||
@ -1165,19 +1165,19 @@ class OrderParts(AjaxView):
|
||||
|
||||
# Extract part information from the form
|
||||
for item in self.request.POST:
|
||||
|
||||
|
||||
if item.startswith('part-supplier-'):
|
||||
|
||||
|
||||
pk = item.replace('part-supplier-', '')
|
||||
|
||||
|
||||
# Check that the part actually exists
|
||||
try:
|
||||
part = Part.objects.get(id=pk)
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
supplier_part_id = self.request.POST[item]
|
||||
|
||||
|
||||
quantity = self.request.POST.get('part-quantity-' + str(pk), 0)
|
||||
|
||||
# Ensure a valid supplier has been passed
|
||||
@ -1591,7 +1591,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
self.form.fields['line'].widget = HiddenInput()
|
||||
else:
|
||||
self.form.add_error('line', _('Select line item'))
|
||||
|
||||
|
||||
if self.part:
|
||||
self.form.fields['part'].widget = HiddenInput()
|
||||
else:
|
||||
@ -1626,7 +1626,7 @@ class SalesOrderAssignSerials(AjaxView, FormMixin):
|
||||
continue
|
||||
|
||||
# Now we have a valid stock item - but can it be added to the sales order?
|
||||
|
||||
|
||||
# If not in stock, cannot be added to the order
|
||||
if not stock_item.in_stock:
|
||||
self.form.add_error(
|
||||
@ -1694,7 +1694,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
model = SalesOrderAllocation
|
||||
form_class = order_forms.CreateSalesOrderAllocationForm
|
||||
ajax_form_title = _('Allocate Stock to Order')
|
||||
|
||||
|
||||
def get_initial(self):
|
||||
initials = super().get_initial().copy()
|
||||
|
||||
@ -1709,10 +1709,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
items = StockItem.objects.filter(part=line.part)
|
||||
|
||||
quantity = line.quantity - line.allocated_quantity()
|
||||
|
||||
|
||||
if quantity < 0:
|
||||
quantity = 0
|
||||
|
||||
|
||||
if items.count() == 1:
|
||||
item = items.first()
|
||||
initials['item'] = item
|
||||
@ -1728,7 +1728,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
line_id = form['line'].value()
|
||||
@ -1756,10 +1756,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
||||
|
||||
# Hide the 'line' field
|
||||
form.fields['line'].widget = HiddenInput()
|
||||
|
||||
|
||||
except (ValueError, SalesOrderLineItem.DoesNotExist):
|
||||
pass
|
||||
|
||||
|
||||
return form
|
||||
|
||||
|
||||
@ -1768,7 +1768,7 @@ class SalesOrderAllocationEdit(AjaxUpdateView):
|
||||
model = SalesOrderAllocation
|
||||
form_class = order_forms.EditSalesOrderAllocationForm
|
||||
ajax_form_title = _('Edit Allocation Quantity')
|
||||
|
||||
|
||||
def get_form(self):
|
||||
form = super().get_form()
|
||||
|
||||
|
@ -25,13 +25,13 @@ class PartResource(ModelResource):
|
||||
|
||||
# ForeignKey fields
|
||||
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory))
|
||||
|
||||
|
||||
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
category_name = Field(attribute='category__name', readonly=True)
|
||||
|
||||
|
||||
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
suppliers = Field(attribute='supplier_count', readonly=True)
|
||||
@ -73,7 +73,7 @@ class PartResource(ModelResource):
|
||||
|
||||
|
||||
class PartAdmin(ImportExportModelAdmin):
|
||||
|
||||
|
||||
resource_class = PartResource
|
||||
|
||||
list_display = ('full_name', 'description', 'total_stock', 'category')
|
||||
|
@ -41,7 +41,7 @@ class PartCategoryTree(TreeSerializer):
|
||||
model = PartCategory
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
|
||||
@property
|
||||
def root_url(self):
|
||||
return reverse('part-index')
|
||||
@ -79,7 +79,7 @@ class CategoryList(generics.ListCreateAPIView):
|
||||
pass
|
||||
# Look for top-level categories
|
||||
elif isNull(cat_id):
|
||||
|
||||
|
||||
if not cascade:
|
||||
queryset = queryset.filter(parent=None)
|
||||
|
||||
@ -166,9 +166,9 @@ class CategoryParameters(generics.ListAPIView):
|
||||
parent_categories = category.get_ancestors()
|
||||
for parent in parent_categories:
|
||||
category_list.append(parent.pk)
|
||||
|
||||
|
||||
queryset = queryset.filter(category__in=category_list)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@ -264,7 +264,7 @@ class PartThumbs(generics.ListAPIView):
|
||||
|
||||
# Get all Parts which have an associated image
|
||||
queryset = queryset.exclude(image='')
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
@ -301,7 +301,7 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartSerializer
|
||||
|
||||
|
||||
starred_parts = None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
@ -482,7 +482,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
|
||||
queryset = part_serializers.PartSerializer.prefetch_queryset(queryset)
|
||||
queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
|
||||
|
||||
@ -576,7 +576,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
if cat_id is None:
|
||||
# No category filtering if category is not specified
|
||||
pass
|
||||
|
||||
|
||||
else:
|
||||
# Category has been specified!
|
||||
if isNull(cat_id):
|
||||
@ -780,10 +780,10 @@ class BomList(generics.ListCreateAPIView):
|
||||
kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# Ensure the request context is passed through!
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
@ -867,7 +867,7 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
# Work out which lines have actually been validated
|
||||
pks = []
|
||||
|
||||
|
||||
for bom_item in queryset.all():
|
||||
if bom_item.is_line_valid:
|
||||
pks.append(bom_item.pk)
|
||||
@ -915,7 +915,7 @@ class BomItemValidate(generics.UpdateAPIView):
|
||||
valid = request.data.get('valid', False)
|
||||
|
||||
instance = self.get_object()
|
||||
|
||||
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@ -949,7 +949,7 @@ part_api_urls = [
|
||||
url(r'^sale-price/', include([
|
||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||
])),
|
||||
|
||||
|
||||
# Base URL for PartParameter API endpoints
|
||||
url(r'^parameter/', include([
|
||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
||||
|
@ -43,7 +43,7 @@ class PartConfig(AppConfig):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
|
||||
|
||||
if not os.path.exists(loc):
|
||||
logger.info("InvenTree: Generating thumbnail for Part '{p}'".format(p=part.name))
|
||||
try:
|
||||
|
@ -69,7 +69,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
for item in items:
|
||||
|
||||
item.level = str(int(level))
|
||||
|
||||
|
||||
# Avoid circular BOM references
|
||||
if item.pk in uids:
|
||||
continue
|
||||
@ -79,7 +79,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
if item.sub_part.assembly:
|
||||
if max_levels is None or level < max_levels:
|
||||
add_items(item.sub_part.bom_items.all().order_by('id'), level + 1)
|
||||
|
||||
|
||||
if cascade:
|
||||
# Cascading (multi-level) BOM
|
||||
|
||||
@ -124,7 +124,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
parameter_cols[name].update({b_idx: value})
|
||||
except KeyError:
|
||||
parameter_cols[name] = {b_idx: value}
|
||||
|
||||
|
||||
# Add parameter columns to dataset
|
||||
parameter_cols_ordered = OrderedDict(sorted(parameter_cols.items(), key=lambda x: x[0]))
|
||||
add_columns_to_dataset(parameter_cols_ordered, len(bom_items))
|
||||
@ -185,7 +185,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
# Filter manufacturer parts
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
|
||||
manufacturer_parts = manufacturer_parts.prefetch_related('supplier_parts')
|
||||
|
||||
|
||||
# Process manufacturer part
|
||||
for manufacturer_idx, manufacturer_part in enumerate(manufacturer_parts):
|
||||
|
||||
@ -250,7 +250,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# Filter supplier parts
|
||||
manufacturer_parts = ManufacturerPart.objects.filter(part__pk=b_part.pk)
|
||||
|
||||
|
||||
for idx, manufacturer_part in enumerate(manufacturer_parts):
|
||||
|
||||
if manufacturer_part:
|
||||
@ -295,7 +295,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
|
||||
# Filter supplier parts
|
||||
supplier_parts = SupplierPart.objects.filter(part__pk=b_part.pk)
|
||||
|
||||
|
||||
for idx, supplier_part in enumerate(supplier_parts):
|
||||
|
||||
if supplier_part.supplier:
|
||||
@ -326,7 +326,7 @@ def ExportBom(part, fmt='csv', cascade=False, max_levels=None, parameter_data=Fa
|
||||
filename = '{n}_BOM.{fmt}'.format(n=part.full_name, fmt=fmt)
|
||||
|
||||
return DownloadFile(data, filename)
|
||||
|
||||
|
||||
|
||||
class BomUploadManager:
|
||||
""" Class for managing an uploaded BOM file """
|
||||
@ -342,7 +342,7 @@ class BomUploadManager:
|
||||
'Part_IPN',
|
||||
'Part_ID',
|
||||
]
|
||||
|
||||
|
||||
# Fields which would be helpful but are not required
|
||||
OPTIONAL_HEADERS = [
|
||||
'Reference',
|
||||
@ -360,7 +360,7 @@ class BomUploadManager:
|
||||
|
||||
def __init__(self, bom_file):
|
||||
""" Initialize the BomUpload class with a user-uploaded file object """
|
||||
|
||||
|
||||
self.process(bom_file)
|
||||
|
||||
def process(self, bom_file):
|
||||
@ -387,7 +387,7 @@ class BomUploadManager:
|
||||
|
||||
def guess_header(self, header, threshold=80):
|
||||
""" Try to match a header (from the file) to a list of known headers
|
||||
|
||||
|
||||
Args:
|
||||
header - Header name to look for
|
||||
threshold - Match threshold for fuzzy search
|
||||
@ -421,7 +421,7 @@ class BomUploadManager:
|
||||
return matches[0]['header']
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def columns(self):
|
||||
""" Return a list of headers for the thingy """
|
||||
headers = []
|
||||
|
@ -82,7 +82,7 @@
|
||||
tree_id: 2
|
||||
lft: 1
|
||||
rght: 4
|
||||
|
||||
|
||||
- model: part.partcategory
|
||||
pk: 8
|
||||
fields:
|
||||
|
@ -95,11 +95,11 @@ class BomExportForm(forms.Form):
|
||||
parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM"))
|
||||
|
||||
stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM"))
|
||||
|
||||
|
||||
manufacturer_data = forms.BooleanField(label=_("Include Manufacturer Data"), required=False, initial=True, help_text=_("Include part manufacturer data in exported BOM"))
|
||||
|
||||
supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM"))
|
||||
|
||||
|
||||
def get_choices(self):
|
||||
""" BOM export format choices """
|
||||
|
||||
@ -324,7 +324,7 @@ class EditCategoryParameterTemplateForm(HelperForm):
|
||||
add_to_all_categories = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
help_text=_('Add parameter template to all categories'))
|
||||
|
||||
|
||||
class Meta:
|
||||
model = PartCategoryParameterTemplate
|
||||
fields = [
|
||||
|
@ -349,7 +349,7 @@ class Part(MPTTModel):
|
||||
|
||||
context['available'] = self.available_stock
|
||||
context['on_order'] = self.on_order
|
||||
|
||||
|
||||
context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity']
|
||||
context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity']
|
||||
|
||||
@ -434,7 +434,7 @@ class Part(MPTTModel):
|
||||
a) The parent part is the same as this one
|
||||
b) The parent part is used in the BOM for *this* part
|
||||
c) The parent part is used in the BOM for any child parts under this one
|
||||
|
||||
|
||||
Failing this check raises a ValidationError!
|
||||
|
||||
"""
|
||||
@ -506,7 +506,7 @@ class Part(MPTTModel):
|
||||
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None)
|
||||
|
||||
|
||||
# There are no matchin StockItem objects (skip further tests)
|
||||
if not stock.exists():
|
||||
return None
|
||||
@ -578,7 +578,7 @@ class Part(MPTTModel):
|
||||
|
||||
if self.IPN:
|
||||
elements.append(self.IPN)
|
||||
|
||||
|
||||
elements.append(self.name)
|
||||
|
||||
if self.revision:
|
||||
@ -663,7 +663,7 @@ class Part(MPTTModel):
|
||||
def clean(self):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
|
||||
|
||||
Update trackable status:
|
||||
If this part is trackable, and it is used in the BOM
|
||||
for a parent part which is *not* trackable,
|
||||
@ -946,7 +946,7 @@ class Part(MPTTModel):
|
||||
quantity = 0
|
||||
|
||||
for build in builds:
|
||||
|
||||
|
||||
bom_item = None
|
||||
|
||||
# List the bom lines required to make the build (including inherited ones!)
|
||||
@ -958,7 +958,7 @@ class Part(MPTTModel):
|
||||
build_quantity = build.quantity * bom_item.quantity
|
||||
|
||||
quantity += build_quantity
|
||||
|
||||
|
||||
return quantity
|
||||
|
||||
def requiring_sales_orders(self):
|
||||
@ -1008,7 +1008,7 @@ class Part(MPTTModel):
|
||||
def quantity_to_order(self):
|
||||
"""
|
||||
Return the quantity needing to be ordered for this part.
|
||||
|
||||
|
||||
Here, an "order" could be one of:
|
||||
- Build Order
|
||||
- Sales Order
|
||||
@ -1019,7 +1019,7 @@ class Part(MPTTModel):
|
||||
Required for orders = self.required_order_quantity()
|
||||
Currently on order = self.on_order
|
||||
Currently building = self.quantity_being_built
|
||||
|
||||
|
||||
"""
|
||||
|
||||
# Total requirement
|
||||
@ -1114,7 +1114,7 @@ class Part(MPTTModel):
|
||||
|
||||
if total is None:
|
||||
total = 0
|
||||
|
||||
|
||||
return max(total, 0)
|
||||
|
||||
@property
|
||||
@ -1238,7 +1238,7 @@ class Part(MPTTModel):
|
||||
@property
|
||||
def total_stock(self):
|
||||
""" Return the total stock quantity for this part.
|
||||
|
||||
|
||||
- Part may be stored in multiple locations
|
||||
- If this part is a "template" (variants exist) then these are counted too
|
||||
"""
|
||||
@ -1463,7 +1463,7 @@ class Part(MPTTModel):
|
||||
|
||||
# Start with a list of all parts designated as 'sub components'
|
||||
parts = Part.objects.filter(component=True)
|
||||
|
||||
|
||||
# Exclude this part
|
||||
parts = parts.exclude(id=self.id)
|
||||
|
||||
@ -1496,7 +1496,7 @@ class Part(MPTTModel):
|
||||
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True):
|
||||
""" Return a simplified pricing string for this part
|
||||
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier pricing (default = True)
|
||||
@ -1519,7 +1519,7 @@ class Part(MPTTModel):
|
||||
return "{a} - {b}".format(a=min_price, b=max_price)
|
||||
|
||||
def get_supplier_price_range(self, quantity=1):
|
||||
|
||||
|
||||
min_price = None
|
||||
max_price = None
|
||||
|
||||
@ -1586,7 +1586,7 @@ class Part(MPTTModel):
|
||||
return (min_price, max_price)
|
||||
|
||||
def get_price_range(self, quantity=1, buy=True, bom=True):
|
||||
|
||||
|
||||
""" Return the price range for this part. This price can be either:
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
@ -1683,7 +1683,7 @@ class Part(MPTTModel):
|
||||
|
||||
@transaction.atomic
|
||||
def copy_parameters_from(self, other, **kwargs):
|
||||
|
||||
|
||||
clear = kwargs.get('clear', True)
|
||||
|
||||
if clear:
|
||||
@ -1730,7 +1730,7 @@ class Part(MPTTModel):
|
||||
# Copy the parameters data
|
||||
if kwargs.get('parameters', True):
|
||||
self.copy_parameters_from(other)
|
||||
|
||||
|
||||
# Copy the fields that aren't available in the duplicate form
|
||||
self.salable = other.salable
|
||||
self.assembly = other.assembly
|
||||
@ -1760,7 +1760,7 @@ class Part(MPTTModel):
|
||||
tests = tests.filter(required=required)
|
||||
|
||||
return tests
|
||||
|
||||
|
||||
def getRequiredTests(self):
|
||||
# Return the tests which are required by this part
|
||||
return self.getTestTemplates(required=True)
|
||||
@ -1906,7 +1906,7 @@ class PartAttachment(InvenTreeAttachment):
|
||||
"""
|
||||
Model for storing file attachments against a Part object
|
||||
"""
|
||||
|
||||
|
||||
def getSubdir(self):
|
||||
return os.path.join("part_files", str(self.part.id))
|
||||
|
||||
@ -2265,7 +2265,7 @@ class BomItem(models.Model):
|
||||
|
||||
def validate_hash(self, valid=True):
|
||||
""" Mark this item as 'valid' (store the checksum hash).
|
||||
|
||||
|
||||
Args:
|
||||
valid: If true, validate the hash, otherwise invalidate it (default = True)
|
||||
"""
|
||||
@ -2303,7 +2303,7 @@ class BomItem(models.Model):
|
||||
# Check for circular BOM references
|
||||
if self.sub_part:
|
||||
self.sub_part.checkAddToBOM(self.part)
|
||||
|
||||
|
||||
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
|
||||
if self.sub_part.trackable:
|
||||
if not self.quantity == int(self.quantity):
|
||||
@ -2339,7 +2339,7 @@ class BomItem(models.Model):
|
||||
"""
|
||||
|
||||
query = self.sub_part.stock_items.all()
|
||||
|
||||
|
||||
query = query.prefetch_related([
|
||||
'sub_part__stock_items',
|
||||
])
|
||||
@ -2396,7 +2396,7 @@ class BomItem(models.Model):
|
||||
def get_required_quantity(self, build_quantity):
|
||||
""" Calculate the required part quantity, based on the supplier build_quantity.
|
||||
Includes overage estimate in the returned value.
|
||||
|
||||
|
||||
Args:
|
||||
build_quantity: Number of parts to build
|
||||
|
||||
|
@ -134,7 +134,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for Part (brief detail) """
|
||||
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
|
||||
|
||||
stock = serializers.FloatField(source='total_stock')
|
||||
|
||||
class Meta:
|
||||
@ -232,7 +232,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Filter to limit orders to "open"
|
||||
order_filter = Q(
|
||||
order__status__in=PurchaseOrderStatus.OPEN
|
||||
@ -259,7 +259,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
output_field=models.DecimalField(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
return queryset
|
||||
|
||||
def get_starred(self, part):
|
||||
@ -358,7 +358,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = serializers.FloatField()
|
||||
|
||||
part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(assembly=True))
|
||||
|
||||
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
|
||||
sub_part = serializers.PrimaryKeyRelatedField(queryset=Part.objects.filter(component=True))
|
||||
|
@ -46,5 +46,5 @@
|
||||
part: {{ part.id }},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
@ -198,7 +198,7 @@
|
||||
})
|
||||
|
||||
$("#part-export").click(function() {
|
||||
|
||||
|
||||
var url = "{% url 'part-export' %}?category={{ category.id }}";
|
||||
|
||||
location.href = url;
|
||||
|
@ -20,20 +20,20 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-font'></span></td>
|
||||
<td><strong>{% trans "Part name" %}</strong></td>
|
||||
<td>{{ part.name }}</td>
|
||||
<td>{{ part.name }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% if part.IPN %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><strong>{% trans "IPN" %}</strong></td>
|
||||
<td>{{ part.IPN }}</td>
|
||||
<td>{{ part.IPN }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.revision %}
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
<td><strong>{% trans "Revision" %}</strong></td>
|
||||
<td>{{ part.revision }}</td>
|
||||
<td>{{ part.revision }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.trackable %}
|
||||
@ -42,7 +42,7 @@
|
||||
<td><strong>{% trans "Latest Serial Number" %}</strong></td>
|
||||
<td>
|
||||
{% if part.getLatestSerialNumber %}
|
||||
{{ part.getLatestSerialNumber }}
|
||||
{{ part.getLatestSerialNumber }}{% include "clip.html"%}
|
||||
{% else %}
|
||||
<em>{% trans "No serial numbers recorded" %}</em>
|
||||
{% endif %}
|
||||
@ -52,7 +52,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info-circle'></span></td>
|
||||
<td><strong>{% trans "Description" %}</strong></td>
|
||||
<td>{{ part.description }}</td>
|
||||
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% if part.variant_of %}
|
||||
<tr>
|
||||
@ -96,7 +96,7 @@
|
||||
<td></td>
|
||||
<td><strong>{% trans "Default Supplier" %}</strong></td>
|
||||
<td><a href="{% url 'supplier-part-detail' part.default_supplier.id %}">
|
||||
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}
|
||||
{{ part.default_supplier.supplier.name }} | {{ part.default_supplier.SKU }}{% include "clip.html"%}
|
||||
</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@ -262,5 +262,5 @@
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
@ -58,7 +58,7 @@
|
||||
});
|
||||
|
||||
$("#manufacturer-part-delete").click(function() {
|
||||
|
||||
|
||||
var selections = $("#manufacturer-table").bootstrapTable("getSelections");
|
||||
|
||||
var parts = [];
|
||||
|
@ -20,12 +20,12 @@
|
||||
{% if editing %}
|
||||
<form method='POST'>
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
{{ form }}
|
||||
<hr>
|
||||
|
||||
<button type="submit" class='btn btn-default'>{% trans "Save" %}</button>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
{{ form.media }}
|
||||
|
@ -49,7 +49,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
@ -151,7 +151,7 @@
|
||||
<td>{% decimal allocated %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
@ -177,11 +177,11 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% block heading %}
|
||||
@ -272,7 +272,7 @@
|
||||
function onSelectImage(response) {
|
||||
// Callback when the image-selection modal form is displayed
|
||||
// Populate the form with image data (requested via AJAX)
|
||||
|
||||
|
||||
$("#modal-form").find("#image-select-table").bootstrapTable({
|
||||
pagination: true,
|
||||
pageSize: 25,
|
||||
@ -301,9 +301,9 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
{% if roles.part.change %}
|
||||
|
||||
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
{% if allow_download %}
|
||||
$("#part-image-url").click(function() {
|
||||
|
@ -11,7 +11,7 @@
|
||||
<form method='post' action='' class='js-modal-form' enctype='multipart/form-data'>
|
||||
{% csrf_token %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<input id='image-input' name='image' type='hidden' value="{{ part.image }}">
|
||||
|
||||
<table id='image-select-table' class='table table-striped table-condensed table-img-grid'>
|
||||
|
@ -4,10 +4,10 @@
|
||||
{% block form %}
|
||||
<form method="post" action='' class='js-modal-form' enctype="multipart/form-data">
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
<label class='control-label'>Parts</label>
|
||||
<p class='help-block'>{% trans "Set category for the following parts" %}</p>
|
||||
|
||||
|
||||
<table class='table table-striped'>
|
||||
<tr>
|
||||
<th>{% trans "Part" %}</th>
|
||||
@ -36,8 +36,8 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
@ -11,7 +11,7 @@
|
||||
{% block category_content %}
|
||||
|
||||
<div class='panel panel-default panel-inventree'>
|
||||
|
||||
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Subcategories" %}</h4>
|
||||
</div>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user