mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
745b745ce4
@ -30,6 +30,8 @@ class InfoView(AjaxView):
|
|||||||
Use to confirm that the server is running, etc.
|
Use to confirm that the server is running, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
@ -17,3 +17,43 @@ def status_codes(request):
|
|||||||
'BuildStatus': BuildStatus,
|
'BuildStatus': BuildStatus,
|
||||||
'StockStatus': StockStatus,
|
'StockStatus': StockStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- roles.part.view
|
||||||
|
- roles.build.delete
|
||||||
|
|
||||||
|
Each value will return a boolean True / False
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
roles = {
|
||||||
|
}
|
||||||
|
|
||||||
|
for group in user.groups.all():
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
|
||||||
|
# Ensure the role name is in the dict
|
||||||
|
if rule.name not in roles:
|
||||||
|
roles[rule.name] = {
|
||||||
|
'view': user.is_superuser,
|
||||||
|
'add': user.is_superuser,
|
||||||
|
'change': user.is_superuser,
|
||||||
|
'delete': user.is_superuser
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roles are additive across groups
|
||||||
|
roles[rule.name]['view'] |= rule.can_view
|
||||||
|
roles[rule.name]['add'] |= rule.can_add
|
||||||
|
roles[rule.name]['change'] |= rule.can_change
|
||||||
|
roles[rule.name]['delete'] |= rule.can_delete
|
||||||
|
|
||||||
|
return {'roles': roles}
|
||||||
|
@ -15,6 +15,8 @@ from django.http import StreamingHttpResponse
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
import InvenTree.version
|
import InvenTree.version
|
||||||
|
|
||||||
from .settings import MEDIA_URL, STATIC_URL
|
from .settings import MEDIA_URL, STATIC_URL
|
||||||
@ -441,3 +443,21 @@ def validateFilterString(value):
|
|||||||
results[k] = v
|
results[k] = v
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def addUserPermissions(user, permissions):
|
||||||
|
"""
|
||||||
|
Shortcut function for adding multiple permissions to a user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for permission in permissions:
|
||||||
|
addUserPermission(user, permission)
|
||||||
|
@ -138,6 +138,7 @@ INSTALLED_APPS = [
|
|||||||
'part.apps.PartConfig',
|
'part.apps.PartConfig',
|
||||||
'report.apps.ReportConfig',
|
'report.apps.ReportConfig',
|
||||||
'stock.apps.StockConfig',
|
'stock.apps.StockConfig',
|
||||||
|
'users.apps.UsersConfig',
|
||||||
|
|
||||||
# Third part add-ons
|
# Third part add-ons
|
||||||
'django_filters', # Extended filter functionality
|
'django_filters', # Extended filter functionality
|
||||||
@ -153,6 +154,7 @@ INSTALLED_APPS = [
|
|||||||
'markdownx', # Markdown editing
|
'markdownx', # Markdown editing
|
||||||
'markdownify', # Markdown template rendering
|
'markdownify', # Markdown template rendering
|
||||||
'django_tex', # LaTeX output
|
'django_tex', # LaTeX output
|
||||||
|
'django_admin_shell', # Python shell for the admin interface
|
||||||
]
|
]
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
@ -208,6 +210,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'InvenTree.context.status_codes',
|
'InvenTree.context.status_codes',
|
||||||
|
'InvenTree.context.user_roles',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -229,6 +232,10 @@ REST_FRAMEWORK = {
|
|||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
'rest_framework.authentication.TokenAuthentication',
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
),
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
'rest_framework.permissions.DjangoModelPermissions',
|
||||||
|
),
|
||||||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'
|
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
vendored
Normal file
13
InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
/**
|
||||||
|
* @author: Dennis Hernández
|
||||||
|
* @webSite: http://djhvscf.github.io/Blog
|
||||||
|
* @version: v2.1.1
|
||||||
|
*/
|
||||||
|
.no-filter-control {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
margin: 0 2px 2px 2px;
|
||||||
|
}
|
3021
InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
vendored
Normal file
3021
InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2361
InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
Normal file
2361
InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
|
|||||||
var options = opts || {};
|
var options = opts || {};
|
||||||
|
|
||||||
value = parseFloat(value);
|
value = parseFloat(value);
|
||||||
maximum = parseFloat(maximum);
|
|
||||||
|
|
||||||
var percent = parseInt(value / maximum * 100);
|
var percent = 100;
|
||||||
|
|
||||||
|
// Prevent div-by-zero or null value
|
||||||
|
if (maximum && maximum > 0) {
|
||||||
|
maximum = parseFloat(maximum);
|
||||||
|
percent = parseInt(value / maximum * 100);
|
||||||
|
}
|
||||||
|
|
||||||
if (percent > 100) {
|
if (percent > 100) {
|
||||||
percent = 100;
|
percent = 100;
|
||||||
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
|
|||||||
|
|
||||||
var extraclass = '';
|
var extraclass = '';
|
||||||
|
|
||||||
if (value > maximum) {
|
if (maximum) {
|
||||||
|
// TODO - Special color?
|
||||||
|
}
|
||||||
|
else if (value > maximum) {
|
||||||
extraclass='progress-bar-over';
|
extraclass='progress-bar-over';
|
||||||
} else if (value < maximum) {
|
} else if (value < maximum) {
|
||||||
extraclass = 'progress-bar-under';
|
extraclass = 'progress-bar-under';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var text = value;
|
||||||
|
|
||||||
|
if (maximum) {
|
||||||
|
text += ' / ';
|
||||||
|
text += maximum;
|
||||||
|
}
|
||||||
|
|
||||||
var id = options.id || 'progress-bar';
|
var id = options.id || 'progress-bar';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id='${id}' class='progress'>
|
<div id='${id}' class='progress'>
|
||||||
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
|
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
|
||||||
<div class='progress-value'>${value} / ${maximum}</div>
|
<div class='progress-value'>${text}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
|
|||||||
options.pagination = true;
|
options.pagination = true;
|
||||||
options.pageSize = inventreeLoad(varName, 25);
|
options.pageSize = inventreeLoad(varName, 25);
|
||||||
options.pageList = [25, 50, 100, 250, 'all'];
|
options.pageList = [25, 50, 100, 250, 'all'];
|
||||||
|
|
||||||
options.rememberOrder = true;
|
options.rememberOrder = true;
|
||||||
|
|
||||||
|
if (options.sortable == null) {
|
||||||
options.sortable = true;
|
options.sortable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.search == null) {
|
||||||
options.search = true;
|
options.search = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.showColumns == null) {
|
||||||
options.showColumns = true;
|
options.showColumns = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Callback to save pagination data
|
// Callback to save pagination data
|
||||||
options.onPageChange = function(number, size) {
|
options.onPageChange = function(number, size) {
|
||||||
|
@ -117,6 +117,7 @@ urlpatterns = [
|
|||||||
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||||
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
|
||||||
|
|
||||||
|
url(r'^admin/shell/', include('django_admin_shell.urls')),
|
||||||
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
url(r'^admin/', admin.site.urls, name='inventree-admin'),
|
||||||
|
|
||||||
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),
|
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),
|
||||||
|
@ -22,6 +22,7 @@ from django.views.generic.base import TemplateView
|
|||||||
from part.models import Part, PartCategory
|
from part.models import Part, PartCategory
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
from common.models import InvenTreeSetting, ColorTheme
|
from common.models import InvenTreeSetting, ColorTheme
|
||||||
|
from users.models import check_user_role, RuleSet
|
||||||
|
|
||||||
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
|
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
|
||||||
from .helpers import str2bool
|
from .helpers import str2bool
|
||||||
@ -107,31 +108,72 @@ class TreeSerializer(views.APIView):
|
|||||||
return JsonResponse(response, safe=False)
|
return JsonResponse(response, safe=False)
|
||||||
|
|
||||||
|
|
||||||
class AjaxMixin(PermissionRequiredMixin):
|
class InvenTreeRoleMixin(PermissionRequiredMixin):
|
||||||
|
"""
|
||||||
|
Permission class based on user roles, not user 'permissions'.
|
||||||
|
|
||||||
|
To specify which role is required for the mixin,
|
||||||
|
set the class attribute 'role_required' to something like the following:
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
role_required = [
|
||||||
|
'part.change',
|
||||||
|
'build.add',
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# By default, no roles are required
|
||||||
|
# Roles must be specified
|
||||||
|
role_required = None
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""
|
||||||
|
Determine if the current user
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles_required = []
|
||||||
|
|
||||||
|
if type(self.role_required) is str:
|
||||||
|
roles_required.append(self.role_required)
|
||||||
|
elif type(self.role_required) in [list, tuple]:
|
||||||
|
roles_required = self.role_required
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# Superuser can have any permissions they desire
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for required in roles_required:
|
||||||
|
|
||||||
|
(role, permission) = required.split('.')
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Return False if the user does not have *any* of the required roles
|
||||||
|
if not check_user_role(user, role, permission):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# We did not fail any required checks
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AjaxMixin(InvenTreeRoleMixin):
|
||||||
""" AjaxMixin provides basic functionality for rendering a Django form to JSON.
|
""" AjaxMixin provides basic functionality for rendering a Django form to JSON.
|
||||||
Handles jsonResponse rendering, and adds extra data for the modal forms to process
|
Handles jsonResponse rendering, and adds extra data for the modal forms to process
|
||||||
on the client side.
|
on the client side.
|
||||||
|
|
||||||
Any view which inherits the AjaxMixin will need
|
Any view which inherits the AjaxMixin will need
|
||||||
correct permissions set using the 'permission_required' attribute
|
correct permissions set using the 'role_required' attribute
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# By default, allow *any* permissions
|
# By default, allow *any* role
|
||||||
permission_required = '*'
|
role_required = None
|
||||||
|
|
||||||
def has_permission(self):
|
|
||||||
"""
|
|
||||||
Override the default behaviour of has_permission from PermissionRequiredMixin.
|
|
||||||
|
|
||||||
Basically, if permission_required attribute = '*',
|
|
||||||
no permissions are actually required!
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.permission_required == '*':
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return super().has_permission()
|
|
||||||
|
|
||||||
# By default, point to the modal_form template
|
# By default, point to the modal_form template
|
||||||
# (this can be overridden by a child class)
|
# (this can be overridden by a child class)
|
||||||
|
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import filters
|
from rest_framework import filters
|
||||||
from rest_framework import generics, permissions
|
from rest_framework import generics
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
|
||||||
@ -28,10 +28,6 @@ class BuildList(generics.ListCreateAPIView):
|
|||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = BuildSerializer
|
serializer_class = BuildSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -99,10 +95,6 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
|
|||||||
queryset = Build.objects.all()
|
queryset = Build.objects.all()
|
||||||
serializer_class = BuildSerializer
|
serializer_class = BuildSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BuildItemList(generics.ListCreateAPIView):
|
class BuildItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of BuildItem objects
|
""" API endpoint for accessing a list of BuildItem objects
|
||||||
@ -137,10 +129,6 @@ class BuildItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
|
@ -35,25 +35,27 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<hr>
|
<hr>
|
||||||
<h4>
|
<h4>
|
||||||
{{ build.quantity }} x {{ build.part.full_name }}
|
{{ build.quantity }} x {{ build.part.full_name }}
|
||||||
{% if user.is_staff and perms.build.change_build %}
|
{% if user.is_staff and roles.build.change %}
|
||||||
<a href="{% url 'admin:build_build_change' build.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:build_build_change' build.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
<div class='btn-row'>
|
<div class='btn-row'>
|
||||||
<div class='btn-group action-buttons'>
|
<div class='btn-group action-buttons'>
|
||||||
<button type='button' class='btn btn-default' id='build-edit' title='Edit Build'>
|
{% if roles.build.change %}
|
||||||
|
<button type='button' class='btn btn-default' id='build-edit' title='{% trans "Edit Build" %}'>
|
||||||
<span class='fas fa-edit icon-green'/>
|
<span class='fas fa-edit icon-green'/>
|
||||||
</button>
|
</button>
|
||||||
{% if build.is_active %}
|
{% if build.is_active %}
|
||||||
<button type='button' class='btn btn-default' id='build-complete' title="Complete Build">
|
<button type='button' class='btn btn-default' id='build-complete' title='{% trans "Complete Build" %}'>
|
||||||
<span class='fas fa-tools'/>
|
<span class='fas fa-tools'/>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
|
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='{% trans "Cancel Build" %}'>
|
||||||
<span class='fas fa-times-circle icon-red'/>
|
<span class='fas fa-times-circle icon-red'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if build.status == BuildStatus.CANCELLED %}
|
{% endif %}
|
||||||
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
|
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||||
|
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='{% trans "Delete Build" %}'>
|
||||||
<span class='fas fa-trash-alt icon-red'/>
|
<span class='fas fa-trash-alt icon-red'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -4,6 +4,7 @@ from __future__ import unicode_literals
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -30,6 +31,20 @@ class BuildTestSimple(TestCase):
|
|||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
self.user = User.objects.get(username='testuser')
|
self.user = User.objects.get(username='testuser')
|
||||||
|
|
||||||
|
g = Group.objects.create(name='builders')
|
||||||
|
self.user.groups.add(g)
|
||||||
|
|
||||||
|
for rule in g.rule_sets.all():
|
||||||
|
if rule.name == 'build':
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
g.save()
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
def test_build_objects(self):
|
def test_build_objects(self):
|
||||||
@ -94,7 +109,20 @@ class TestBuildAPI(APITestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
g = Group.objects.create(name='builders')
|
||||||
|
user.groups.add(g)
|
||||||
|
|
||||||
|
for rule in g.rule_sets.all():
|
||||||
|
if rule.name == 'build':
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
g.save()
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
@ -131,7 +159,20 @@ class TestBuildViews(TestCase):
|
|||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('username', 'user@email.com', 'password')
|
user = User.objects.create_user('username', 'user@email.com', 'password')
|
||||||
|
|
||||||
|
g = Group.objects.create(name='builders')
|
||||||
|
user.groups.add(g)
|
||||||
|
|
||||||
|
for rule in g.rule_sets.all():
|
||||||
|
if rule.name == 'build':
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
g.save()
|
||||||
|
|
||||||
self.client.login(username='username', password='password')
|
self.client.login(username='username', password='password')
|
||||||
|
|
||||||
|
@ -17,16 +17,18 @@ from . import forms
|
|||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
|
|
||||||
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
|
||||||
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
from InvenTree.helpers import str2bool, ExtractSerialNumbers
|
from InvenTree.helpers import str2bool, ExtractSerialNumbers
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
|
|
||||||
|
|
||||||
class BuildIndex(ListView):
|
class BuildIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" View for displaying list of Builds
|
""" View for displaying list of Builds
|
||||||
"""
|
"""
|
||||||
model = Build
|
model = Build
|
||||||
template_name = 'build/index.html'
|
template_name = 'build/index.html'
|
||||||
context_object_name = 'builds'
|
context_object_name = 'builds'
|
||||||
|
role_required = 'build.view'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Return all Build objects (order by date, newest first) """
|
""" Return all Build objects (order by date, newest first) """
|
||||||
@ -56,6 +58,7 @@ class BuildCancel(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Cancel Build')
|
ajax_form_title = _('Cancel Build')
|
||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
form_class = forms.CancelBuildForm
|
form_class = forms.CancelBuildForm
|
||||||
|
role_required = 'build.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Handle POST request. Mark the build status as CANCELLED """
|
""" Handle POST request. Mark the build status as CANCELLED """
|
||||||
@ -94,6 +97,7 @@ class BuildAutoAllocate(AjaxUpdateView):
|
|||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
ajax_form_title = _('Allocate Stock')
|
ajax_form_title = _('Allocate Stock')
|
||||||
ajax_template_name = 'build/auto_allocate.html'
|
ajax_template_name = 'build/auto_allocate.html'
|
||||||
|
role_required = 'build.change'
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
""" Get the context data for form rendering. """
|
""" Get the context data for form rendering. """
|
||||||
@ -147,6 +151,7 @@ class BuildUnallocate(AjaxUpdateView):
|
|||||||
form_class = forms.ConfirmBuildForm
|
form_class = forms.ConfirmBuildForm
|
||||||
ajax_form_title = _("Unallocate Stock")
|
ajax_form_title = _("Unallocate Stock")
|
||||||
ajax_template_name = "build/unallocate.html"
|
ajax_template_name = "build/unallocate.html"
|
||||||
|
form_required = 'build.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -184,6 +189,7 @@ class BuildComplete(AjaxUpdateView):
|
|||||||
context_object_name = "build"
|
context_object_name = "build"
|
||||||
ajax_form_title = _("Complete Build")
|
ajax_form_title = _("Complete Build")
|
||||||
ajax_template_name = "build/complete.html"
|
ajax_template_name = "build/complete.html"
|
||||||
|
role_required = 'build.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
""" Get the form object.
|
""" Get the form object.
|
||||||
@ -325,6 +331,7 @@ class BuildNotes(UpdateView):
|
|||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
template_name = 'build/notes.html'
|
template_name = 'build/notes.html'
|
||||||
model = Build
|
model = Build
|
||||||
|
role_required = 'build.view'
|
||||||
|
|
||||||
fields = ['notes']
|
fields = ['notes']
|
||||||
|
|
||||||
@ -342,9 +349,11 @@ class BuildNotes(UpdateView):
|
|||||||
|
|
||||||
class BuildDetail(DetailView):
|
class BuildDetail(DetailView):
|
||||||
""" Detail view of a single Build object. """
|
""" Detail view of a single Build object. """
|
||||||
|
|
||||||
model = Build
|
model = Build
|
||||||
template_name = 'build/detail.html'
|
template_name = 'build/detail.html'
|
||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
|
role_required = 'build.view'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
@ -363,6 +372,7 @@ class BuildAllocate(DetailView):
|
|||||||
model = Build
|
model = Build
|
||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
template_name = 'build/allocate.html'
|
template_name = 'build/allocate.html'
|
||||||
|
role_required = ['build.change']
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
""" Provide extra context information for the Build allocation page """
|
""" Provide extra context information for the Build allocation page """
|
||||||
@ -392,6 +402,7 @@ class BuildCreate(AjaxCreateView):
|
|||||||
form_class = forms.EditBuildForm
|
form_class = forms.EditBuildForm
|
||||||
ajax_form_title = _('Start new Build')
|
ajax_form_title = _('Start new Build')
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
|
role_required = 'build.add'
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
""" Get initial parameters for Build creation.
|
""" Get initial parameters for Build creation.
|
||||||
@ -427,6 +438,7 @@ class BuildUpdate(AjaxUpdateView):
|
|||||||
context_object_name = 'build'
|
context_object_name = 'build'
|
||||||
ajax_form_title = _('Edit Build Details')
|
ajax_form_title = _('Edit Build Details')
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
|
role_required = 'build.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -440,6 +452,7 @@ class BuildDelete(AjaxDeleteView):
|
|||||||
model = Build
|
model = Build
|
||||||
ajax_template_name = 'build/delete_build.html'
|
ajax_template_name = 'build/delete_build.html'
|
||||||
ajax_form_title = _('Delete Build')
|
ajax_form_title = _('Delete Build')
|
||||||
|
role_required = 'build.delete'
|
||||||
|
|
||||||
|
|
||||||
class BuildItemDelete(AjaxDeleteView):
|
class BuildItemDelete(AjaxDeleteView):
|
||||||
@ -451,6 +464,7 @@ class BuildItemDelete(AjaxDeleteView):
|
|||||||
ajax_template_name = 'build/delete_build_item.html'
|
ajax_template_name = 'build/delete_build_item.html'
|
||||||
ajax_form_title = _('Unallocate Stock')
|
ajax_form_title = _('Unallocate Stock')
|
||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
|
role_required = 'build.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -465,6 +479,7 @@ class BuildItemCreate(AjaxCreateView):
|
|||||||
form_class = forms.EditBuildItemForm
|
form_class = forms.EditBuildItemForm
|
||||||
ajax_template_name = 'build/create_build_item.html'
|
ajax_template_name = 'build/create_build_item.html'
|
||||||
ajax_form_title = _('Allocate new Part')
|
ajax_form_title = _('Allocate new Part')
|
||||||
|
role_required = 'build.add'
|
||||||
|
|
||||||
part = None
|
part = None
|
||||||
available_stock = None
|
available_stock = None
|
||||||
@ -618,6 +633,7 @@ class BuildItemEdit(AjaxUpdateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
form_class = forms.EditBuildItemForm
|
form_class = forms.EditBuildItemForm
|
||||||
ajax_form_title = _('Edit Stock Allocation')
|
ajax_form_title = _('Edit Stock Allocation')
|
||||||
|
role_required = 'build.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
|
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import filters
|
from rest_framework import filters
|
||||||
from rest_framework import generics, permissions
|
from rest_framework import generics
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@ -40,10 +40,6 @@ class CompanyList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -82,10 +78,6 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartList(generics.ListCreateAPIView):
|
class SupplierPartList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for list view of SupplierPart object
|
""" API endpoint for list view of SupplierPart object
|
||||||
@ -170,10 +162,6 @@ class SupplierPartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
serializer_class = SupplierPartSerializer
|
serializer_class = SupplierPartSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -202,7 +190,6 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = SupplierPart.objects.all()
|
queryset = SupplierPart.objects.all()
|
||||||
serializer_class = SupplierPartSerializer
|
serializer_class = SupplierPartSerializer
|
||||||
permission_classes = (permissions.IsAuthenticated,)
|
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
]
|
]
|
||||||
@ -218,10 +205,6 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
|
|||||||
queryset = SupplierPriceBreak.objects.all()
|
queryset = SupplierPriceBreak.objects.all()
|
||||||
serializer_class = SupplierPriceBreakSerializer
|
serializer_class = SupplierPriceBreakSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
|
@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
|
|||||||
company.image = None
|
company.image = None
|
||||||
company.save()
|
company.save()
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
print("Could not generate Company thumbnails")
|
# Getting here probably meant the database was in test mode
|
||||||
|
pass
|
||||||
|
@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
|
|||||||
# Exit if there are no SupplierPart objects
|
# Exit if there are no SupplierPart objects
|
||||||
# This crucial otherwise the unit test suite fails!
|
# This crucial otherwise the unit test suite fails!
|
||||||
if SupplierPart.objects.count() == 0:
|
if SupplierPart.objects.count() == 0:
|
||||||
print("No SupplierPart objects - skipping")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
print("Reversing migration for manufacturer association")
|
print("Reversing migration for manufacturer association")
|
||||||
@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
|
|||||||
# Exit if there are no SupplierPart objects
|
# Exit if there are no SupplierPart objects
|
||||||
# This crucial otherwise the unit test suite fails!
|
# This crucial otherwise the unit test suite fails!
|
||||||
if SupplierPart.objects.count() == 0:
|
if SupplierPart.objects.count() == 0:
|
||||||
print("No SupplierPart objects - skipping")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Link a 'manufacturer_name' to a 'Company'
|
# Link a 'manufacturer_name' to a 'Company'
|
||||||
|
@ -23,7 +23,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
|
|||||||
<hr>
|
<hr>
|
||||||
<h4>
|
<h4>
|
||||||
{{ company.name }}
|
{{ company.name }}
|
||||||
{% if user.is_staff and perms.company.change_company %}
|
{% if user.is_staff and roles.company.change %}
|
||||||
<a href="{% url 'admin:company_company_change' company.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:company_company_change' company.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
|
@ -3,6 +3,8 @@ from rest_framework import status
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from InvenTree.helpers import addUserPermissions
|
||||||
|
|
||||||
from .models import Company
|
from .models import Company
|
||||||
|
|
||||||
|
|
||||||
@ -14,7 +16,16 @@ class CompanyTest(APITestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
perms = [
|
||||||
|
'view_company',
|
||||||
|
'change_company',
|
||||||
|
'add_company',
|
||||||
|
]
|
||||||
|
|
||||||
|
addUserPermissions(self.user, perms)
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
|
Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@ JSON API for the Order app
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework import generics, permissions
|
from rest_framework import generics
|
||||||
from rest_framework import filters
|
from rest_framework import filters
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
@ -109,10 +109,6 @@ class POList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -162,10 +158,6 @@ class PODetail(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class POLineItemList(generics.ListCreateAPIView):
|
class POLineItemList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of POLineItem objects
|
""" API endpoint for accessing a list of POLineItem objects
|
||||||
@ -188,10 +180,6 @@ class POLineItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return self.serializer_class(*args, **kwargs)
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
]
|
]
|
||||||
@ -208,10 +196,6 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView):
|
|||||||
queryset = PurchaseOrderLineItem
|
queryset = PurchaseOrderLineItem
|
||||||
serializer_class = POLineItemSerializer
|
serializer_class = POLineItemSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
@ -300,10 +284,6 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -351,8 +331,6 @@ class SODetail(generics.RetrieveUpdateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
|
|
||||||
class SOLineItemList(generics.ListCreateAPIView):
|
class SOLineItemList(generics.ListCreateAPIView):
|
||||||
"""
|
"""
|
||||||
@ -398,8 +376,6 @@ class SOLineItemList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend]
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
|
||||||
filter_fields = [
|
filter_fields = [
|
||||||
@ -414,8 +390,6 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
|
|||||||
queryset = SalesOrderLineItem.objects.all()
|
queryset = SalesOrderLineItem.objects.all()
|
||||||
serializer_class = SOLineItemSerializer
|
serializer_class = SOLineItemSerializer
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
|
|
||||||
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
|
@ -24,7 +24,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<hr>
|
<hr>
|
||||||
<h4>
|
<h4>
|
||||||
{{ order }}
|
{{ order }}
|
||||||
{% if user.is_staff and perms.order.change_purchaseorder %}
|
{% if user.is_staff and roles.purchase_order.change %}
|
||||||
<a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
@ -32,29 +32,31 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<p>
|
<p>
|
||||||
<div class='btn-row'>
|
<div class='btn-row'>
|
||||||
<div class='btn-group action-buttons'>
|
<div class='btn-group action-buttons'>
|
||||||
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
|
{% if roles.purchase_order.change %}
|
||||||
|
<button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
|
||||||
<span class='fas fa-edit icon-green'></span>
|
<span class='fas fa-edit icon-green'></span>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default' id='export-order' title='Export order to file'>
|
|
||||||
<span class='fas fa-file-download'></span>
|
|
||||||
</button>
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
{% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
|
||||||
<button type='button' class='btn btn-default' id='place-order' title='Place order'>
|
<button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'>
|
||||||
<span class='fas fa-paper-plane icon-blue'></span>
|
<span class='fas fa-paper-plane icon-blue'></span>
|
||||||
</button>
|
</button>
|
||||||
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
{% elif order.status == PurchaseOrderStatus.PLACED %}
|
||||||
<button type='button' class='btn btn-default' id='receive-order' title='Receive items'>
|
<button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
|
||||||
<span class='fas fa-clipboard-check'></span>
|
<span class='fas fa-clipboard-check'></span>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default' id='complete-order' title='Mark order as complete'>
|
<button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
|
||||||
<span class='fas fa-check-circle'></span>
|
<span class='fas fa-check-circle'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
|
{% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
|
||||||
<button type='button' class='btn btn-default' id='cancel-order' title='Cancel order'>
|
<button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
|
||||||
<span class='fas fa-times-circle icon-red'></span>
|
<span class='fas fa-times-circle icon-red'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
|
||||||
|
<span class='fas fa-file-download'></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
|
@ -28,9 +28,11 @@
|
|||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
<h4>{% trans "Order Notes" %}</h4>
|
<h4>{% trans "Order Notes" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
{% if roles.purchase_order.change %}
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
|
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class='panel panel-default'>
|
<div class='panel panel-default'>
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
{% include "attachment_table.html" with attachments=order.attachments.all %}
|
{% include "attachment_table.html" with attachments=order.attachments.all %}
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
<div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
|
||||||
<button type='button' class='btn btn-default' id='new-po-line'>{% trans "Add Line Item" %}</button>
|
<button type='button' class='btn btn-default' id='new-po-line'>{% trans "Add Line Item" %}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -209,12 +209,12 @@ $("#po-table").inventreeTable({
|
|||||||
|
|
||||||
var pk = row.pk;
|
var pk = row.pk;
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PENDING %}
|
{% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.delete %}
|
||||||
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
|
||||||
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if order.status == PurchaseOrderStatus.PLACED %}
|
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
|
||||||
if (row.received < row.quantity) {
|
if (row.received < row.quantity) {
|
||||||
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,9 @@ InvenTree | {% trans "Purchase Orders" %}
|
|||||||
|
|
||||||
<div id='table-buttons'>
|
<div id='table-buttons'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
|
{% if roles.purchase_order.add %}
|
||||||
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
|
<button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
|
||||||
|
{% endif %}
|
||||||
<div class='filter-list' id='filter-list-purchaseorder'>
|
<div class='filter-list' id='filter-list-purchaseorder'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,19 +34,17 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<hr>
|
<hr>
|
||||||
<h4>
|
<h4>
|
||||||
{{ order }}
|
{{ order }}
|
||||||
{% if user.is_staff and perms.order.change_salesorder %}
|
{% if user.is_staff and roles.sales_order.change %}
|
||||||
<a href="{% url 'admin:order_salesorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:order_salesorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
<p>{{ order.description }}</p>
|
<p>{{ order.description }}</p>
|
||||||
<div class='btn-row'>
|
<div class='btn-row'>
|
||||||
<div class='btn-group action-buttons'>
|
<div class='btn-group action-buttons'>
|
||||||
|
{% if roles.sales_order.change %}
|
||||||
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
|
<button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
|
||||||
<span class='fas fa-edit icon-green'></span>
|
<span class='fas fa-edit icon-green'></span>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
|
|
||||||
<span class='fas fa-clipboard-list'></span>
|
|
||||||
</button>
|
|
||||||
{% if order.status == SalesOrderStatus.PENDING %}
|
{% if order.status == SalesOrderStatus.PENDING %}
|
||||||
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
|
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
|
||||||
<span class='fas fa-paper-plane icon-blue'></span>
|
<span class='fas fa-paper-plane icon-blue'></span>
|
||||||
@ -55,6 +53,10 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<span class='fas fa-times-circle icon-red'></span>
|
<span class='fas fa-times-circle icon-red'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
|
||||||
|
<span class='fas fa-clipboard-list'></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -14,7 +14,9 @@ InvenTree | {% trans "Sales Orders" %}
|
|||||||
|
|
||||||
<div id='table-buttons'>
|
<div id='table-buttons'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
|
{% if roles.sales_order.add %}
|
||||||
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
|
<button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
|
||||||
|
{% endif %}
|
||||||
<div class='filter-list' id='filter-list-salesorder'>
|
<div class='filter-list' id='filter-list-salesorder'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,6 +6,7 @@ from __future__ import unicode_literals
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus
|
||||||
|
|
||||||
@ -32,7 +33,21 @@ class OrderViewTestCase(TestCase):
|
|||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('username', 'user@email.com', 'password')
|
user = User.objects.create_user('username', 'user@email.com', 'password')
|
||||||
|
|
||||||
|
# Ensure that the user has the correct permissions!
|
||||||
|
g = Group.objects.create(name='orders')
|
||||||
|
user.groups.add(g)
|
||||||
|
|
||||||
|
for rule in g.rule_sets.all():
|
||||||
|
if rule.name in ['purchase_order', 'sales_order']:
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
g.save()
|
||||||
|
|
||||||
self.client.login(username='username', password='password')
|
self.client.login(username='username', password='password')
|
||||||
|
|
||||||
|
@ -28,19 +28,22 @@ from . import forms as order_forms
|
|||||||
|
|
||||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.helpers import DownloadFile, str2bool
|
from InvenTree.helpers import DownloadFile, str2bool
|
||||||
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
|
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderIndex(ListView):
|
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" List view for all purchase orders """
|
""" List view for all purchase orders """
|
||||||
|
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
template_name = 'order/purchase_orders.html'
|
template_name = 'order/purchase_orders.html'
|
||||||
context_object_name = 'orders'
|
context_object_name = 'orders'
|
||||||
|
|
||||||
|
role_required = 'purchase_order.view'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
""" Retrieve the list of purchase orders,
|
""" Retrieve the list of purchase orders,
|
||||||
ensure that the most recent ones are returned first. """
|
ensure that the most recent ones are returned first. """
|
||||||
@ -55,19 +58,21 @@ class PurchaseOrderIndex(ListView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderIndex(ListView):
|
class SalesOrderIndex(InvenTreeRoleMixin, ListView):
|
||||||
|
|
||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
template_name = 'order/sales_orders.html'
|
template_name = 'order/sales_orders.html'
|
||||||
context_object_name = 'orders'
|
context_object_name = 'orders'
|
||||||
|
role_required = 'sales_order.view'
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderDetail(DetailView):
|
class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for a PurchaseOrder object """
|
""" Detail view for a PurchaseOrder object """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
|
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
|
||||||
template_name = 'order/purchase_order_detail.html'
|
template_name = 'order/purchase_order_detail.html'
|
||||||
|
role_required = 'purchase_order.view'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
@ -75,12 +80,13 @@ class PurchaseOrderDetail(DetailView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderDetail(DetailView):
|
class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for a SalesOrder object """
|
""" Detail view for a SalesOrder object """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
queryset = SalesOrder.objects.all().prefetch_related('lines')
|
queryset = SalesOrder.objects.all().prefetch_related('lines')
|
||||||
template_name = 'order/sales_order_detail.html'
|
template_name = 'order/sales_order_detail.html'
|
||||||
|
role_required = 'sales_order.view'
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
||||||
@ -92,6 +98,7 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
|
|||||||
form_class = order_forms.EditPurchaseOrderAttachmentForm
|
form_class = order_forms.EditPurchaseOrderAttachmentForm
|
||||||
ajax_form_title = _("Add Purchase Order Attachment")
|
ajax_form_title = _("Add Purchase Order Attachment")
|
||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
|
role_required = 'purchase_order.add'
|
||||||
|
|
||||||
def post_save(self, **kwargs):
|
def post_save(self, **kwargs):
|
||||||
self.object.user = self.request.user
|
self.object.user = self.request.user
|
||||||
@ -139,6 +146,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
|
|||||||
model = SalesOrderAttachment
|
model = SalesOrderAttachment
|
||||||
form_class = order_forms.EditSalesOrderAttachmentForm
|
form_class = order_forms.EditSalesOrderAttachmentForm
|
||||||
ajax_form_title = _('Add Sales Order Attachment')
|
ajax_form_title = _('Add Sales Order Attachment')
|
||||||
|
role_required = 'sales_order.add'
|
||||||
|
|
||||||
def post_save(self, **kwargs):
|
def post_save(self, **kwargs):
|
||||||
self.object.user = self.request.user
|
self.object.user = self.request.user
|
||||||
@ -174,6 +182,7 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
|
|||||||
model = PurchaseOrderAttachment
|
model = PurchaseOrderAttachment
|
||||||
form_class = order_forms.EditPurchaseOrderAttachmentForm
|
form_class = order_forms.EditPurchaseOrderAttachmentForm
|
||||||
ajax_form_title = _("Edit Attachment")
|
ajax_form_title = _("Edit Attachment")
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -195,6 +204,7 @@ class SalesOrderAttachmentEdit(AjaxUpdateView):
|
|||||||
model = SalesOrderAttachment
|
model = SalesOrderAttachment
|
||||||
form_class = order_forms.EditSalesOrderAttachmentForm
|
form_class = order_forms.EditSalesOrderAttachmentForm
|
||||||
ajax_form_title = _("Edit Attachment")
|
ajax_form_title = _("Edit Attachment")
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -216,6 +226,7 @@ class PurchaseOrderAttachmentDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Delete Attachment")
|
ajax_form_title = _("Delete Attachment")
|
||||||
ajax_template_name = "order/delete_attachment.html"
|
ajax_template_name = "order/delete_attachment.html"
|
||||||
context_object_name = "attachment"
|
context_object_name = "attachment"
|
||||||
|
role_required = 'purchase_order.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -230,6 +241,7 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Delete Attachment")
|
ajax_form_title = _("Delete Attachment")
|
||||||
ajax_template_name = "order/delete_attachment.html"
|
ajax_template_name = "order/delete_attachment.html"
|
||||||
context_object_name = "attachment"
|
context_object_name = "attachment"
|
||||||
|
role_required = 'sales_order.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -237,12 +249,13 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderNotes(UpdateView):
|
class PurchaseOrderNotes(InvenTreeRoleMixin, UpdateView):
|
||||||
""" View for updating the 'notes' field of a PurchaseOrder """
|
""" View for updating the 'notes' field of a PurchaseOrder """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
template_name = 'order/order_notes.html'
|
template_name = 'order/order_notes.html'
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
|
role_required = 'purchase_order.view'
|
||||||
|
|
||||||
fields = ['notes']
|
fields = ['notes']
|
||||||
|
|
||||||
@ -259,12 +272,13 @@ class PurchaseOrderNotes(UpdateView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderNotes(UpdateView):
|
class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
|
||||||
""" View for editing the 'notes' field of a SalesORder """
|
""" View for editing the 'notes' field of a SalesORder """
|
||||||
|
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
template_name = 'order/sales_order_notes.html'
|
template_name = 'order/sales_order_notes.html'
|
||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
|
role_required = 'sales_order.view'
|
||||||
|
|
||||||
fields = ['notes']
|
fields = ['notes']
|
||||||
|
|
||||||
@ -286,6 +300,7 @@ class PurchaseOrderCreate(AjaxCreateView):
|
|||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
ajax_form_title = _("Create Purchase Order")
|
ajax_form_title = _("Create Purchase Order")
|
||||||
form_class = order_forms.EditPurchaseOrderForm
|
form_class = order_forms.EditPurchaseOrderForm
|
||||||
|
role_required = 'purchase_order.add'
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
@ -317,6 +332,7 @@ class SalesOrderCreate(AjaxCreateView):
|
|||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
ajax_form_title = _("Create Sales Order")
|
ajax_form_title = _("Create Sales Order")
|
||||||
form_class = order_forms.EditSalesOrderForm
|
form_class = order_forms.EditSalesOrderForm
|
||||||
|
role_required = 'sales_order.add'
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
@ -347,6 +363,7 @@ class PurchaseOrderEdit(AjaxUpdateView):
|
|||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
ajax_form_title = _('Edit Purchase Order')
|
ajax_form_title = _('Edit Purchase Order')
|
||||||
form_class = order_forms.EditPurchaseOrderForm
|
form_class = order_forms.EditPurchaseOrderForm
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
|
|
||||||
@ -367,6 +384,7 @@ class SalesOrderEdit(AjaxUpdateView):
|
|||||||
model = SalesOrder
|
model = SalesOrder
|
||||||
ajax_form_title = _('Edit Sales Order')
|
ajax_form_title = _('Edit Sales Order')
|
||||||
form_class = order_forms.EditSalesOrderForm
|
form_class = order_forms.EditSalesOrderForm
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -384,6 +402,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Cancel Order')
|
ajax_form_title = _('Cancel Order')
|
||||||
ajax_template_name = 'order/order_cancel.html'
|
ajax_template_name = 'order/order_cancel.html'
|
||||||
form_class = order_forms.CancelPurchaseOrderForm
|
form_class = order_forms.CancelPurchaseOrderForm
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Mark the PO as 'CANCELLED' """
|
""" Mark the PO as 'CANCELLED' """
|
||||||
@ -417,6 +436,7 @@ class SalesOrderCancel(AjaxUpdateView):
|
|||||||
ajax_form_title = _("Cancel sales order")
|
ajax_form_title = _("Cancel sales order")
|
||||||
ajax_template_name = "order/sales_order_cancel.html"
|
ajax_template_name = "order/sales_order_cancel.html"
|
||||||
form_class = order_forms.CancelSalesOrderForm
|
form_class = order_forms.CancelSalesOrderForm
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -451,6 +471,7 @@ class PurchaseOrderIssue(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Issue Order')
|
ajax_form_title = _('Issue Order')
|
||||||
ajax_template_name = "order/order_issue.html"
|
ajax_template_name = "order/order_issue.html"
|
||||||
form_class = order_forms.IssuePurchaseOrderForm
|
form_class = order_forms.IssuePurchaseOrderForm
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
""" Mark the purchase order as 'PLACED' """
|
""" Mark the purchase order as 'PLACED' """
|
||||||
@ -486,6 +507,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
|
|||||||
ajax_template_name = "order/order_complete.html"
|
ajax_template_name = "order/order_complete.html"
|
||||||
ajax_form_title = _("Complete Order")
|
ajax_form_title = _("Complete Order")
|
||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
|
|
||||||
@ -520,6 +542,7 @@ class SalesOrderShip(AjaxUpdateView):
|
|||||||
context_object_name = 'order'
|
context_object_name = 'order'
|
||||||
ajax_template_name = 'order/sales_order_ship.html'
|
ajax_template_name = 'order/sales_order_ship.html'
|
||||||
ajax_form_title = _('Ship Order')
|
ajax_form_title = _('Ship Order')
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -563,6 +586,7 @@ class PurchaseOrderExport(AjaxView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
model = PurchaseOrder
|
model = PurchaseOrder
|
||||||
|
role_required = 'purchase_order.view'
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
|
||||||
@ -594,6 +618,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
|
|||||||
form_class = order_forms.ReceivePurchaseOrderForm
|
form_class = order_forms.ReceivePurchaseOrderForm
|
||||||
ajax_form_title = _("Receive Parts")
|
ajax_form_title = _("Receive Parts")
|
||||||
ajax_template_name = "order/receive_parts.html"
|
ajax_template_name = "order/receive_parts.html"
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
# Where the parts will be going (selected in POST request)
|
# Where the parts will be going (selected in POST request)
|
||||||
destination = None
|
destination = None
|
||||||
@ -779,6 +804,11 @@ class OrderParts(AjaxView):
|
|||||||
ajax_form_title = _("Order Parts")
|
ajax_form_title = _("Order Parts")
|
||||||
ajax_template_name = 'order/order_wizard/select_parts.html'
|
ajax_template_name = 'order/order_wizard/select_parts.html'
|
||||||
|
|
||||||
|
role_required = [
|
||||||
|
'part.view',
|
||||||
|
'purchase_order.change',
|
||||||
|
]
|
||||||
|
|
||||||
# List of Parts we wish to order
|
# List of Parts we wish to order
|
||||||
parts = []
|
parts = []
|
||||||
suppliers = []
|
suppliers = []
|
||||||
@ -1085,6 +1115,7 @@ class POLineItemCreate(AjaxCreateView):
|
|||||||
context_object_name = 'line'
|
context_object_name = 'line'
|
||||||
form_class = order_forms.EditPurchaseOrderLineItemForm
|
form_class = order_forms.EditPurchaseOrderLineItemForm
|
||||||
ajax_form_title = _('Add Line Item')
|
ajax_form_title = _('Add Line Item')
|
||||||
|
role_required = 'purchase_order.add'
|
||||||
|
|
||||||
def post(self, request, *arg, **kwargs):
|
def post(self, request, *arg, **kwargs):
|
||||||
|
|
||||||
@ -1199,6 +1230,7 @@ class SOLineItemCreate(AjaxCreateView):
|
|||||||
context_order_name = 'line'
|
context_order_name = 'line'
|
||||||
form_class = order_forms.EditSalesOrderLineItemForm
|
form_class = order_forms.EditSalesOrderLineItemForm
|
||||||
ajax_form_title = _('Add Line Item')
|
ajax_form_title = _('Add Line Item')
|
||||||
|
role_required = 'sales_order.add'
|
||||||
|
|
||||||
def get_form(self, *args, **kwargs):
|
def get_form(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -1250,6 +1282,7 @@ class SOLineItemEdit(AjaxUpdateView):
|
|||||||
model = SalesOrderLineItem
|
model = SalesOrderLineItem
|
||||||
form_class = order_forms.EditSalesOrderLineItemForm
|
form_class = order_forms.EditSalesOrderLineItemForm
|
||||||
ajax_form_title = _('Edit Line Item')
|
ajax_form_title = _('Edit Line Item')
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -1268,6 +1301,7 @@ class POLineItemEdit(AjaxUpdateView):
|
|||||||
form_class = order_forms.EditPurchaseOrderLineItemForm
|
form_class = order_forms.EditPurchaseOrderLineItemForm
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit Line Item')
|
ajax_form_title = _('Edit Line Item')
|
||||||
|
role_required = 'purchase_order.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -1285,6 +1319,7 @@ class POLineItemDelete(AjaxDeleteView):
|
|||||||
model = PurchaseOrderLineItem
|
model = PurchaseOrderLineItem
|
||||||
ajax_form_title = _('Delete Line Item')
|
ajax_form_title = _('Delete Line Item')
|
||||||
ajax_template_name = 'order/po_lineitem_delete.html'
|
ajax_template_name = 'order/po_lineitem_delete.html'
|
||||||
|
role_required = 'purchase_order.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -1297,6 +1332,7 @@ class SOLineItemDelete(AjaxDeleteView):
|
|||||||
model = SalesOrderLineItem
|
model = SalesOrderLineItem
|
||||||
ajax_form_title = _("Delete Line Item")
|
ajax_form_title = _("Delete Line Item")
|
||||||
ajax_template_name = "order/so_lineitem_delete.html"
|
ajax_template_name = "order/so_lineitem_delete.html"
|
||||||
|
role_required = 'sales_order.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
@ -1310,6 +1346,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
|
|||||||
model = SalesOrderAllocation
|
model = SalesOrderAllocation
|
||||||
form_class = order_forms.EditSalesOrderAllocationForm
|
form_class = order_forms.EditSalesOrderAllocationForm
|
||||||
ajax_form_title = _('Allocate Stock to Order')
|
ajax_form_title = _('Allocate Stock to Order')
|
||||||
|
role_required = 'sales_order.add'
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
initials = super().get_initial().copy()
|
initials = super().get_initial().copy()
|
||||||
@ -1379,6 +1416,7 @@ class SalesOrderAllocationEdit(AjaxUpdateView):
|
|||||||
model = SalesOrderAllocation
|
model = SalesOrderAllocation
|
||||||
form_class = order_forms.EditSalesOrderAllocationForm
|
form_class = order_forms.EditSalesOrderAllocationForm
|
||||||
ajax_form_title = _('Edit Allocation Quantity')
|
ajax_form_title = _('Edit Allocation Quantity')
|
||||||
|
role_required = 'sales_order.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -1396,3 +1434,4 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _("Remove allocation")
|
ajax_form_title = _("Remove allocation")
|
||||||
context_object_name = 'allocation'
|
context_object_name = 'allocation'
|
||||||
ajax_template_name = "order/so_allocation_delete.html"
|
ajax_template_name = "order/so_allocation_delete.html"
|
||||||
|
role_required = 'sales_order.delete'
|
||||||
|
@ -44,6 +44,10 @@ class PartCategoryTree(TreeSerializer):
|
|||||||
def get_items(self):
|
def get_items(self):
|
||||||
return PartCategory.objects.all().prefetch_related('parts', 'children')
|
return PartCategory.objects.all().prefetch_related('parts', 'children')
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
permissions.IsAuthenticated,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CategoryList(generics.ListCreateAPIView):
|
class CategoryList(generics.ListCreateAPIView):
|
||||||
""" API endpoint for accessing a list of PartCategory objects.
|
""" API endpoint for accessing a list of PartCategory objects.
|
||||||
@ -55,10 +59,6 @@ class CategoryList(generics.ListCreateAPIView):
|
|||||||
queryset = PartCategory.objects.all()
|
queryset = PartCategory.objects.all()
|
||||||
serializer_class = part_serializers.CategorySerializer
|
serializer_class = part_serializers.CategorySerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Custom filtering:
|
Custom filtering:
|
||||||
@ -119,10 +119,6 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
|||||||
queryset = PartSellPriceBreak.objects.all()
|
queryset = PartSellPriceBreak.objects.all()
|
||||||
serializer_class = part_serializers.PartSalePriceSerializer
|
serializer_class = part_serializers.PartSalePriceSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend
|
DjangoFilterBackend
|
||||||
]
|
]
|
||||||
@ -182,8 +178,6 @@ class PartTestTemplateList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.OrderingFilter,
|
filters.OrderingFilter,
|
||||||
@ -221,10 +215,6 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
|
|||||||
queryset = Part.objects.all()
|
queryset = Part.objects.all()
|
||||||
serializer_class = part_serializers.PartThumbSerializerUpdate
|
serializer_class = part_serializers.PartThumbSerializerUpdate
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend
|
DjangoFilterBackend
|
||||||
]
|
]
|
||||||
@ -246,10 +236,6 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -580,10 +566,6 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -676,10 +658,6 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
|
|||||||
queryset = PartParameterTemplate.objects.all()
|
queryset = PartParameterTemplate.objects.all()
|
||||||
serializer_class = part_serializers.PartParameterTemplateSerializer
|
serializer_class = part_serializers.PartParameterTemplateSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
filters.OrderingFilter,
|
filters.OrderingFilter,
|
||||||
]
|
]
|
||||||
@ -699,10 +677,6 @@ class PartParameterList(generics.ListCreateAPIView):
|
|||||||
queryset = PartParameter.objects.all()
|
queryset = PartParameter.objects.all()
|
||||||
serializer_class = part_serializers.PartParameterSerializer
|
serializer_class = part_serializers.PartParameterSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend
|
DjangoFilterBackend
|
||||||
]
|
]
|
||||||
@ -765,23 +739,36 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
# Filter by "optional" status?
|
||||||
|
optional = params.get('optional', None)
|
||||||
|
|
||||||
|
if optional is not None:
|
||||||
|
optional = str2bool(optional)
|
||||||
|
|
||||||
|
queryset = queryset.filter(optional=optional)
|
||||||
|
|
||||||
# Filter by part?
|
# Filter by part?
|
||||||
part = self.request.query_params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
if part is not None:
|
if part is not None:
|
||||||
queryset = queryset.filter(part=part)
|
queryset = queryset.filter(part=part)
|
||||||
|
|
||||||
# Filter by sub-part?
|
# Filter by sub-part?
|
||||||
sub_part = self.request.query_params.get('sub_part', None)
|
sub_part = params.get('sub_part', None)
|
||||||
|
|
||||||
if sub_part is not None:
|
if sub_part is not None:
|
||||||
queryset = queryset.filter(sub_part=sub_part)
|
queryset = queryset.filter(sub_part=sub_part)
|
||||||
|
|
||||||
return queryset
|
# Filter by "trackable" status of the sub-part
|
||||||
|
trackable = params.get('trackable', None)
|
||||||
|
|
||||||
permission_classes = [
|
if trackable is not None:
|
||||||
permissions.IsAuthenticated,
|
trackable = str2bool(trackable)
|
||||||
]
|
queryset = queryset.filter(sub_part__trackable=trackable)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
@ -799,10 +786,6 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
queryset = BomItem.objects.all()
|
queryset = BomItem.objects.all()
|
||||||
serializer_class = part_serializers.BomItemSerializer
|
serializer_class = part_serializers.BomItemSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class BomItemValidate(generics.UpdateAPIView):
|
class BomItemValidate(generics.UpdateAPIView):
|
||||||
""" API endpoint for validating a BomItem """
|
""" API endpoint for validating a BomItem """
|
||||||
|
@ -37,4 +37,4 @@ class PartConfig(AppConfig):
|
|||||||
part.image = None
|
part.image = None
|
||||||
part.save()
|
part.save()
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
print("Could not generate Part thumbnails")
|
pass
|
||||||
|
@ -231,7 +231,8 @@ class EditBomItemForm(HelperForm):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
'overage',
|
'overage',
|
||||||
'note'
|
'note',
|
||||||
|
'optional',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Prevent editing of the part associated with this BomItem
|
# Prevent editing of the part associated with this BomItem
|
||||||
|
18
InvenTree/part/migrations/0051_bomitem_optional.py
Normal file
18
InvenTree/part/migrations/0051_bomitem_optional.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-04 13:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0050_auto_20200917_2315'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bomitem',
|
||||||
|
name='optional',
|
||||||
|
field=models.BooleanField(default=False, help_text='This BOM item is optional'),
|
||||||
|
),
|
||||||
|
]
|
@ -111,6 +111,58 @@ class PartCategory(InvenTreeTree):
|
|||||||
""" True if there are any parts in this category """
|
""" True if there are any parts in this category """
|
||||||
return self.partcount() > 0
|
return self.partcount() > 0
|
||||||
|
|
||||||
|
def prefetch_parts_parameters(self, cascade=True):
|
||||||
|
""" Prefectch parts parameters """
|
||||||
|
|
||||||
|
return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template').all()
|
||||||
|
|
||||||
|
def get_unique_parameters(self, cascade=True, prefetch=None):
|
||||||
|
""" Get all unique parameter names for all parts from this category """
|
||||||
|
|
||||||
|
unique_parameters_names = []
|
||||||
|
|
||||||
|
if prefetch:
|
||||||
|
parts = prefetch
|
||||||
|
else:
|
||||||
|
parts = self.prefetch_parts_parameters(cascade=cascade)
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
for parameter in part.parameters.all():
|
||||||
|
parameter_name = parameter.template.name
|
||||||
|
if parameter_name not in unique_parameters_names:
|
||||||
|
unique_parameters_names.append(parameter_name)
|
||||||
|
|
||||||
|
return sorted(unique_parameters_names)
|
||||||
|
|
||||||
|
def get_parts_parameters(self, cascade=True, prefetch=None):
|
||||||
|
""" Get all parameter names and values for all parts from this category """
|
||||||
|
|
||||||
|
category_parameters = []
|
||||||
|
|
||||||
|
if prefetch:
|
||||||
|
parts = prefetch
|
||||||
|
else:
|
||||||
|
parts = self.prefetch_parts_parameters(cascade=cascade)
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
part_parameters = {
|
||||||
|
'pk': part.pk,
|
||||||
|
'name': part.name,
|
||||||
|
'description': part.description,
|
||||||
|
}
|
||||||
|
# Add IPN only if it exists
|
||||||
|
if part.IPN:
|
||||||
|
part_parameters['IPN'] = part.IPN
|
||||||
|
|
||||||
|
for parameter in part.parameters.all():
|
||||||
|
parameter_name = parameter.template.name
|
||||||
|
parameter_value = parameter.data
|
||||||
|
part_parameters[parameter_name] = parameter_value
|
||||||
|
|
||||||
|
category_parameters.append(part_parameters)
|
||||||
|
|
||||||
|
return category_parameters
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
|
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
|
||||||
def before_delete_part_category(sender, instance, using, **kwargs):
|
def before_delete_part_category(sender, instance, using, **kwargs):
|
||||||
@ -382,7 +434,7 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return _('Next available serial numbers are') + ' ' + text
|
return _('Next available serial numbers are') + ' ' + text
|
||||||
else:
|
else:
|
||||||
text = str(latest)
|
text = str(latest + 1)
|
||||||
|
|
||||||
return _('Next available serial number is') + ' ' + text
|
return _('Next available serial number is') + ' ' + text
|
||||||
|
|
||||||
@ -732,12 +784,13 @@ class Part(MPTTModel):
|
|||||||
""" Return the current number of parts currently being built
|
""" Return the current number of parts currently being built
|
||||||
"""
|
"""
|
||||||
|
|
||||||
quantity = self.active_builds.aggregate(quantity=Sum('quantity'))['quantity']
|
stock_items = self.stock_items.filter(is_building=True)
|
||||||
|
|
||||||
if quantity is None:
|
query = stock_items.aggregate(
|
||||||
quantity = 0
|
quantity=Coalesce(Sum('quantity'), Decimal(0))
|
||||||
|
)
|
||||||
|
|
||||||
return quantity
|
return query['quantity']
|
||||||
|
|
||||||
def build_order_allocations(self):
|
def build_order_allocations(self):
|
||||||
"""
|
"""
|
||||||
@ -1447,6 +1500,7 @@ class BomItem(models.Model):
|
|||||||
part: Link to the parent part (the part that will be produced)
|
part: Link to the parent part (the part that will be produced)
|
||||||
sub_part: Link to the child part (the part that will be consumed)
|
sub_part: Link to the child part (the part that will be consumed)
|
||||||
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
quantity: Number of 'sub_parts' consumed to produce one 'part'
|
||||||
|
optional: Boolean field describing if this BomItem is optional
|
||||||
reference: BOM reference field (e.g. part designators)
|
reference: BOM reference field (e.g. part designators)
|
||||||
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
||||||
note: Note field for this BOM item
|
note: Note field for this BOM item
|
||||||
@ -1480,6 +1534,8 @@ class BomItem(models.Model):
|
|||||||
# Quantity required
|
# Quantity required
|
||||||
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item'))
|
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item'))
|
||||||
|
|
||||||
|
optional = models.BooleanField(default=False, help_text=_("This BOM item is optional"))
|
||||||
|
|
||||||
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
||||||
help_text=_('Estimated build wastage quantity (absolute or percentage)')
|
help_text=_('Estimated build wastage quantity (absolute or percentage)')
|
||||||
)
|
)
|
||||||
|
@ -403,6 +403,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
'price_range',
|
'price_range',
|
||||||
|
'optional',
|
||||||
'overage',
|
'overage',
|
||||||
'note',
|
'note',
|
||||||
'validated',
|
'validated',
|
||||||
|
@ -39,10 +39,12 @@
|
|||||||
<button class='btn btn-default action-button' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'><span class='fas fa-plus-circle'></span></button>
|
<button class='btn btn-default action-button' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'><span class='fas fa-plus-circle'></span></button>
|
||||||
<button class='btn btn-default action-button' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button>
|
<button class='btn btn-default action-button' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button>
|
||||||
{% elif part.active %}
|
{% elif part.active %}
|
||||||
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-default action-button' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'><span class='fas fa-edit'></span></button>
|
<button class='btn btn-default action-button' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'><span class='fas fa-edit'></span></button>
|
||||||
{% if part.is_bom_valid == False %}
|
{% if part.is_bom_valid == False %}
|
||||||
<button class='btn btn-default action-button' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'><span class='fas fa-clipboard-check'></span></button>
|
<button class='btn btn-default action-button' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'><span class='fas fa-clipboard-check'></span></button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default action-button' id='download-bom' type='button'><span class='fas fa-file-download'></span></button>
|
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default action-button' id='download-bom' type='button'><span class='fas fa-file-download'></span></button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
{% extends "part/part_base.html" %}
|
{% extends "part/part_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
{% block details %}
|
{% block details %}
|
||||||
|
|
||||||
{% include 'part/tabs.html' with tab='build' %}
|
{% include 'part/tabs.html' with tab='build' %}
|
||||||
|
|
||||||
<h3>Part Builds</h3>
|
<h3>{% trans "Part Builds" %}</h3>
|
||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-flui' style='float: right';>
|
<div class='button-toolbar container-flui' style='float: right';>
|
||||||
{% if part.active %}
|
{% if part.active %}
|
||||||
<button class="btn btn-success" id='start-build'>Start New Build</button>
|
{% if roles.build.add %}
|
||||||
|
<button class="btn btn-success" id='start-build'>{% trans "Start New Build" %}</button>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class='filter-list' id='filter-list-build'>
|
<div class='filter-list' id='filter-list-build'>
|
||||||
<!-- Empty div for filters -->
|
<!-- Empty div for filters -->
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
{% if category %}
|
{% if category %}
|
||||||
<h3>
|
<h3>
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
{% if user.is_staff and perms.part.change_partcategory %}
|
{% if user.is_staff and roles.part.change %}
|
||||||
<a href="{% url 'admin:part_partcategory_change' category.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:part_partcategory_change' category.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
@ -20,17 +20,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
<div class='btn-group action-buttons'>
|
<div class='btn-group action-buttons'>
|
||||||
|
{% if roles.part.add %}
|
||||||
<button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
|
<button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
|
||||||
<span class='fas fa-plus-circle icon-green'/>
|
<span class='fas fa-plus-circle icon-green'/>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if category %}
|
{% if category %}
|
||||||
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
|
<button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
|
||||||
<span class='fas fa-edit icon-blue'/>
|
<span class='fas fa-edit icon-blue'/>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.part.delete %}
|
||||||
<button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
|
<button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
|
||||||
<span class='fas fa-trash-alt icon-red'/>
|
<span class='fas fa-trash-alt icon-red'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -104,11 +110,15 @@
|
|||||||
<div class='button-toolbar container-fluid' style="float: right;">
|
<div class='button-toolbar container-fluid' style="float: right;">
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
|
<button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
|
||||||
|
{% if roles.part.add %}
|
||||||
<button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button>
|
<button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button>
|
||||||
|
{% endif %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
|
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
|
{% if roles.part.change %}
|
||||||
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
|
||||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -120,8 +130,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{% block category_tables %}
|
||||||
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
|
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
|
||||||
</table>
|
</table>
|
||||||
|
{% endblock category_tables %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block js_load %}
|
{% block js_load %}
|
||||||
@ -177,6 +190,7 @@
|
|||||||
location.href = url;
|
location.href = url;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.part.add %}
|
||||||
$("#part-create").click(function() {
|
$("#part-create").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'part-create' %}",
|
"{% url 'part-create' %}",
|
||||||
@ -204,6 +218,7 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if category %}
|
{% if category %}
|
||||||
$("#cat-edit").click(function () {
|
$("#cat-edit").click(function () {
|
||||||
|
31
InvenTree/part/templates/part/category_parametric.html
Normal file
31
InvenTree/part/templates/part/category_parametric.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{% extends "part/category.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block category_tables %}
|
||||||
|
|
||||||
|
{% include 'part/category_tabs.html' with tab='parametric-table' %}
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='parametric-part-table'>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
/* Hide Button Toolbar */
|
||||||
|
window.onload = function hideButtonToolbar() {
|
||||||
|
var toolbar = document.getElementById("button-toolbar");
|
||||||
|
toolbar.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
loadParametricPartTable(
|
||||||
|
"#parametric-part-table",
|
||||||
|
{
|
||||||
|
headers: {{ headers|safe }},
|
||||||
|
data: {{ parameters|safe }},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
{% endblock %}
|
12
InvenTree/part/templates/part/category_partlist.html
Normal file
12
InvenTree/part/templates/part/category_partlist.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "part/category.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block category_tables %}
|
||||||
|
|
||||||
|
{% include 'part/category_tabs.html' with tab='part-list' %}
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
11
InvenTree/part/templates/part/category_tabs.html
Normal file
11
InvenTree/part/templates/part/category_tabs.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li{% ifequal tab 'part-list' %} class="active"{% endifequal %}>
|
||||||
|
<a href="{% url 'category-detail' category.id %}">{% trans "Parts" %} <span class="badge">{% decimal part_count %}</span></a>
|
||||||
|
</li>
|
||||||
|
<li{% ifequal tab 'parametric-table' %} class='active'{% endifequal %}>
|
||||||
|
<a href="{% url 'category-parametric' category.id %}">{% trans "Parametric Table" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
@ -29,7 +29,9 @@
|
|||||||
<h4>{% trans "Part Notes" %}</h4>
|
<h4>{% trans "Part Notes" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
|
{% if roles.part.change %}
|
||||||
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
|
<button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
@ -10,7 +10,9 @@
|
|||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
|
{% if roles.part.add %}
|
||||||
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>{% trans "New Parameter" %}</button>
|
<button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>{% trans "New Parameter" %}</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -30,8 +32,12 @@
|
|||||||
<td>
|
<td>
|
||||||
{{ param.template.units }}
|
{{ param.template.units }}
|
||||||
<div class='btn-group' style='float: right;'>
|
<div class='btn-group' style='float: right;'>
|
||||||
|
{% if roles.part.change %}
|
||||||
<button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
|
<button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.part.delete %}
|
||||||
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
|
<button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -48,6 +54,7 @@
|
|||||||
$('#param-table').inventreeTable({
|
$('#param-table').inventreeTable({
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.part.add %}
|
||||||
$('#param-create').click(function() {
|
$('#param-create').click(function() {
|
||||||
launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
|
launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
|
||||||
reload: true,
|
reload: true,
|
||||||
@ -59,6 +66,7 @@
|
|||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
$('.param-edit').click(function() {
|
$('.param-edit').click(function() {
|
||||||
var button = $(this);
|
var button = $(this);
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
<div class="media-body">
|
<div class="media-body">
|
||||||
<h3>
|
<h3>
|
||||||
{{ part.full_name }}
|
{{ part.full_name }}
|
||||||
{% if user.is_staff and perms.part.change_part %}
|
{% if user.is_staff and roles.part.change %}
|
||||||
<a href="{% url 'admin:part_part_change' part.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:part_part_change' part.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not part.active %}
|
{% if not part.active %}
|
||||||
@ -56,26 +56,36 @@
|
|||||||
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
|
||||||
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
<span id='part-price-icon' class='fas fa-dollar-sign'/>
|
||||||
</button>
|
</button>
|
||||||
<button type='button' class='btn btn-default' id='part-count' title='Count part stock'>
|
{% if roles.stock.change %}
|
||||||
|
<button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
|
||||||
<span class='fas fa-clipboard-list'/>
|
<span class='fas fa-clipboard-list'/>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if part.purchaseable %}
|
{% if part.purchaseable %}
|
||||||
<button type='button' class='btn btn-default' id='part-order' title='Order part'>
|
{% if roles.purchase_order.add %}
|
||||||
|
<button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
|
||||||
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
<span id='part-order-icon' class='fas fa-shopping-cart'/>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<!-- Part actions -->
|
<!-- Part actions -->
|
||||||
|
{% if roles.part.add or roles.part.change or roles.part.delete %}
|
||||||
<div class='btn-group'>
|
<div class='btn-group'>
|
||||||
<button id='part-actions' title='{% trans "Part actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <span class='fas fa-shapes'></span> <span class='caret'></span></button>
|
<button id='part-actions' title='{% trans "Part actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <span class='fas fa-shapes'></span> <span class='caret'></span></button>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
|
{% if roles.part.add %}
|
||||||
<li><a href='#' id='part-duplicate'><span class='fas fa-copy'></span> {% trans "Duplicate part" %}</a></li>
|
<li><a href='#' id='part-duplicate'><span class='fas fa-copy'></span> {% trans "Duplicate part" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.part.change %}
|
||||||
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
|
<li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
|
||||||
{% if not part.active %}
|
{% endif %}
|
||||||
|
{% if not part.active and roles.part.delete %}
|
||||||
<li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
|
<li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<table class='table table-condensed'>
|
<table class='table table-condensed'>
|
||||||
<col width='25'>
|
<col width='25'>
|
||||||
@ -274,6 +284,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.part.change %}
|
||||||
$("#part-edit").click(function() {
|
$("#part-edit").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'part-edit' part.id %}",
|
"{% url 'part-edit' part.id %}",
|
||||||
@ -282,6 +293,7 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
$("#part-order").click(function() {
|
$("#part-order").click(function() {
|
||||||
launchModalForm("{% url 'order-parts' %}", {
|
launchModalForm("{% url 'order-parts' %}", {
|
||||||
@ -292,6 +304,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if roles.part.add %}
|
||||||
$("#part-duplicate").click(function() {
|
$("#part-duplicate").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'part-duplicate' part.id %}",
|
"{% url 'part-duplicate' part.id %}",
|
||||||
@ -300,8 +313,9 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not part.active %}
|
{% if not part.active and roles.part.delete %}
|
||||||
$("#part-delete").click(function() {
|
$("#part-delete").click(function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'part-delete' part.id %}",
|
"{% url 'part-delete' part.id %}",
|
||||||
|
@ -26,14 +26,17 @@
|
|||||||
{% if part.assembly %}
|
{% if part.assembly %}
|
||||||
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-bom' part.id %}">{% trans "BOM" %}<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
|
<a href="{% url 'part-bom' part.id %}">{% trans "BOM" %}<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
|
||||||
|
{% if roles.build.view %}
|
||||||
<li{% ifequal tab 'build' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'build' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-build' part.id %}">{% trans "Build Orders" %}<span class='badge'>{{ part.builds.count }}</span></a></li>
|
<a href="{% url 'part-build' part.id %}">{% trans "Build Orders" %}<span class='badge'>{{ part.builds.count }}</span></a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.component or part.used_in_count > 0 %}
|
{% if part.component or part.used_in_count > 0 %}
|
||||||
<li{% ifequal tab 'used' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'used' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-used-in' part.id %}">{% trans "Used In" %} {% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
|
<a href="{% url 'part-used-in' part.id %}">{% trans "Used In" %} {% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.purchaseable %}
|
{% if part.purchaseable and roles.purchase_order.view %}
|
||||||
{% if part.is_template == False %}
|
{% if part.is_template == False %}
|
||||||
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-suppliers' part.id %}">{% trans "Suppliers" %}
|
<a href="{% url 'part-suppliers' part.id %}">{% trans "Suppliers" %}
|
||||||
@ -45,7 +48,7 @@
|
|||||||
<a href="{% url 'part-orders' part.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ part.purchase_orders|length }}</span></a>
|
<a href="{% url 'part-orders' part.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ part.purchase_orders|length }}</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.salable %}
|
{% if part.salable and roles.sales_order.view %}
|
||||||
<li {% if tab == 'sales-prices' %}class='active'{% endif %}>
|
<li {% if tab == 'sales-prices' %}class='active'{% endif %}>
|
||||||
<a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
|
<a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -3,6 +3,7 @@ from rest_framework import status
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
@ -29,7 +30,26 @@ class PartAPITest(APITestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
email='test@testing.com',
|
||||||
|
password='password'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Put the user into a group with the correct permissions
|
||||||
|
group = Group.objects.create(name='mygroup')
|
||||||
|
self.user.groups.add(group)
|
||||||
|
|
||||||
|
# Give the group *all* the permissions!
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
rule.can_view = True
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
group.save()
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from .models import Part, PartCategory
|
from .models import Part, PartCategory, PartParameter, PartParameterTemplate
|
||||||
|
|
||||||
|
|
||||||
class CategoryTest(TestCase):
|
class CategoryTest(TestCase):
|
||||||
@ -15,6 +15,7 @@ class CategoryTest(TestCase):
|
|||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
|
'params',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -94,6 +95,31 @@ class CategoryTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||||
|
|
||||||
|
def test_parameters(self):
|
||||||
|
""" Test that the Category parameters are correctly fetched """
|
||||||
|
|
||||||
|
# Check number of SQL queries to iterate other parameters
|
||||||
|
with self.assertNumQueries(3):
|
||||||
|
# Prefetch: 3 queries (parts, parameters and parameters_template)
|
||||||
|
fasteners = self.fasteners.prefetch_parts_parameters()
|
||||||
|
# Iterate through all parts and parameters
|
||||||
|
for fastener in fasteners:
|
||||||
|
self.assertIsInstance(fastener, Part)
|
||||||
|
for parameter in fastener.parameters.all():
|
||||||
|
self.assertIsInstance(parameter, PartParameter)
|
||||||
|
self.assertIsInstance(parameter.template, PartParameterTemplate)
|
||||||
|
|
||||||
|
# Test number of unique parameters
|
||||||
|
self.assertEqual(len(self.fasteners.get_unique_parameters(prefetch=fasteners)), 1)
|
||||||
|
# Test number of parameters found for each part
|
||||||
|
parts_parameters = self.fasteners.get_parts_parameters(prefetch=fasteners)
|
||||||
|
part_infos = ['pk', 'name', 'description']
|
||||||
|
for part_parameter in parts_parameters:
|
||||||
|
# Remove part informations
|
||||||
|
for item in part_infos:
|
||||||
|
part_parameter.pop(item)
|
||||||
|
self.assertEqual(len(part_parameter), 1)
|
||||||
|
|
||||||
def test_invalid_name(self):
|
def test_invalid_name(self):
|
||||||
# Test that an illegal character is prohibited in a category name
|
# Test that an illegal character is prohibited in a category name
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
from .models import Part
|
from .models import Part
|
||||||
|
|
||||||
@ -23,7 +24,24 @@ class PartViewTestCase(TestCase):
|
|||||||
|
|
||||||
# Create a user
|
# Create a user
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
User.objects.create_user('username', 'user@email.com', 'password')
|
self.user = User.objects.create_user(
|
||||||
|
username='username',
|
||||||
|
email='user@email.com',
|
||||||
|
password='password'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Put the user into a group with the correct permissions
|
||||||
|
group = Group.objects.create(name='mygroup')
|
||||||
|
self.user.groups.add(group)
|
||||||
|
|
||||||
|
# Give the group *all* the permissions!
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
rule.can_view = True
|
||||||
|
rule.can_change = True
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_delete = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
self.client.login(username='username', password='password')
|
self.client.login(username='username', password='password')
|
||||||
|
|
||||||
@ -140,12 +158,14 @@ class PartTests(PartViewTestCase):
|
|||||||
""" Tests for Part forms """
|
""" Tests for Part forms """
|
||||||
|
|
||||||
def test_part_edit(self):
|
def test_part_edit(self):
|
||||||
|
|
||||||
response = self.client.get(reverse('part-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
keys = response.context.keys()
|
keys = response.context.keys()
|
||||||
data = str(response.content)
|
data = str(response.content)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
self.assertIn('part', keys)
|
self.assertIn('part', keys)
|
||||||
self.assertIn('csrf_token', keys)
|
self.assertIn('csrf_token', keys)
|
||||||
|
|
||||||
@ -189,6 +209,8 @@ class PartAttachmentTests(PartViewTestCase):
|
|||||||
response = self.client.get(reverse('part-attachment-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
response = self.client.get(reverse('part-attachment-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# TODO - Create a new attachment using this view
|
||||||
|
|
||||||
def test_invalid_create(self):
|
def test_invalid_create(self):
|
||||||
""" test creation of an attachment for an invalid part """
|
""" test creation of an attachment for an invalid part """
|
||||||
|
|
||||||
|
@ -77,7 +77,8 @@ part_category_urls = [
|
|||||||
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
|
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
|
||||||
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
|
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
|
||||||
|
|
||||||
url('^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
|
||||||
|
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||||
]
|
]
|
||||||
|
|
||||||
part_bom_urls = [
|
part_bom_urls = [
|
||||||
|
@ -38,17 +38,21 @@ from .admin import PartResource
|
|||||||
|
|
||||||
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
|
||||||
from InvenTree.views import QRCodeView
|
from InvenTree.views import QRCodeView
|
||||||
|
from InvenTree.views import InvenTreeRoleMixin
|
||||||
|
|
||||||
from InvenTree.helpers import DownloadFile, str2bool
|
from InvenTree.helpers import DownloadFile, str2bool
|
||||||
|
|
||||||
|
|
||||||
class PartIndex(ListView):
|
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||||
""" View for displaying list of Part objects
|
""" View for displaying list of Part objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model = Part
|
model = Part
|
||||||
template_name = 'part/category.html'
|
template_name = 'part/category.html'
|
||||||
context_object_name = 'parts'
|
context_object_name = 'parts'
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Part.objects.all().select_related('category')
|
return Part.objects.all().select_related('category')
|
||||||
|
|
||||||
@ -76,6 +80,8 @@ class PartAttachmentCreate(AjaxCreateView):
|
|||||||
ajax_form_title = _("Add part attachment")
|
ajax_form_title = _("Add part attachment")
|
||||||
ajax_template_name = "modal_form.html"
|
ajax_template_name = "modal_form.html"
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def post_save(self):
|
def post_save(self):
|
||||||
""" Record the user that uploaded the attachment """
|
""" Record the user that uploaded the attachment """
|
||||||
self.object.user = self.request.user
|
self.object.user = self.request.user
|
||||||
@ -124,6 +130,8 @@ class PartAttachmentEdit(AjaxUpdateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit attachment')
|
ajax_form_title = _('Edit attachment')
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'success': _('Part attachment updated')
|
'success': _('Part attachment updated')
|
||||||
@ -145,6 +153,8 @@ class PartAttachmentDelete(AjaxDeleteView):
|
|||||||
ajax_template_name = "attachment_delete.html"
|
ajax_template_name = "attachment_delete.html"
|
||||||
context_object_name = "attachment"
|
context_object_name = "attachment"
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'danger': _('Deleted part attachment')
|
'danger': _('Deleted part attachment')
|
||||||
@ -158,6 +168,8 @@ class PartTestTemplateCreate(AjaxCreateView):
|
|||||||
form_class = part_forms.EditPartTestTemplateForm
|
form_class = part_forms.EditPartTestTemplateForm
|
||||||
ajax_form_title = _("Create Test Template")
|
ajax_form_title = _("Create Test Template")
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
|
|
||||||
initials = super().get_initial()
|
initials = super().get_initial()
|
||||||
@ -185,6 +197,8 @@ class PartTestTemplateEdit(AjaxUpdateView):
|
|||||||
form_class = part_forms.EditPartTestTemplateForm
|
form_class = part_forms.EditPartTestTemplateForm
|
||||||
ajax_form_title = _("Edit Test Template")
|
ajax_form_title = _("Edit Test Template")
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
|
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -199,6 +213,8 @@ class PartTestTemplateDelete(AjaxDeleteView):
|
|||||||
model = PartTestTemplate
|
model = PartTestTemplate
|
||||||
ajax_form_title = _("Delete Test Template")
|
ajax_form_title = _("Delete Test Template")
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
|
|
||||||
class PartSetCategory(AjaxUpdateView):
|
class PartSetCategory(AjaxUpdateView):
|
||||||
""" View for settings the part category for multiple parts at once """
|
""" View for settings the part category for multiple parts at once """
|
||||||
@ -207,6 +223,8 @@ class PartSetCategory(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Set Part Category')
|
ajax_form_title = _('Set Part Category')
|
||||||
form_class = part_forms.SetPartCategoryForm
|
form_class = part_forms.SetPartCategoryForm
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
category = None
|
category = None
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
@ -290,6 +308,8 @@ class MakePartVariant(AjaxCreateView):
|
|||||||
ajax_form_title = _('Create Variant')
|
ajax_form_title = _('Create Variant')
|
||||||
ajax_template_name = 'part/variant_part.html'
|
ajax_template_name = 'part/variant_part.html'
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_part_template(self):
|
def get_part_template(self):
|
||||||
return get_object_or_404(Part, id=self.kwargs['pk'])
|
return get_object_or_404(Part, id=self.kwargs['pk'])
|
||||||
|
|
||||||
@ -368,6 +388,8 @@ class PartDuplicate(AjaxCreateView):
|
|||||||
ajax_form_title = _("Duplicate Part")
|
ajax_form_title = _("Duplicate Part")
|
||||||
ajax_template_name = "part/copy_part.html"
|
ajax_template_name = "part/copy_part.html"
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'success': _('Copied part')
|
'success': _('Copied part')
|
||||||
@ -491,6 +513,8 @@ class PartCreate(AjaxCreateView):
|
|||||||
ajax_form_title = _('Create new part')
|
ajax_form_title = _('Create new part')
|
||||||
ajax_template_name = 'part/create_part.html'
|
ajax_template_name = 'part/create_part.html'
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'success': _("Created new part"),
|
'success': _("Created new part"),
|
||||||
@ -613,6 +637,8 @@ class PartNotes(UpdateView):
|
|||||||
template_name = 'part/notes.html'
|
template_name = 'part/notes.html'
|
||||||
model = Part
|
model = Part
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
fields = ['notes']
|
fields = ['notes']
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@ -634,7 +660,7 @@ class PartNotes(UpdateView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class PartDetail(DetailView):
|
class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for Part object
|
""" Detail view for Part object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -642,6 +668,8 @@ class PartDetail(DetailView):
|
|||||||
queryset = Part.objects.all().select_related('category')
|
queryset = Part.objects.all().select_related('category')
|
||||||
template_name = 'part/detail.html'
|
template_name = 'part/detail.html'
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
# Add in some extra context information based on query params
|
# Add in some extra context information based on query params
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
""" Provide extra context data to template
|
""" Provide extra context data to template
|
||||||
@ -706,6 +734,8 @@ class PartQRCode(QRCodeView):
|
|||||||
|
|
||||||
ajax_form_title = _("Part QR Code")
|
ajax_form_title = _("Part QR Code")
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
def get_qr_data(self):
|
def get_qr_data(self):
|
||||||
""" Generate QR code data for the Part """
|
""" Generate QR code data for the Part """
|
||||||
|
|
||||||
@ -722,8 +752,11 @@ class PartImageUpload(AjaxUpdateView):
|
|||||||
model = Part
|
model = Part
|
||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Upload Part Image')
|
ajax_form_title = _('Upload Part Image')
|
||||||
|
|
||||||
form_class = part_forms.PartImageForm
|
form_class = part_forms.PartImageForm
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'success': _('Updated part image'),
|
'success': _('Updated part image'),
|
||||||
@ -737,6 +770,8 @@ class PartImageSelect(AjaxUpdateView):
|
|||||||
ajax_template_name = 'part/select_image.html'
|
ajax_template_name = 'part/select_image.html'
|
||||||
ajax_form_title = _('Select Part Image')
|
ajax_form_title = _('Select Part Image')
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'image',
|
'image',
|
||||||
]
|
]
|
||||||
@ -778,6 +813,8 @@ class PartEdit(AjaxUpdateView):
|
|||||||
ajax_form_title = _('Edit Part Properties')
|
ajax_form_title = _('Edit Part Properties')
|
||||||
context_object_name = 'part'
|
context_object_name = 'part'
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
""" Create form for Part editing.
|
""" Create form for Part editing.
|
||||||
Overrides default get_form() method to limit the choices
|
Overrides default get_form() method to limit the choices
|
||||||
@ -802,6 +839,8 @@ class BomValidate(AjaxUpdateView):
|
|||||||
context_object_name = 'part'
|
context_object_name = 'part'
|
||||||
form_class = part_forms.BomValidateForm
|
form_class = part_forms.BomValidateForm
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_context(self):
|
def get_context(self):
|
||||||
return {
|
return {
|
||||||
'part': self.get_object(),
|
'part': self.get_object(),
|
||||||
@ -832,7 +871,7 @@ class BomValidate(AjaxUpdateView):
|
|||||||
return self.renderJsonResponse(request, form, data, context=self.get_context())
|
return self.renderJsonResponse(request, form, data, context=self.get_context())
|
||||||
|
|
||||||
|
|
||||||
class BomUpload(FormView):
|
class BomUpload(InvenTreeRoleMixin, FormView):
|
||||||
""" View for uploading a BOM file, and handling BOM data importing.
|
""" View for uploading a BOM file, and handling BOM data importing.
|
||||||
|
|
||||||
The BOM upload process is as follows:
|
The BOM upload process is as follows:
|
||||||
@ -868,6 +907,8 @@ class BomUpload(FormView):
|
|||||||
missing_columns = []
|
missing_columns = []
|
||||||
allowed_parts = []
|
allowed_parts = []
|
||||||
|
|
||||||
|
role_required = ('part.change', 'part.add')
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
part = self.get_object()
|
part = self.get_object()
|
||||||
return reverse('upload-bom', kwargs={'pk': part.id})
|
return reverse('upload-bom', kwargs={'pk': part.id})
|
||||||
@ -1466,6 +1507,8 @@ class BomUpload(FormView):
|
|||||||
class PartExport(AjaxView):
|
class PartExport(AjaxView):
|
||||||
""" Export a CSV file containing information on multiple parts """
|
""" Export a CSV file containing information on multiple parts """
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
def get_parts(self, request):
|
def get_parts(self, request):
|
||||||
""" Extract part list from the POST parameters.
|
""" Extract part list from the POST parameters.
|
||||||
Parts can be supplied as:
|
Parts can be supplied as:
|
||||||
@ -1543,6 +1586,8 @@ class BomDownload(AjaxView):
|
|||||||
- File format should be passed as a query param e.g. ?format=csv
|
- File format should be passed as a query param e.g. ?format=csv
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
model = Part
|
model = Part
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
@ -1596,6 +1641,8 @@ class BomExport(AjaxView):
|
|||||||
form_class = part_forms.BomExportForm
|
form_class = part_forms.BomExportForm
|
||||||
ajax_form_title = _("Export Bill of Materials")
|
ajax_form_title = _("Export Bill of Materials")
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
return self.renderJsonResponse(request, self.form_class())
|
return self.renderJsonResponse(request, self.form_class())
|
||||||
|
|
||||||
@ -1645,6 +1692,8 @@ class PartDelete(AjaxDeleteView):
|
|||||||
ajax_form_title = _('Confirm Part Deletion')
|
ajax_form_title = _('Confirm Part Deletion')
|
||||||
context_object_name = 'part'
|
context_object_name = 'part'
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
success_url = '/part/'
|
success_url = '/part/'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
@ -1661,6 +1710,8 @@ class PartPricing(AjaxView):
|
|||||||
ajax_form_title = _("Part Pricing")
|
ajax_form_title = _("Part Pricing")
|
||||||
form_class = part_forms.PartPriceForm
|
form_class = part_forms.PartPriceForm
|
||||||
|
|
||||||
|
role_required = ['sales_order.view', 'part.view']
|
||||||
|
|
||||||
def get_part(self):
|
def get_part(self):
|
||||||
try:
|
try:
|
||||||
return Part.objects.get(id=self.kwargs['pk'])
|
return Part.objects.get(id=self.kwargs['pk'])
|
||||||
@ -1778,6 +1829,8 @@ class PartPricing(AjaxView):
|
|||||||
class PartParameterTemplateCreate(AjaxCreateView):
|
class PartParameterTemplateCreate(AjaxCreateView):
|
||||||
""" View for creating a new PartParameterTemplate """
|
""" View for creating a new PartParameterTemplate """
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
form_class = part_forms.EditPartParameterTemplateForm
|
form_class = part_forms.EditPartParameterTemplateForm
|
||||||
ajax_form_title = _('Create Part Parameter Template')
|
ajax_form_title = _('Create Part Parameter Template')
|
||||||
@ -1786,6 +1839,8 @@ class PartParameterTemplateCreate(AjaxCreateView):
|
|||||||
class PartParameterTemplateEdit(AjaxUpdateView):
|
class PartParameterTemplateEdit(AjaxUpdateView):
|
||||||
""" View for editing a PartParameterTemplate """
|
""" View for editing a PartParameterTemplate """
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
form_class = part_forms.EditPartParameterTemplateForm
|
form_class = part_forms.EditPartParameterTemplateForm
|
||||||
ajax_form_title = _('Edit Part Parameter Template')
|
ajax_form_title = _('Edit Part Parameter Template')
|
||||||
@ -1794,6 +1849,8 @@ class PartParameterTemplateEdit(AjaxUpdateView):
|
|||||||
class PartParameterTemplateDelete(AjaxDeleteView):
|
class PartParameterTemplateDelete(AjaxDeleteView):
|
||||||
""" View for deleting an existing PartParameterTemplate """
|
""" View for deleting an existing PartParameterTemplate """
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
model = PartParameterTemplate
|
model = PartParameterTemplate
|
||||||
ajax_form_title = _("Delete Part Parameter Template")
|
ajax_form_title = _("Delete Part Parameter Template")
|
||||||
|
|
||||||
@ -1801,6 +1858,8 @@ class PartParameterTemplateDelete(AjaxDeleteView):
|
|||||||
class PartParameterCreate(AjaxCreateView):
|
class PartParameterCreate(AjaxCreateView):
|
||||||
""" View for creating a new PartParameter """
|
""" View for creating a new PartParameter """
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
model = PartParameter
|
model = PartParameter
|
||||||
form_class = part_forms.EditPartParameterForm
|
form_class = part_forms.EditPartParameterForm
|
||||||
ajax_form_title = _('Create Part Parameter')
|
ajax_form_title = _('Create Part Parameter')
|
||||||
@ -1851,6 +1910,8 @@ class PartParameterCreate(AjaxCreateView):
|
|||||||
class PartParameterEdit(AjaxUpdateView):
|
class PartParameterEdit(AjaxUpdateView):
|
||||||
""" View for editing a PartParameter """
|
""" View for editing a PartParameter """
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
model = PartParameter
|
model = PartParameter
|
||||||
form_class = part_forms.EditPartParameterForm
|
form_class = part_forms.EditPartParameterForm
|
||||||
ajax_form_title = _('Edit Part Parameter')
|
ajax_form_title = _('Edit Part Parameter')
|
||||||
@ -1865,17 +1926,62 @@ class PartParameterEdit(AjaxUpdateView):
|
|||||||
class PartParameterDelete(AjaxDeleteView):
|
class PartParameterDelete(AjaxDeleteView):
|
||||||
""" View for deleting a PartParameter """
|
""" View for deleting a PartParameter """
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
model = PartParameter
|
model = PartParameter
|
||||||
ajax_template_name = 'part/param_delete.html'
|
ajax_template_name = 'part/param_delete.html'
|
||||||
ajax_form_title = _('Delete Part Parameter')
|
ajax_form_title = _('Delete Part Parameter')
|
||||||
|
|
||||||
|
|
||||||
class CategoryDetail(DetailView):
|
class CategoryDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for PartCategory """
|
""" Detail view for PartCategory """
|
||||||
|
|
||||||
model = PartCategory
|
model = PartCategory
|
||||||
context_object_name = 'category'
|
context_object_name = 'category'
|
||||||
queryset = PartCategory.objects.all().prefetch_related('children')
|
queryset = PartCategory.objects.all().prefetch_related('children')
|
||||||
template_name = 'part/category.html'
|
template_name = 'part/category_partlist.html'
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
|
context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
context['part_count'] = kwargs['object'].partcount()
|
||||||
|
except KeyError:
|
||||||
|
context['part_count'] = 0
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryParametric(CategoryDetail):
|
||||||
|
""" Parametric view for PartCategory """
|
||||||
|
|
||||||
|
template_name = 'part/category_parametric.html'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
|
||||||
|
context = super(CategoryParametric, self).get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
|
# Get current category
|
||||||
|
category = kwargs.get('object', None)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
cascade = kwargs.get('cascade', True)
|
||||||
|
# Prefetch parts parameters
|
||||||
|
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
|
||||||
|
# Get table headers (unique parameters names)
|
||||||
|
context['headers'] = category.get_unique_parameters(cascade=cascade,
|
||||||
|
prefetch=parts_parameters)
|
||||||
|
# Insert part information
|
||||||
|
context['headers'].insert(0, 'description')
|
||||||
|
context['headers'].insert(0, 'part')
|
||||||
|
# Get parameters data
|
||||||
|
context['parameters'] = category.get_parts_parameters(cascade=cascade,
|
||||||
|
prefetch=parts_parameters)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class CategoryEdit(AjaxUpdateView):
|
class CategoryEdit(AjaxUpdateView):
|
||||||
@ -1885,6 +1991,8 @@ class CategoryEdit(AjaxUpdateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit Part Category')
|
ajax_form_title = _('Edit Part Category')
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
|
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
|
||||||
|
|
||||||
@ -1922,6 +2030,8 @@ class CategoryDelete(AjaxDeleteView):
|
|||||||
context_object_name = 'category'
|
context_object_name = 'category'
|
||||||
success_url = '/part/'
|
success_url = '/part/'
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'danger': _('Part category was deleted'),
|
'danger': _('Part category was deleted'),
|
||||||
@ -1936,6 +2046,8 @@ class CategoryCreate(AjaxCreateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
form_class = part_forms.EditCategoryForm
|
form_class = part_forms.EditCategoryForm
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
""" Add extra context data to template.
|
""" Add extra context data to template.
|
||||||
|
|
||||||
@ -1971,12 +2083,14 @@ class CategoryCreate(AjaxCreateView):
|
|||||||
return initials
|
return initials
|
||||||
|
|
||||||
|
|
||||||
class BomItemDetail(DetailView):
|
class BomItemDetail(InvenTreeRoleMixin, DetailView):
|
||||||
""" Detail view for BomItem """
|
""" Detail view for BomItem """
|
||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
queryset = BomItem.objects.all()
|
queryset = BomItem.objects.all()
|
||||||
template_name = 'part/bom-detail.html'
|
template_name = 'part/bom-detail.html'
|
||||||
|
|
||||||
|
role_required = 'part.view'
|
||||||
|
|
||||||
|
|
||||||
class BomItemCreate(AjaxCreateView):
|
class BomItemCreate(AjaxCreateView):
|
||||||
""" Create view for making a new BomItem object """
|
""" Create view for making a new BomItem object """
|
||||||
@ -1985,6 +2099,8 @@ class BomItemCreate(AjaxCreateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Create BOM item')
|
ajax_form_title = _('Create BOM item')
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
""" Override get_form() method to reduce Part selection options.
|
""" Override get_form() method to reduce Part selection options.
|
||||||
|
|
||||||
@ -2051,6 +2167,8 @@ class BomItemEdit(AjaxUpdateView):
|
|||||||
ajax_template_name = 'modal_form.html'
|
ajax_template_name = 'modal_form.html'
|
||||||
ajax_form_title = _('Edit BOM item')
|
ajax_form_title = _('Edit BOM item')
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
""" Override get_form() method to filter part selection options
|
""" Override get_form() method to filter part selection options
|
||||||
|
|
||||||
@ -2099,6 +2217,8 @@ class BomItemDelete(AjaxDeleteView):
|
|||||||
context_object_name = 'item'
|
context_object_name = 'item'
|
||||||
ajax_form_title = _('Confim BOM item deletion')
|
ajax_form_title = _('Confim BOM item deletion')
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
|
||||||
|
|
||||||
class PartSalePriceBreakCreate(AjaxCreateView):
|
class PartSalePriceBreakCreate(AjaxCreateView):
|
||||||
""" View for creating a sale price break for a part """
|
""" View for creating a sale price break for a part """
|
||||||
@ -2107,6 +2227,8 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
|||||||
form_class = part_forms.EditPartSalePriceBreakForm
|
form_class = part_forms.EditPartSalePriceBreakForm
|
||||||
ajax_form_title = _('Add Price Break')
|
ajax_form_title = _('Add Price Break')
|
||||||
|
|
||||||
|
role_required = 'part.add'
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return {
|
return {
|
||||||
'success': _('Added new price break')
|
'success': _('Added new price break')
|
||||||
@ -2156,6 +2278,8 @@ class PartSalePriceBreakEdit(AjaxUpdateView):
|
|||||||
form_class = part_forms.EditPartSalePriceBreakForm
|
form_class = part_forms.EditPartSalePriceBreakForm
|
||||||
ajax_form_title = _('Edit Price Break')
|
ajax_form_title = _('Edit Price Break')
|
||||||
|
|
||||||
|
role_required = 'part.change'
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
|
|
||||||
form = super().get_form()
|
form = super().get_form()
|
||||||
@ -2170,3 +2294,5 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
|
|||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
ajax_form_title = _("Delete Price Break")
|
ajax_form_title = _("Delete Price Break")
|
||||||
ajax_template_name = "modal_delete_form.html"
|
ajax_template_name = "modal_delete_form.html"
|
||||||
|
|
||||||
|
role_required = 'part.delete'
|
||||||
|
@ -52,6 +52,10 @@ class StockCategoryTree(TreeSerializer):
|
|||||||
def get_items(self):
|
def get_items(self):
|
||||||
return StockLocation.objects.all().prefetch_related('stock_items', 'children')
|
return StockLocation.objects.all().prefetch_related('stock_items', 'children')
|
||||||
|
|
||||||
|
permission_classes = [
|
||||||
|
permissions.IsAuthenticated,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API detail endpoint for Stock object
|
""" API detail endpoint for Stock object
|
||||||
@ -68,7 +72,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = StockItem.objects.all()
|
queryset = StockItem.objects.all()
|
||||||
serializer_class = StockItemSerializer
|
serializer_class = StockItemSerializer
|
||||||
permission_classes = (permissions.IsAuthenticated,)
|
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
|
||||||
@ -289,10 +292,6 @@ class StockLocationList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -695,10 +694,6 @@ class StockList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -744,10 +739,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
|||||||
queryset = StockItemTestResult.objects.all()
|
queryset = StockItemTestResult.objects.all()
|
||||||
serializer_class = StockItemTestResultSerializer
|
serializer_class = StockItemTestResultSerializer
|
||||||
|
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = [
|
||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
filters.SearchFilter,
|
filters.SearchFilter,
|
||||||
@ -799,7 +790,6 @@ class StockTrackingList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = StockItemTracking.objects.all()
|
queryset = StockItemTracking.objects.all()
|
||||||
serializer_class = StockTrackingSerializer
|
serializer_class = StockTrackingSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@ -871,7 +861,6 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = StockLocation.objects.all()
|
queryset = StockLocation.objects.all()
|
||||||
serializer_class = LocationSerializer
|
serializer_class = LocationSerializer
|
||||||
permission_classes = (permissions.IsAuthenticated,)
|
|
||||||
|
|
||||||
|
|
||||||
stock_endpoints = [
|
stock_endpoints = [
|
||||||
|
@ -8,6 +8,8 @@ from __future__ import unicode_literals
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.forms.utils import ErrorDict
|
from django.forms.utils import ErrorDict
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from mptt.fields import TreeNodeChoiceField
|
from mptt.fields import TreeNodeChoiceField
|
||||||
|
|
||||||
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
|
|||||||
|
|
||||||
from report.models import TestReport
|
from report.models import TestReport
|
||||||
|
|
||||||
|
from part.models import Part
|
||||||
|
|
||||||
from .models import StockLocation, StockItem, StockItemTracking
|
from .models import StockLocation, StockItem, StockItemTracking
|
||||||
from .models import StockItemAttachment
|
from .models import StockItemAttachment
|
||||||
from .models import StockItemTestResult
|
from .models import StockItemTestResult
|
||||||
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
|
|||||||
self.fields['file_format'].choices = self.get_format_choices()
|
self.fields['file_format'].choices = self.get_format_choices()
|
||||||
|
|
||||||
|
|
||||||
|
class InstallStockForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for manually installing a stock item into another stock item
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = forms.ModelChoiceField(
|
||||||
|
queryset=Part.objects.all(),
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_item = forms.ModelChoiceField(
|
||||||
|
required=True,
|
||||||
|
queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
|
||||||
|
help_text=_('Stock item to install')
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity_to_install = RoundingDecimalFormField(
|
||||||
|
max_digits=10, decimal_places=5,
|
||||||
|
initial=1,
|
||||||
|
label=_('Quantity'),
|
||||||
|
help_text=_('Stock quantity to assign'),
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(0.001)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text=_('Notes')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StockItem
|
||||||
|
fields = [
|
||||||
|
'part',
|
||||||
|
'stock_item',
|
||||||
|
'quantity_to_install',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
data = super().clean()
|
||||||
|
|
||||||
|
stock_item = data.get('stock_item', None)
|
||||||
|
quantity = data.get('quantity_to_install', None)
|
||||||
|
|
||||||
|
if stock_item and quantity and quantity > stock_item.quantity:
|
||||||
|
raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class UninstallStockForm(forms.ModelForm):
|
class UninstallStockForm(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
Form for uninstalling a stock item which is installed in another item.
|
Form for uninstalling a stock item which is installed in another item.
|
||||||
|
18
InvenTree/stock/migrations/0052_stockitem_is_building.py
Normal file
18
InvenTree/stock/migrations/0052_stockitem_is_building.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-04 13:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0051_auto_20200928_0928'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='is_building',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
@ -130,6 +130,7 @@ class StockItem(MPTTModel):
|
|||||||
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
|
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
|
||||||
notes: Extra notes field
|
notes: Extra notes field
|
||||||
build: Link to a Build (if this stock item was created from a build)
|
build: Link to a Build (if this stock item was created from a build)
|
||||||
|
is_building: Boolean field indicating if this stock item is currently being built
|
||||||
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
|
||||||
infinite: If True this StockItem can never be exhausted
|
infinite: If True this StockItem can never be exhausted
|
||||||
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
|
||||||
@ -142,6 +143,7 @@ class StockItem(MPTTModel):
|
|||||||
build_order=None,
|
build_order=None,
|
||||||
belongs_to=None,
|
belongs_to=None,
|
||||||
customer=None,
|
customer=None,
|
||||||
|
is_building=False,
|
||||||
status__in=StockStatus.AVAILABLE_CODES
|
status__in=StockStatus.AVAILABLE_CODES
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -273,11 +275,25 @@ class StockItem(MPTTModel):
|
|||||||
# TODO - Find a test than can be perfomed...
|
# TODO - Find a test than can be perfomed...
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Ensure that the item cannot be assigned to itself
|
||||||
if self.belongs_to and self.belongs_to.pk == self.pk:
|
if self.belongs_to and self.belongs_to.pk == self.pk:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'belongs_to': _('Item cannot belong to itself')
|
'belongs_to': _('Item cannot belong to itself')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# If the item is marked as "is_building", it must point to a build!
|
||||||
|
if self.is_building and not self.build:
|
||||||
|
raise ValidationError({
|
||||||
|
'build': _("Item must have a build reference if is_building=True")
|
||||||
|
})
|
||||||
|
|
||||||
|
# If the item points to a build, check that the Part references match
|
||||||
|
if self.build:
|
||||||
|
if not self.part == self.build.part:
|
||||||
|
raise ValidationError({
|
||||||
|
'build': _("Build reference does not point to the same part object")
|
||||||
|
})
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('stock-item-detail', kwargs={'pk': self.id})
|
return reverse('stock-item-detail', kwargs={'pk': self.id})
|
||||||
|
|
||||||
@ -389,6 +405,10 @@ class StockItem(MPTTModel):
|
|||||||
related_name='build_outputs',
|
related_name='build_outputs',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_building = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
purchase_order = models.ForeignKey(
|
purchase_order = models.ForeignKey(
|
||||||
'order.PurchaseOrder',
|
'order.PurchaseOrder',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -600,12 +620,13 @@ class StockItem(MPTTModel):
|
|||||||
return self.installedItemCount() > 0
|
return self.installedItemCount() > 0
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def installIntoStockItem(self, otherItem, user, notes):
|
def installStockItem(self, otherItem, quantity, user, notes):
|
||||||
"""
|
"""
|
||||||
Install this stock item into another stock item.
|
Install another stock item into this stock item.
|
||||||
|
|
||||||
Args
|
Args
|
||||||
otherItem: The stock item to install this item into
|
otherItem: The stock item to install into this stock item
|
||||||
|
quantity: The quantity of stock to install
|
||||||
user: The user performing the operation
|
user: The user performing the operation
|
||||||
notes: Any notes associated with the operation
|
notes: Any notes associated with the operation
|
||||||
"""
|
"""
|
||||||
@ -614,18 +635,29 @@ class StockItem(MPTTModel):
|
|||||||
if self.belongs_to is not None:
|
if self.belongs_to is not None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# TODO - Are there any other checks that need to be performed at this stage?
|
# If the quantity is less than the stock item, split the stock!
|
||||||
|
stock_item = otherItem.splitStock(quantity, None, user)
|
||||||
|
|
||||||
# Mark this stock item as belonging to the other one
|
if stock_item is None:
|
||||||
self.belongs_to = otherItem
|
stock_item = otherItem
|
||||||
|
|
||||||
self.save()
|
# Assign the other stock item into this one
|
||||||
|
stock_item.belongs_to = self
|
||||||
|
stock_item.save()
|
||||||
|
|
||||||
# Add a transaction note!
|
# Add a transaction note to the other item
|
||||||
self.addTransactionNote(
|
stock_item.addTransactionNote(
|
||||||
_('Installed in stock item') + ' ' + str(otherItem.pk),
|
_('Installed into stock item') + ' ' + str(self.pk),
|
||||||
user,
|
user,
|
||||||
notes=notes
|
notes=notes,
|
||||||
|
url=self.get_absolute_url()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a transaction note to this item
|
||||||
|
self.addTransactionNote(
|
||||||
|
_('Installed stock item') + ' ' + str(stock_item.pk),
|
||||||
|
user, notes=notes,
|
||||||
|
url=stock_item.get_absolute_url()
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@ -645,16 +677,31 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
# TODO - Are there any other checks that need to be performed at this stage?
|
# TODO - Are there any other checks that need to be performed at this stage?
|
||||||
|
|
||||||
|
# Add a transaction note to the parent item
|
||||||
|
self.belongs_to.addTransactionNote(
|
||||||
|
_("Uninstalled stock item") + ' ' + str(self.pk),
|
||||||
|
user,
|
||||||
|
notes=notes,
|
||||||
|
url=self.get_absolute_url(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark this stock item as *not* belonging to anyone
|
||||||
self.belongs_to = None
|
self.belongs_to = None
|
||||||
self.location = location
|
self.location = location
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
if location:
|
||||||
|
url = location.get_absolute_url()
|
||||||
|
else:
|
||||||
|
url = ''
|
||||||
|
|
||||||
# Add a transaction note!
|
# Add a transaction note!
|
||||||
self.addTransactionNote(
|
self.addTransactionNote(
|
||||||
_('Uninstalled into location') + ' ' + str(location),
|
_('Uninstalled into location') + ' ' + str(location),
|
||||||
user,
|
user,
|
||||||
notes=notes
|
notes=notes,
|
||||||
|
url=url
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -688,6 +735,10 @@ class StockItem(MPTTModel):
|
|||||||
if self.customer is not None:
|
if self.customer is not None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Not 'in stock' if it is building
|
||||||
|
if self.is_building:
|
||||||
|
return False
|
||||||
|
|
||||||
# Not 'in stock' if the status code makes it unavailable
|
# Not 'in stock' if the status code makes it unavailable
|
||||||
if self.status in StockStatus.UNAVAILABLE_CODES:
|
if self.status in StockStatus.UNAVAILABLE_CODES:
|
||||||
return False
|
return False
|
||||||
@ -838,20 +889,20 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
# Do not split a serialized part
|
# Do not split a serialized part
|
||||||
if self.serialized:
|
if self.serialized:
|
||||||
return
|
return self
|
||||||
|
|
||||||
try:
|
try:
|
||||||
quantity = Decimal(quantity)
|
quantity = Decimal(quantity)
|
||||||
except (InvalidOperation, ValueError):
|
except (InvalidOperation, ValueError):
|
||||||
return
|
return self
|
||||||
|
|
||||||
# Doesn't make sense for a zero quantity
|
# Doesn't make sense for a zero quantity
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return
|
return self
|
||||||
|
|
||||||
# Also doesn't make sense to split the full amount
|
# Also doesn't make sense to split the full amount
|
||||||
if quantity >= self.quantity:
|
if quantity >= self.quantity:
|
||||||
return
|
return self
|
||||||
|
|
||||||
# Create a new StockItem object, duplicating relevant fields
|
# Create a new StockItem object, duplicating relevant fields
|
||||||
# Nullify the PK so a new record is created
|
# Nullify the PK so a new record is created
|
||||||
|
@ -65,7 +65,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
|||||||
{% else %}
|
{% else %}
|
||||||
<a href='{% url "part-detail" item.part.pk %}'>{{ item.part.full_name }}</a> × {% decimal item.quantity %}
|
<a href='{% url "part-detail" item.part.pk %}'>{{ item.part.full_name }}</a> × {% decimal item.quantity %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_staff and perms.stock.change_stockitem %}
|
{% if user.is_staff and roles.stock.change %}
|
||||||
<a href="{% url 'admin:stock_stockitem_change' item.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:stock_stockitem_change' item.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
|
17
InvenTree/stock/templates/stock/item_install.html
Normal file
17
InvenTree/stock/templates/stock/item_install.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block pre_form_content %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans "Install another StockItem into this item." %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans "Stock items can only be installed if they meet the following criteria" %}:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li>
|
||||||
|
<li>{% trans "The StockItem is currently in stock" %}</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
@ -10,19 +10,7 @@
|
|||||||
<h4>{% trans "Installed Stock Items" %}</h4>
|
<h4>{% trans "Installed Stock Items" %}</h4>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div id='button-toolbar'>
|
<table class='table table-striped table-condensed' id='installed-table'></table>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -30,135 +18,14 @@
|
|||||||
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
$('#installed-table').inventreeTable({
|
loadInstalledInTable(
|
||||||
formatNoMatches: function() {
|
$('#installed-table'),
|
||||||
return '{% trans "No stock items installed" %}';
|
|
||||||
},
|
|
||||||
url: "{% url 'api-stock-list' %}",
|
|
||||||
queryParams: {
|
|
||||||
installed_in: {{ item.id }},
|
|
||||||
part_detail: true,
|
|
||||||
},
|
|
||||||
name: 'stock-item-installed',
|
|
||||||
url: "{% url 'api-stock-list' %}",
|
|
||||||
showColumns: true,
|
|
||||||
columns: [
|
|
||||||
{
|
{
|
||||||
checkbox: true,
|
stock_item: {{ item.pk }},
|
||||||
title: '{% trans 'Select' %}',
|
part: {{ item.part.pk }},
|
||||||
searchable: false,
|
quantity: {{ item.quantity }},
|
||||||
switchable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'pk',
|
|
||||||
title: 'ID',
|
|
||||||
visible: false,
|
|
||||||
switchable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'part_name',
|
|
||||||
title: '{% trans "Part" %}',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
|
|
||||||
var url = `/stock/item/${row.pk}/`;
|
|
||||||
var thumb = row.part_detail.thumbnail;
|
|
||||||
var name = row.part_detail.full_name;
|
|
||||||
|
|
||||||
html = imageHoverIcon(thumb) + renderLink(name, url);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'IPN',
|
|
||||||
title: 'IPN',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return row.part_detail.IPN;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'part_description',
|
|
||||||
title: '{% trans "Description" %}',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return row.part_detail.description;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'quantity',
|
|
||||||
title: '{% trans "Stock" %}',
|
|
||||||
sortable: true,
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
|
|
||||||
var val = parseFloat(value);
|
|
||||||
|
|
||||||
// If there is a single unit with a serial number, use the serial number
|
|
||||||
if (row.serial && row.quantity == 1) {
|
|
||||||
val = '# ' + row.serial;
|
|
||||||
} else {
|
|
||||||
val = +val.toFixed(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
title: '{% trans "Status" %}',
|
|
||||||
sortable: 'true',
|
|
||||||
formatter: function(value, row, index, field) {
|
|
||||||
return stockStatusDisplay(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'batch',
|
|
||||||
title: '{% trans "Batch" %}',
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
switchable: false,
|
|
||||||
title: '',
|
|
||||||
formatter: function(value, row) {
|
|
||||||
var pk = row.pk;
|
|
||||||
|
|
||||||
var html = `<div class='btn-group float-right' role='group'>`;
|
|
||||||
|
|
||||||
html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
onLoadSuccess: function() {
|
|
||||||
|
|
||||||
var table = $('#installed-table');
|
|
||||||
|
|
||||||
// Find buttons and associate actions
|
|
||||||
table.find('.button-uninstall').click(function() {
|
|
||||||
var pk = $(this).attr('pk');
|
|
||||||
|
|
||||||
launchModalForm(
|
|
||||||
"{% url 'stock-item-uninstall' %}",
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
'items[]': [pk],
|
|
||||||
},
|
|
||||||
reload: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
|
||||||
},
|
|
||||||
buttons: [
|
|
||||||
'#stock-options',
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#multi-item-uninstall').click(function() {
|
$('#multi-item-uninstall').click(function() {
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
{% extends "modal_form.html" %}
|
{% extends "modal_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
Create serialized items from this stock item.<br>
|
{% trans "Create serialized items from this stock item." %}
|
||||||
Select quantity to serialize, and unique serial numbers.
|
<br>
|
||||||
|
{% trans "Select quantity to serialize, and unique serial numbers." %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -8,7 +8,7 @@
|
|||||||
{% if location %}
|
{% if location %}
|
||||||
<h3>
|
<h3>
|
||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
{% if user.is_staff and perms.stock.change_stocklocation %}
|
{% if user.is_staff and roles.stock.change %}
|
||||||
<a href="{% url 'admin:stock_stocklocation_change' location.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
<a href="{% url 'admin:stock_stocklocation_change' location.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
@ -3,6 +3,8 @@ from rest_framework import status
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from InvenTree.helpers import addUserPermissions
|
||||||
|
|
||||||
from .models import StockLocation
|
from .models import StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -22,6 +24,20 @@ class StockAPITestCase(APITestCase):
|
|||||||
# Create a user for auth
|
# Create a user for auth
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||||
|
|
||||||
|
# Add the necessary permissions to the user
|
||||||
|
perms = [
|
||||||
|
'view_stockitemtestresult',
|
||||||
|
'change_stockitemtestresult',
|
||||||
|
'add_stockitemtestresult',
|
||||||
|
'add_stocklocation',
|
||||||
|
'change_stocklocation',
|
||||||
|
'add_stockitem',
|
||||||
|
'change_stockitem',
|
||||||
|
]
|
||||||
|
|
||||||
|
addUserPermissions(self.user, perms)
|
||||||
|
|
||||||
self.client.login(username='testuser', password='password')
|
self.client.login(username='testuser', password='password')
|
||||||
|
|
||||||
def doPost(self, url, data={}):
|
def doPost(self, url, data={}):
|
||||||
|
@ -7,7 +7,9 @@ import datetime
|
|||||||
|
|
||||||
from .models import StockLocation, StockItem, StockItemTracking
|
from .models import StockLocation, StockItem, StockItemTracking
|
||||||
from .models import StockItemTestResult
|
from .models import StockItemTestResult
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from build.models import Build
|
||||||
|
|
||||||
|
|
||||||
class StockTest(TestCase):
|
class StockTest(TestCase):
|
||||||
@ -47,6 +49,35 @@ class StockTest(TestCase):
|
|||||||
Part.objects.rebuild()
|
Part.objects.rebuild()
|
||||||
StockItem.objects.rebuild()
|
StockItem.objects.rebuild()
|
||||||
|
|
||||||
|
def test_is_building(self):
|
||||||
|
"""
|
||||||
|
Test that the is_building flag does not count towards stock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = Part.objects.get(pk=1)
|
||||||
|
|
||||||
|
# Record the total stock count
|
||||||
|
n = part.total_stock
|
||||||
|
|
||||||
|
StockItem.objects.create(part=part, quantity=5)
|
||||||
|
|
||||||
|
# And there should be *no* items being build
|
||||||
|
self.assertEqual(part.quantity_being_built, 0)
|
||||||
|
|
||||||
|
build = Build.objects.create(part=part, title='A test build', quantity=1)
|
||||||
|
|
||||||
|
# Add some stock items which are "building"
|
||||||
|
for i in range(10):
|
||||||
|
StockItem.objects.create(
|
||||||
|
part=part, build=build,
|
||||||
|
quantity=10, is_building=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# The "is_building" quantity should not be counted here
|
||||||
|
self.assertEqual(part.total_stock, n + 5)
|
||||||
|
|
||||||
|
self.assertEqual(part.quantity_being_built, 100)
|
||||||
|
|
||||||
def test_loc_count(self):
|
def test_loc_count(self):
|
||||||
self.assertEqual(StockLocation.objects.count(), 7)
|
self.assertEqual(StockLocation.objects.count(), 7)
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ stock_item_detail_urls = [
|
|||||||
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
||||||
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
|
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
|
||||||
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
|
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
|
||||||
|
url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
|
||||||
|
|
||||||
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
||||||
|
|
||||||
|
@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StockItemInstall(AjaxUpdateView):
|
||||||
|
"""
|
||||||
|
View for manually installing stock items into
|
||||||
|
a particular stock item.
|
||||||
|
|
||||||
|
In contrast to the StockItemUninstall view,
|
||||||
|
only a single stock item can be installed at once.
|
||||||
|
|
||||||
|
The "part" to be installed must be provided in the GET query parameters.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = StockItem
|
||||||
|
form_class = StockForms.InstallStockForm
|
||||||
|
ajax_form_title = _('Install Stock Item')
|
||||||
|
ajax_template_name = "stock/item_install.html"
|
||||||
|
|
||||||
|
part = None
|
||||||
|
|
||||||
|
def get_stock_items(self):
|
||||||
|
"""
|
||||||
|
Return a list of stock items suitable for displaying to the user.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Items must be in stock
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
- Items can be filtered by Part reference
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
|
# Filter by Part association
|
||||||
|
|
||||||
|
# Look at GET params
|
||||||
|
part_id = self.request.GET.get('part', None)
|
||||||
|
|
||||||
|
if part_id is None:
|
||||||
|
# Look at POST params
|
||||||
|
part_id = self.request.POST.get('part', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.part = Part.objects.get(pk=part_id)
|
||||||
|
items = items.filter(part=self.part)
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
self.part = None
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def get_initial(self):
|
||||||
|
|
||||||
|
initials = super().get_initial()
|
||||||
|
|
||||||
|
items = self.get_stock_items()
|
||||||
|
|
||||||
|
# If there is a single stock item available, we can use it!
|
||||||
|
if items.count() == 1:
|
||||||
|
item = items.first()
|
||||||
|
initials['stock_item'] = item.pk
|
||||||
|
initials['quantity_to_install'] = item.quantity
|
||||||
|
|
||||||
|
if self.part:
|
||||||
|
initials['part'] = self.part
|
||||||
|
|
||||||
|
return initials
|
||||||
|
|
||||||
|
def get_form(self):
|
||||||
|
|
||||||
|
form = super().get_form()
|
||||||
|
|
||||||
|
form.fields['stock_item'].queryset = self.get_stock_items()
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
||||||
|
form = self.get_form()
|
||||||
|
|
||||||
|
valid = form.is_valid()
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
# We assume by this point that we have a valid stock_item and quantity values
|
||||||
|
data = form.cleaned_data
|
||||||
|
|
||||||
|
other_stock_item = data['stock_item']
|
||||||
|
quantity = data['quantity_to_install']
|
||||||
|
notes = data['notes']
|
||||||
|
|
||||||
|
# Install the other stock item into this one
|
||||||
|
this_stock_item = self.get_object()
|
||||||
|
|
||||||
|
this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'form_valid': valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.renderJsonResponse(request, form, data=data)
|
||||||
|
|
||||||
|
|
||||||
class StockItemUninstall(AjaxView, FormMixin):
|
class StockItemUninstall(AjaxView, FormMixin):
|
||||||
"""
|
"""
|
||||||
View for uninstalling one or more StockItems,
|
View for uninstalling one or more StockItems,
|
||||||
|
18
InvenTree/templates/403.html
Normal file
18
InvenTree/templates/403.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
InvenTree | {% trans "Permission Denied" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<h3>{% trans "Permission Denied" %}</h3>
|
||||||
|
|
||||||
|
<div class='alert alert-danger alert-block'>
|
||||||
|
{% trans "You do not have permission to view this page." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
InvenTree | Index
|
InvenTree | {% trans "Index" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -9,18 +9,26 @@ InvenTree | Index
|
|||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
|
{% if roles.part.view %}
|
||||||
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
|
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
|
||||||
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
|
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
|
||||||
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
|
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
|
||||||
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
|
{% endif %}
|
||||||
|
{% if roles.build.view %}
|
||||||
|
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class='col-sm-6'>
|
<div class='col-sm-6'>
|
||||||
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
|
{% if roles.stock.view %}
|
||||||
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
|
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
|
||||||
{% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
|
{% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.purchase_order.view %}
|
||||||
|
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.sales_order.view %}
|
||||||
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
|
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -39,6 +39,7 @@
|
|||||||
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
|
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
|
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
|
<link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/bootstrap-table-filter-control.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
||||||
<link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
|
<link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
|
||||||
|
|
||||||
@ -99,6 +100,8 @@ InvenTree
|
|||||||
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-en-US.min.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-en-US.min.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-group-by.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-group-by.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-toggle.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-toggle.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script>
|
||||||
|
<!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> -->
|
||||||
|
|
||||||
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
|
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
|
||||||
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
|
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
|
||||||
|
@ -169,6 +169,10 @@ function loadBomTable(table, options) {
|
|||||||
// Let's make it a bit more pretty
|
// Let's make it a bit more pretty
|
||||||
text = parseFloat(text);
|
text = parseFloat(text);
|
||||||
|
|
||||||
|
if (row.optional) {
|
||||||
|
text += " ({% trans "Optional" %})";
|
||||||
|
}
|
||||||
|
|
||||||
if (row.overage) {
|
if (row.overage) {
|
||||||
text += "<small> (+" + row.overage + ") </small>";
|
text += "<small> (+" + row.overage + ") </small>";
|
||||||
}
|
}
|
||||||
|
@ -163,6 +163,72 @@ function loadSimplePartTable(table, url, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadParametricPartTable(table, options={}) {
|
||||||
|
/* Load parametric table for part parameters
|
||||||
|
*
|
||||||
|
* Args:
|
||||||
|
* - table: HTML reference to the table
|
||||||
|
* - table_headers: Unique parameters found in category
|
||||||
|
* - table_data: Parameters data
|
||||||
|
*/
|
||||||
|
|
||||||
|
var table_headers = options.headers
|
||||||
|
var table_data = options.data
|
||||||
|
|
||||||
|
var columns = [];
|
||||||
|
|
||||||
|
for (header of table_headers) {
|
||||||
|
if (header === 'part') {
|
||||||
|
columns.push({
|
||||||
|
field: header,
|
||||||
|
title: '{% trans 'Part' %}',
|
||||||
|
sortable: true,
|
||||||
|
sortName: 'name',
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
|
||||||
|
var name = '';
|
||||||
|
|
||||||
|
if (row.IPN) {
|
||||||
|
name += row.IPN + ' | ' + row.name;
|
||||||
|
} else {
|
||||||
|
name += row.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLink(name, '/part/' + row.pk + '/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (header === 'description') {
|
||||||
|
columns.push({
|
||||||
|
field: header,
|
||||||
|
title: '{% trans 'Description' %}',
|
||||||
|
sortable: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
columns.push({
|
||||||
|
field: header,
|
||||||
|
title: header,
|
||||||
|
sortable: true,
|
||||||
|
filterControl: 'input',
|
||||||
|
/* TODO: Search icons are not displayed */
|
||||||
|
/*clear: 'fa-times icon-red',*/
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(table).inventreeTable({
|
||||||
|
sortName: 'part',
|
||||||
|
queryParams: table_headers,
|
||||||
|
groupBy: false,
|
||||||
|
name: options.name || 'parametric',
|
||||||
|
formatNoMatches: function() { return "{% trans "No parts found" %}"; },
|
||||||
|
columns: columns,
|
||||||
|
showColumns: true,
|
||||||
|
data: table_data,
|
||||||
|
filterControl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function loadPartTable(table, url, options={}) {
|
function loadPartTable(table, url, options={}) {
|
||||||
/* Load part listing data into specified table.
|
/* Load part listing data into specified table.
|
||||||
*
|
*
|
||||||
|
@ -470,11 +470,17 @@ function loadStockTable(table, options) {
|
|||||||
|
|
||||||
if (row.customer) {
|
if (row.customer) {
|
||||||
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
|
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
|
||||||
} else if (row.build_order) {
|
} else {
|
||||||
|
if (row.build_order) {
|
||||||
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
|
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
|
||||||
} else if (row.sales_order) {
|
} else if (row.sales_order) {
|
||||||
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
|
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.belongs_to) {
|
||||||
|
html += `<span class='fas fa-box label-right' title='{% trans "Stock item has been installed in another item" %}'></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Special stock status codes
|
// Special stock status codes
|
||||||
|
|
||||||
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
|
|||||||
} else if (row.customer) {
|
} else if (row.customer) {
|
||||||
var text = "{% trans "Shipped to customer" %}";
|
var text = "{% trans "Shipped to customer" %}";
|
||||||
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
|
return renderLink(text, `/company/${row.customer}/assigned-stock/`);
|
||||||
|
} else if (row.sales_order) {
|
||||||
|
var text = `{% trans "Assigned to sales order" %}`;
|
||||||
|
return renderLink(text, `/order/sales-order/${row.sales_order}/`);
|
||||||
}
|
}
|
||||||
else if (value) {
|
else if (value) {
|
||||||
return renderLink(value, `/stock/location/${row.location}/`);
|
return renderLink(value, `/stock/location/${row.location}/`);
|
||||||
@ -799,3 +808,300 @@ function createNewStockItem(options) {
|
|||||||
|
|
||||||
launchModalForm("{% url 'stock-item-create' %}", options);
|
launchModalForm("{% url 'stock-item-create' %}", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadInstalledInTable(table, options) {
|
||||||
|
/*
|
||||||
|
* Display a table showing the stock items which are installed in this stock item.
|
||||||
|
* This is a multi-level tree table, where the "top level" items are Part objects,
|
||||||
|
* and the children of each top-level item are the associated installed stock items.
|
||||||
|
*
|
||||||
|
* The process for retrieving data and displaying the table is as follows:
|
||||||
|
*
|
||||||
|
* A) Get BOM data for the stock item
|
||||||
|
* - It is assumed that the stock item will be for an assembly
|
||||||
|
* (otherwise why are we installing stuff anyway?)
|
||||||
|
* - Request BOM items for stock_item.part (and only for trackable sub items)
|
||||||
|
*
|
||||||
|
* B) Add parts to table
|
||||||
|
* - Create rows for each trackable sub-part in the table
|
||||||
|
*
|
||||||
|
* C) Gather installed stock item data
|
||||||
|
* - Get the list of installed stock items via the API
|
||||||
|
* - If the Part reference is already in the table, add the sub-item as a child
|
||||||
|
* - If this is a stock item for a *new* part, request that part from the API,
|
||||||
|
* and add that part as a new row, then add the stock item as a child of that part
|
||||||
|
*
|
||||||
|
* D) Enjoy!
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* And the options object contains the following things:
|
||||||
|
*
|
||||||
|
* - stock_item: The PK of the master stock_item object
|
||||||
|
* - part: The PK of the Part reference of the stock_item object
|
||||||
|
* - quantity: The quantity of the stock item
|
||||||
|
*/
|
||||||
|
|
||||||
|
function updateCallbacks() {
|
||||||
|
// Setup callback functions when buttons are pressed
|
||||||
|
table.find('.button-install').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
`/stock/item/${options.stock_item}/install/`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
part: pk,
|
||||||
|
},
|
||||||
|
success: function() {
|
||||||
|
// Refresh entire table!
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
table.inventreeTable(
|
||||||
|
{
|
||||||
|
url: "{% url 'api-bom-list' %}",
|
||||||
|
queryParams: {
|
||||||
|
part: options.part,
|
||||||
|
trackable: true,
|
||||||
|
sub_part_detail: true,
|
||||||
|
},
|
||||||
|
showColumns: false,
|
||||||
|
name: 'installed-in',
|
||||||
|
detailView: true,
|
||||||
|
detailViewByClick: true,
|
||||||
|
detailFilter: function(index, row) {
|
||||||
|
return row.installed_count && row.installed_count > 0;
|
||||||
|
},
|
||||||
|
detailFormatter: function(index, row, element) {
|
||||||
|
var subTableId = `installed-table-${row.sub_part}`;
|
||||||
|
|
||||||
|
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
|
||||||
|
|
||||||
|
element.html(html);
|
||||||
|
|
||||||
|
var subTable = $(`#${subTableId}`);
|
||||||
|
|
||||||
|
// Display a "sub table" showing all the linked stock items
|
||||||
|
subTable.bootstrapTable({
|
||||||
|
data: row.installed_items,
|
||||||
|
showHeader: true,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'item',
|
||||||
|
title: '{% trans "Stock Item" %}',
|
||||||
|
formatter: function(value, subrow, index, field) {
|
||||||
|
|
||||||
|
var pk = subrow.pk;
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
if (subrow.serial && subrow.quantity == 1) {
|
||||||
|
html += `{% trans "Serial" %}: ${subrow.serial}`;
|
||||||
|
} else {
|
||||||
|
html += `{% trans "Quantity" %}: ${subrow.quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLink(html, `/stock/item/${subrow.pk}/`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '{% trans "Status" %}',
|
||||||
|
formatter: function(value, subrow, index, field) {
|
||||||
|
return stockStatusDisplay(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'batch',
|
||||||
|
title: '{% trans "Batch" %}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
title: '',
|
||||||
|
formatter: function(value, subrow, index) {
|
||||||
|
|
||||||
|
var pk = subrow.pk;
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// Add some buttons yo!
|
||||||
|
html += `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans "Uninstall stock item" %}");
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onPostBody: function() {
|
||||||
|
// Setup button callbacks
|
||||||
|
subTable.find('.button-uninstall').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
"{% url 'stock-item-uninstall' %}",
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
'items[]': [pk],
|
||||||
|
},
|
||||||
|
success: function() {
|
||||||
|
// Refresh entire table!
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
checkbox: true,
|
||||||
|
title: '{% trans 'Select' %}',
|
||||||
|
searchable: false,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
title: 'ID',
|
||||||
|
visible: false,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'part',
|
||||||
|
title: '{% trans "Part" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
|
||||||
|
var url = `/part/${row.sub_part}/`;
|
||||||
|
var thumb = row.sub_part_detail.thumbnail;
|
||||||
|
var name = row.sub_part_detail.full_name;
|
||||||
|
|
||||||
|
html = imageHoverIcon(thumb) + renderLink(name, url);
|
||||||
|
|
||||||
|
if (row.not_in_bom) {
|
||||||
|
html = `<i>${html}</i>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'installed',
|
||||||
|
title: '{% trans "Installed" %}',
|
||||||
|
sortable: false,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
// Construct a progress showing how many items have been installed
|
||||||
|
|
||||||
|
var installed = row.installed_count || 0;
|
||||||
|
var required = row.quantity || 0;
|
||||||
|
|
||||||
|
required *= options.quantity;
|
||||||
|
|
||||||
|
var progress = makeProgressBar(installed, required, {
|
||||||
|
id: row.sub_part.pk,
|
||||||
|
});
|
||||||
|
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
switchable: false,
|
||||||
|
formatter: function(value, row) {
|
||||||
|
var pk = row.sub_part;
|
||||||
|
|
||||||
|
var html = `<div class='btn-group float-right' role='group'>`;
|
||||||
|
|
||||||
|
html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}');
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onLoadSuccess: function() {
|
||||||
|
// Grab a list of parts which are actually installed in this stock item
|
||||||
|
|
||||||
|
inventreeGet(
|
||||||
|
"{% url 'api-stock-list' %}",
|
||||||
|
{
|
||||||
|
installed_in: options.stock_item,
|
||||||
|
part_detail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(stock_items) {
|
||||||
|
|
||||||
|
var table_data = table.bootstrapTable('getData');
|
||||||
|
|
||||||
|
stock_items.forEach(function(item) {
|
||||||
|
|
||||||
|
var match = false;
|
||||||
|
|
||||||
|
for (var idx = 0; idx < table_data.length; idx++) {
|
||||||
|
|
||||||
|
var row = table_data[idx];
|
||||||
|
|
||||||
|
// Check each row in the table to see if this stock item matches
|
||||||
|
table_data.forEach(function(row) {
|
||||||
|
|
||||||
|
// Match on "sub_part"
|
||||||
|
if (row.sub_part == item.part) {
|
||||||
|
|
||||||
|
// First time?
|
||||||
|
if (row.installed_count == null) {
|
||||||
|
row.installed_count = 0;
|
||||||
|
row.installed_items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
row.installed_count += item.quantity;
|
||||||
|
row.installed_items.push(item);
|
||||||
|
|
||||||
|
// Push the row back into the table
|
||||||
|
table.bootstrapTable('updateRow', idx, row, true);
|
||||||
|
|
||||||
|
match = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// The stock item did *not* match any items in the BOM!
|
||||||
|
// Add a new row to the table...
|
||||||
|
|
||||||
|
// Contruct a new "row" to add to the table
|
||||||
|
var new_row = {
|
||||||
|
sub_part: item.part,
|
||||||
|
sub_part_detail: item.part_detail,
|
||||||
|
not_in_bom: true,
|
||||||
|
installed_count: item.quantity,
|
||||||
|
installed_items: [item],
|
||||||
|
};
|
||||||
|
|
||||||
|
table.bootstrapTable('append', [new_row]);
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update button callback links
|
||||||
|
updateCallbacks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
updateCallbacks();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
@ -15,9 +15,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="navbar-collapse collapse">
|
<div class="navbar-collapse collapse">
|
||||||
<ul class="nav navbar-nav">
|
<ul class="nav navbar-nav">
|
||||||
|
{% if roles.part.view %}
|
||||||
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
|
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.stock.view %}
|
||||||
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
|
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.build.view %}
|
||||||
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
|
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.purchase_order.view %}
|
||||||
<li class='nav navbar-nav'>
|
<li class='nav navbar-nav'>
|
||||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
|
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
@ -26,6 +33,8 @@
|
|||||||
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
|
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.sales_order.view %}
|
||||||
<li class='nav navbar-nav'>
|
<li class='nav navbar-nav'>
|
||||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
||||||
<ul class='dropdown-menu'>
|
<ul class='dropdown-menu'>
|
||||||
@ -33,6 +42,7 @@
|
|||||||
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
|
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="nav navbar-nav navbar-right">
|
<ul class="nav navbar-nav navbar-right">
|
||||||
{% include "search_form.html" %}
|
{% include "search_form.html" %}
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
<div>
|
<div>
|
||||||
<input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
|
<input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not roles.part.change %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
|
||||||
</div>
|
</div>
|
@ -6,19 +6,27 @@
|
|||||||
<button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>{% trans "Export" %}</button>
|
<button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>{% trans "Export" %}</button>
|
||||||
{% if read_only %}
|
{% if read_only %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if roles.stock.add %}
|
||||||
<button class="btn btn-success" id='item-create'>{% trans "New Stock Item" %}</button>
|
<button class="btn btn-success" id='item-create'>{% trans "New Stock Item" %}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.stock.change or roles.stock.delete %}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
|
{% if roles.stock.change %}
|
||||||
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'>{% trans "Add stock" %}</a></li>
|
<li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'>{% trans "Add stock" %}</a></li>
|
||||||
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'>{% trans "Remove stock" %}</a></li>
|
<li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'>{% trans "Remove stock" %}</a></li>
|
||||||
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'>{% trans "Count stock" %}</a></li>
|
<li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'>{% trans "Count stock" %}</a></li>
|
||||||
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'>{% trans "Move stock" %}</a></li>
|
<li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'>{% trans "Move stock" %}</a></li>
|
||||||
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'>{% trans "Order stock" %}</a></li>
|
<li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'>{% trans "Order stock" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if roles.stock.delete %}
|
||||||
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
|
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class='filter-list' id='filter-list-stock'>
|
<div class='filter-list' id='filter-list-stock'>
|
||||||
<!-- An empty div in which the filter list will be constructed -->
|
<!-- An empty div in which the filter list will be constructed -->
|
||||||
|
@ -1,3 +1,132 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
# from django.contrib import admin
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
|
||||||
|
from users.models import RuleSet
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Class for displaying inline RuleSet data in the Group admin page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = RuleSet
|
||||||
|
can_delete = False
|
||||||
|
verbose_name = 'Ruleset'
|
||||||
|
verbose_plural_name = 'Rulesets'
|
||||||
|
fields = ['name'] + [option for option in RuleSet.RULE_OPTIONS]
|
||||||
|
readonly_fields = ['name']
|
||||||
|
max_num = len(RuleSet.RULESET_CHOICES)
|
||||||
|
min_num = 1
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeGroupAdminForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Custom admin form for the Group model.
|
||||||
|
|
||||||
|
Adds the ability for editing user membership directly in the group admin page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
exclude = []
|
||||||
|
fields = [
|
||||||
|
'name',
|
||||||
|
'users',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.instance.pk:
|
||||||
|
# Populate the users field with the current Group users.
|
||||||
|
self.fields['users'].initial = self.instance.user_set.all()
|
||||||
|
|
||||||
|
# Add the users field.
|
||||||
|
users = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=FilteredSelectMultiple('users', False),
|
||||||
|
label=_('Users'),
|
||||||
|
help_text=_('Select which users are assigned to this group')
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_m2m(self):
|
||||||
|
# Add the users to the Group.
|
||||||
|
|
||||||
|
self.instance.user_set.set(self.cleaned_data['users'])
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Default save
|
||||||
|
instance = super().save()
|
||||||
|
# Save many-to-many data
|
||||||
|
self.save_m2m()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class RoleGroupAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Custom admin interface for the Group model
|
||||||
|
"""
|
||||||
|
|
||||||
|
form = InvenTreeGroupAdminForm
|
||||||
|
|
||||||
|
inlines = [
|
||||||
|
RuleSetInline,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_formsets_with_inlines(self, request, obj=None):
|
||||||
|
for inline in self.get_inline_instances(request, obj):
|
||||||
|
# Hide RuleSetInline in the 'Add role' view
|
||||||
|
if not isinstance(inline, RuleSetInline) or obj is not None:
|
||||||
|
yield inline.get_formset(request, obj), inline
|
||||||
|
|
||||||
|
filter_horizontal = ['permissions']
|
||||||
|
|
||||||
|
# Save inlines before model
|
||||||
|
# https://stackoverflow.com/a/14860703/12794913
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
pass # don't actually save the parent instance
|
||||||
|
|
||||||
|
def save_formset(self, request, form, formset, change):
|
||||||
|
formset.save() # this will save the children
|
||||||
|
# update_fields is required to trigger permissions update
|
||||||
|
form.instance.save(update_fields=['name']) # form.instance is the parent
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeUserAdmin(UserAdmin):
|
||||||
|
"""
|
||||||
|
Custom admin page for the User model.
|
||||||
|
|
||||||
|
Hides the "permissions" view as this is now handled
|
||||||
|
entirely by groups and RuleSets.
|
||||||
|
|
||||||
|
(And it's confusing!)
|
||||||
|
"""
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {'fields': ('username', 'password')}),
|
||||||
|
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
|
||||||
|
(_('Permissions'), {
|
||||||
|
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
|
||||||
|
}),
|
||||||
|
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.unregister(Group)
|
||||||
|
admin.site.register(Group, RoleGroupAdmin)
|
||||||
|
|
||||||
|
admin.site.unregister(User)
|
||||||
|
admin.site.register(User, InvenTreeUserAdmin)
|
||||||
|
@ -1,8 +1,33 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class UsersConfig(AppConfig):
|
class UsersConfig(AppConfig):
|
||||||
name = 'users'
|
name = 'users'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.assign_permissions()
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def assign_permissions(self):
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from users.models import RuleSet, update_group_roles
|
||||||
|
|
||||||
|
# First, delete any rule_set objects which have become outdated!
|
||||||
|
for rule in RuleSet.objects.all():
|
||||||
|
if rule.name not in RuleSet.RULESET_NAMES:
|
||||||
|
print("need to delete:", rule.name)
|
||||||
|
rule.delete()
|
||||||
|
|
||||||
|
# Update group permission assignments for all groups
|
||||||
|
for group in Group.objects.all():
|
||||||
|
|
||||||
|
update_group_roles(group)
|
||||||
|
31
InvenTree/users/migrations/0001_initial.py
Normal file
31
InvenTree/users/migrations/0001_initial.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-03 13:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0011_update_proxy_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RuleSet',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(choices=[('general', 'General'), ('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('supplier', 'Suppliers'), ('purchase_order', 'Purchase Orders'), ('customer', 'Customers'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50)),
|
||||||
|
('can_view', models.BooleanField(default=True, help_text='Permission to view items', verbose_name='View')),
|
||||||
|
('can_add', models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Create')),
|
||||||
|
('can_change', models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Update')),
|
||||||
|
('can_delete', models.BooleanField(default=False, help_text='Permission to delete items', verbose_name='Delete')),
|
||||||
|
('group', models.ForeignKey(help_text='Group', on_delete=django.db.models.deletion.CASCADE, related_name='rule_sets', to='auth.Group')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('name', 'group')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
18
InvenTree/users/migrations/0002_auto_20201004_0158.py
Normal file
18
InvenTree/users/migrations/0002_auto_20201004_0158.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-04 01:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ruleset',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(choices=[('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
23
InvenTree/users/migrations/0003_auto_20201005_2227.py
Normal file
23
InvenTree/users/migrations/0003_auto_20201005_2227.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2020-10-05 22:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0002_auto_20201004_0158'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ruleset',
|
||||||
|
name='can_add',
|
||||||
|
field=models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Add'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ruleset',
|
||||||
|
name='can_change',
|
||||||
|
field=models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Change'),
|
||||||
|
),
|
||||||
|
]
|
0
InvenTree/users/migrations/__init__.py
Normal file
0
InvenTree/users/migrations/__init__.py
Normal file
@ -1 +1,378 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSet(models.Model):
|
||||||
|
"""
|
||||||
|
A RuleSet is somewhat like a superset of the django permission class,
|
||||||
|
in that in encapsulates a bunch of permissions.
|
||||||
|
|
||||||
|
There are *many* apps models used within InvenTree,
|
||||||
|
so it makes sense to group them into "roles".
|
||||||
|
|
||||||
|
These roles translate (roughly) to the menu options available.
|
||||||
|
|
||||||
|
Each role controls permissions for a number of database tables,
|
||||||
|
which are then handled using the normal django permissions approach.
|
||||||
|
"""
|
||||||
|
|
||||||
|
RULESET_CHOICES = [
|
||||||
|
('admin', _('Admin')),
|
||||||
|
('part', _('Parts')),
|
||||||
|
('stock', _('Stock')),
|
||||||
|
('build', _('Build Orders')),
|
||||||
|
('purchase_order', _('Purchase Orders')),
|
||||||
|
('sales_order', _('Sales Orders')),
|
||||||
|
]
|
||||||
|
|
||||||
|
RULESET_NAMES = [
|
||||||
|
choice[0] for choice in RULESET_CHOICES
|
||||||
|
]
|
||||||
|
|
||||||
|
RULESET_PERMISSIONS = [
|
||||||
|
'view', 'add', 'change', 'delete',
|
||||||
|
]
|
||||||
|
|
||||||
|
RULESET_MODELS = {
|
||||||
|
'admin': [
|
||||||
|
'auth_group',
|
||||||
|
'auth_user',
|
||||||
|
'auth_permission',
|
||||||
|
'authtoken_token',
|
||||||
|
'users_ruleset',
|
||||||
|
],
|
||||||
|
'part': [
|
||||||
|
'part_part',
|
||||||
|
'part_bomitem',
|
||||||
|
'part_partcategory',
|
||||||
|
'part_partattachment',
|
||||||
|
'part_partsellpricebreak',
|
||||||
|
'part_parttesttemplate',
|
||||||
|
'part_partparametertemplate',
|
||||||
|
'part_partparameter',
|
||||||
|
],
|
||||||
|
'stock': [
|
||||||
|
'stock_stockitem',
|
||||||
|
'stock_stocklocation',
|
||||||
|
'stock_stockitemattachment',
|
||||||
|
'stock_stockitemtracking',
|
||||||
|
'stock_stockitemtestresult',
|
||||||
|
],
|
||||||
|
'build': [
|
||||||
|
'part_part',
|
||||||
|
'part_partcategory',
|
||||||
|
'part_bomitem',
|
||||||
|
'build_build',
|
||||||
|
'build_builditem',
|
||||||
|
'stock_stockitem',
|
||||||
|
'stock_stocklocation',
|
||||||
|
],
|
||||||
|
'purchase_order': [
|
||||||
|
'company_company',
|
||||||
|
'company_supplierpart',
|
||||||
|
'company_supplierpricebreak',
|
||||||
|
'order_purchaseorder',
|
||||||
|
'order_purchaseorderattachment',
|
||||||
|
'order_purchaseorderlineitem',
|
||||||
|
],
|
||||||
|
'sales_order': [
|
||||||
|
'company_company',
|
||||||
|
'order_salesorder',
|
||||||
|
'order_salesorderattachment',
|
||||||
|
'order_salesorderlineitem',
|
||||||
|
'order_salesorderallocation',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Database models we ignore permission sets for
|
||||||
|
RULESET_IGNORE = [
|
||||||
|
# Core django models (not user configurable)
|
||||||
|
'admin_logentry',
|
||||||
|
'contenttypes_contenttype',
|
||||||
|
'sessions_session',
|
||||||
|
|
||||||
|
# Models which currently do not require permissions
|
||||||
|
'common_colortheme',
|
||||||
|
'common_currency',
|
||||||
|
'common_inventreesetting',
|
||||||
|
'company_contact',
|
||||||
|
'label_stockitemlabel',
|
||||||
|
'report_reportasset',
|
||||||
|
'report_testreport',
|
||||||
|
'part_partstar',
|
||||||
|
]
|
||||||
|
|
||||||
|
RULE_OPTIONS = [
|
||||||
|
'can_view',
|
||||||
|
'can_add',
|
||||||
|
'can_change',
|
||||||
|
'can_delete',
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (
|
||||||
|
('name', 'group'),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=RULESET_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
help_text=_('Permission set')
|
||||||
|
)
|
||||||
|
|
||||||
|
group = models.ForeignKey(
|
||||||
|
Group,
|
||||||
|
related_name='rule_sets',
|
||||||
|
blank=False, null=False,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
help_text=_('Group'),
|
||||||
|
)
|
||||||
|
|
||||||
|
can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
|
||||||
|
|
||||||
|
can_add = models.BooleanField(verbose_name=_('Add'), default=False, help_text=_('Permission to add items'))
|
||||||
|
|
||||||
|
can_change = models.BooleanField(verbose_name=_('Change'), default=False, help_text=_('Permissions to edit items'))
|
||||||
|
|
||||||
|
can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_model_permission_string(model, permission):
|
||||||
|
"""
|
||||||
|
Construct the correctly formatted permission string,
|
||||||
|
given the app_model name, and the permission type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
app, model = model.split('_')
|
||||||
|
|
||||||
|
return "{app}.{perm}_{model}".format(
|
||||||
|
app=app,
|
||||||
|
perm=permission,
|
||||||
|
model=model
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self, debug=False):
|
||||||
|
""" Ruleset string representation """
|
||||||
|
if debug:
|
||||||
|
# Makes debugging easier
|
||||||
|
return f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' \
|
||||||
|
f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' \
|
||||||
|
f'c: {str(self.can_change).ljust(5)} | d: {str(self.can_delete).ljust(5)}'
|
||||||
|
else:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# It does not make sense to be able to change / create something,
|
||||||
|
# but not be able to view it!
|
||||||
|
|
||||||
|
if self.can_add or self.can_change or self.can_delete:
|
||||||
|
self.can_view = True
|
||||||
|
|
||||||
|
if self.can_add or self.can_delete:
|
||||||
|
self.can_change = True
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.group:
|
||||||
|
# Update the group too!
|
||||||
|
self.group.save()
|
||||||
|
|
||||||
|
def get_models(self):
|
||||||
|
"""
|
||||||
|
Return the database tables / models that this ruleset covers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.RULESET_MODELS.get(self.name, [])
|
||||||
|
|
||||||
|
|
||||||
|
def update_group_roles(group, debug=False):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Iterates through all of the RuleSets associated with the group,
|
||||||
|
and ensures that the correct permissions are either applied or removed from the group.
|
||||||
|
|
||||||
|
This function is called under the following conditions:
|
||||||
|
|
||||||
|
a) Whenever the InvenTree database is launched
|
||||||
|
b) Whenver the group object is updated
|
||||||
|
|
||||||
|
The RuleSet model has complete control over the permissions applied to any group.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# List of permissions already associated with this group
|
||||||
|
group_permissions = set()
|
||||||
|
|
||||||
|
# Iterate through each permission already assigned to this group,
|
||||||
|
# and create a simplified permission key string
|
||||||
|
for p in group.permissions.all():
|
||||||
|
(permission, app, model) = p.natural_key()
|
||||||
|
|
||||||
|
permission_string = '{app}.{perm}'.format(
|
||||||
|
app=app,
|
||||||
|
perm=permission
|
||||||
|
)
|
||||||
|
|
||||||
|
group_permissions.add(permission_string)
|
||||||
|
|
||||||
|
# List of permissions which must be added to the group
|
||||||
|
permissions_to_add = set()
|
||||||
|
|
||||||
|
# List of permissions which must be removed from the group
|
||||||
|
permissions_to_delete = set()
|
||||||
|
|
||||||
|
def add_model(name, action, allowed):
|
||||||
|
"""
|
||||||
|
Add a new model to the pile:
|
||||||
|
|
||||||
|
args:
|
||||||
|
name - The name of the model e.g. part_part
|
||||||
|
action - The permission action e.g. view
|
||||||
|
allowed - Whether or not the action is allowed
|
||||||
|
"""
|
||||||
|
|
||||||
|
if action not in ['view', 'add', 'change', 'delete']:
|
||||||
|
raise ValueError("Action {a} is invalid".format(a=action))
|
||||||
|
|
||||||
|
permission_string = RuleSet.get_model_permission_string(model, action)
|
||||||
|
|
||||||
|
if allowed:
|
||||||
|
|
||||||
|
# An 'allowed' action is always preferenced over a 'forbidden' action
|
||||||
|
if permission_string in permissions_to_delete:
|
||||||
|
permissions_to_delete.remove(permission_string)
|
||||||
|
|
||||||
|
permissions_to_add.add(permission_string)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
# A forbidden action will be ignored if we have already allowed it
|
||||||
|
if permission_string not in permissions_to_add:
|
||||||
|
permissions_to_delete.add(permission_string)
|
||||||
|
|
||||||
|
# Get all the rulesets associated with this group
|
||||||
|
for r in RuleSet.RULESET_CHOICES:
|
||||||
|
|
||||||
|
rulename = r[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
ruleset = RuleSet.objects.get(group=group, name=rulename)
|
||||||
|
except RuleSet.DoesNotExist:
|
||||||
|
# Create the ruleset with default values (if it does not exist)
|
||||||
|
ruleset = RuleSet.objects.create(group=group, name=rulename)
|
||||||
|
|
||||||
|
# Which database tables does this RuleSet touch?
|
||||||
|
models = ruleset.get_models()
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
# Keep track of the available permissions for each model
|
||||||
|
|
||||||
|
add_model(model, 'view', ruleset.can_view)
|
||||||
|
add_model(model, 'add', ruleset.can_add)
|
||||||
|
add_model(model, 'change', ruleset.can_change)
|
||||||
|
add_model(model, 'delete', ruleset.can_delete)
|
||||||
|
|
||||||
|
def get_permission_object(permission_string):
|
||||||
|
"""
|
||||||
|
Find the permission object in the database,
|
||||||
|
from the simplified permission string
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_string - a simplified permission_string e.g. 'part.view_partcategory'
|
||||||
|
|
||||||
|
Returns the permission object in the database associated with the permission string
|
||||||
|
"""
|
||||||
|
|
||||||
|
(app, perm) = permission_string.split('.')
|
||||||
|
|
||||||
|
(permission_name, model) = perm.split('_')
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_type = ContentType.objects.get(app_label=app, model=model)
|
||||||
|
permission = Permission.objects.get(content_type=content_type, codename=perm)
|
||||||
|
except ContentType.DoesNotExist:
|
||||||
|
print(f"Error: Could not find permission matching '{permission_string}'")
|
||||||
|
permission = None
|
||||||
|
|
||||||
|
return permission
|
||||||
|
|
||||||
|
# Add any required permissions to the group
|
||||||
|
for perm in permissions_to_add:
|
||||||
|
|
||||||
|
# Ignore if permission is already in the group
|
||||||
|
if perm in group_permissions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
permission = get_permission_object(perm)
|
||||||
|
|
||||||
|
group.permissions.add(permission)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(f"Adding permission {perm} to group {group.name}")
|
||||||
|
|
||||||
|
# Remove any extra permissions from the group
|
||||||
|
for perm in permissions_to_delete:
|
||||||
|
|
||||||
|
# Ignore if the permission is not already assigned
|
||||||
|
if perm not in group_permissions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
permission = get_permission_object(perm)
|
||||||
|
|
||||||
|
group.permissions.remove(permission)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(f"Removing permission {perm} from group {group.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Group)
|
||||||
|
def create_missing_rule_sets(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Called *after* a Group object is saved.
|
||||||
|
As the linked RuleSet instances are saved *before* the Group,
|
||||||
|
then we can now use these RuleSet values to update the
|
||||||
|
group permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
update_group_roles(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def check_user_role(user, role, permission):
|
||||||
|
"""
|
||||||
|
Check if a user has a particular role:permission combination.
|
||||||
|
|
||||||
|
If the user is a superuser, this will return True
|
||||||
|
"""
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for group in user.groups.all():
|
||||||
|
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
|
||||||
|
if rule.name == role:
|
||||||
|
|
||||||
|
if permission == 'add' and rule.can_add:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if permission == 'change' and rule.can_change:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if permission == 'view' and rule.can_view:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if permission == 'delete' and rule.can_delete:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# No matching permissions found
|
||||||
|
return False
|
||||||
|
@ -1,4 +1,159 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
# from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.apps import apps
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
|
||||||
|
from users.models import RuleSet
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetModelTest(TestCase):
|
||||||
|
"""
|
||||||
|
Some simplistic tests to ensure the RuleSet model is setup correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_ruleset_models(self):
|
||||||
|
|
||||||
|
keys = RuleSet.RULESET_MODELS.keys()
|
||||||
|
|
||||||
|
# Check if there are any rulesets which do not have models defined
|
||||||
|
|
||||||
|
missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
|
||||||
|
|
||||||
|
if len(missing) > 0:
|
||||||
|
print("The following rulesets do not have models assigned:")
|
||||||
|
for m in missing:
|
||||||
|
print("-", m)
|
||||||
|
|
||||||
|
# Check if models have been defined for a ruleset which is incorrect
|
||||||
|
extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
|
||||||
|
|
||||||
|
if len(extra) > 0:
|
||||||
|
print("The following rulesets have been improperly added to RULESET_MODELS:")
|
||||||
|
for e in extra:
|
||||||
|
print("-", e)
|
||||||
|
|
||||||
|
# Check that each ruleset has models assigned
|
||||||
|
empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
|
||||||
|
|
||||||
|
if len(empty) > 0:
|
||||||
|
print("The following rulesets have empty entries in RULESET_MODELS:")
|
||||||
|
for e in empty:
|
||||||
|
print("-", e)
|
||||||
|
|
||||||
|
self.assertEqual(len(missing), 0)
|
||||||
|
self.assertEqual(len(extra), 0)
|
||||||
|
self.assertEqual(len(empty), 0)
|
||||||
|
|
||||||
|
def test_model_names(self):
|
||||||
|
"""
|
||||||
|
Test that each model defined in the rulesets is valid,
|
||||||
|
based on the database schema!
|
||||||
|
"""
|
||||||
|
|
||||||
|
available_models = apps.get_models()
|
||||||
|
|
||||||
|
available_tables = set()
|
||||||
|
|
||||||
|
# Extract each available database model and construct a formatted string
|
||||||
|
for model in available_models:
|
||||||
|
label = model.objects.model._meta.label
|
||||||
|
label = label.replace('.', '_').lower()
|
||||||
|
available_tables.add(label)
|
||||||
|
|
||||||
|
assigned_models = set()
|
||||||
|
|
||||||
|
# Now check that each defined model is a valid table name
|
||||||
|
for key in RuleSet.RULESET_MODELS.keys():
|
||||||
|
|
||||||
|
models = RuleSet.RULESET_MODELS[key]
|
||||||
|
|
||||||
|
for m in models:
|
||||||
|
|
||||||
|
assigned_models.add(m)
|
||||||
|
|
||||||
|
missing_models = set()
|
||||||
|
|
||||||
|
for model in available_tables:
|
||||||
|
if model not in assigned_models and model not in RuleSet.RULESET_IGNORE:
|
||||||
|
missing_models.add(model)
|
||||||
|
|
||||||
|
if len(missing_models) > 0:
|
||||||
|
print("The following database models are not covered by the defined RuleSet permissions:")
|
||||||
|
for m in missing_models:
|
||||||
|
print("-", m)
|
||||||
|
|
||||||
|
extra_models = set()
|
||||||
|
|
||||||
|
defined_models = set()
|
||||||
|
|
||||||
|
for model in assigned_models:
|
||||||
|
defined_models.add(model)
|
||||||
|
|
||||||
|
for model in RuleSet.RULESET_IGNORE:
|
||||||
|
defined_models.add(model)
|
||||||
|
|
||||||
|
for model in defined_models:
|
||||||
|
if model not in available_tables:
|
||||||
|
extra_models.add(model)
|
||||||
|
|
||||||
|
if len(extra_models) > 0:
|
||||||
|
print("The following RuleSet permissions do not match a database model:")
|
||||||
|
for m in extra_models:
|
||||||
|
print("-", m)
|
||||||
|
|
||||||
|
self.assertEqual(len(missing_models), 0)
|
||||||
|
self.assertEqual(len(extra_models), 0)
|
||||||
|
|
||||||
|
def test_permission_assign(self):
|
||||||
|
"""
|
||||||
|
Test that the permission assigning works!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a new group
|
||||||
|
group = Group.objects.create(name="Test group")
|
||||||
|
|
||||||
|
rulesets = group.rule_sets.all()
|
||||||
|
|
||||||
|
# Rulesets should have been created automatically for this group
|
||||||
|
self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES))
|
||||||
|
|
||||||
|
# Check that all permissions have been assigned permissions?
|
||||||
|
permission_set = set()
|
||||||
|
|
||||||
|
for models in RuleSet.RULESET_MODELS.values():
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
permission_set.add(model)
|
||||||
|
|
||||||
|
# Every ruleset by default sets one permission, the "view" permission set
|
||||||
|
self.assertEqual(group.permissions.count(), len(permission_set))
|
||||||
|
|
||||||
|
# Add some more rules
|
||||||
|
for rule in rulesets:
|
||||||
|
rule.can_add = True
|
||||||
|
rule.can_change = True
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
# update_fields is required to trigger permissions update
|
||||||
|
group.save(update_fields=['name'])
|
||||||
|
|
||||||
|
# There should now be three permissions for each rule set
|
||||||
|
self.assertEqual(group.permissions.count(), 3 * len(permission_set))
|
||||||
|
|
||||||
|
# Now remove *all* permissions
|
||||||
|
for rule in rulesets:
|
||||||
|
rule.can_view = False
|
||||||
|
rule.can_add = False
|
||||||
|
rule.can_change = False
|
||||||
|
rule.can_delete = False
|
||||||
|
|
||||||
|
rule.save()
|
||||||
|
|
||||||
|
# update_fields is required to trigger permissions update
|
||||||
|
group.save(update_fields=['name'])
|
||||||
|
|
||||||
|
# There should now not be any permissions assigned to this group
|
||||||
|
self.assertEqual(group.permissions.count(), 0)
|
||||||
|
@ -25,5 +25,6 @@ django-stdimage==5.1.1 # Advanced ImageField management
|
|||||||
django-tex==1.1.7 # LaTeX PDF export
|
django-tex==1.1.7 # LaTeX PDF export
|
||||||
django-weasyprint==1.0.1 # HTML PDF export
|
django-weasyprint==1.0.1 # HTML PDF export
|
||||||
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
django-debug-toolbar==2.2 # Debug / profiling toolbar
|
||||||
|
django-admin-shell==0.1.2 # Python shell for the admin interface
|
||||||
|
|
||||||
inventree # Install the latest version of the InvenTree API python library
|
inventree # Install the latest version of the InvenTree API python library
|
Loading…
Reference in New Issue
Block a user