Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-10-06 22:34:42 +11:00
commit 745b745ce4
87 changed files with 10242 additions and 2296 deletions

View File

@ -30,6 +30,8 @@ class InfoView(AjaxView):
Use to confirm that the server is running, etc.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
data = {

View File

@ -17,3 +17,43 @@ def status_codes(request):
'BuildStatus': BuildStatus,
'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}

View File

@ -15,6 +15,8 @@ from django.http import StreamingHttpResponse
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
from django.contrib.auth.models import Permission
import InvenTree.version
from .settings import MEDIA_URL, STATIC_URL
@ -441,3 +443,21 @@ def validateFilterString(value):
results[k] = v
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)

View File

@ -138,6 +138,7 @@ INSTALLED_APPS = [
'part.apps.PartConfig',
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
# Third part add-ons
'django_filters', # Extended filter functionality
@ -153,6 +154,7 @@ INSTALLED_APPS = [
'markdownx', # Markdown editing
'markdownify', # Markdown template rendering
'django_tex', # LaTeX output
'django_admin_shell', # Python shell for the admin interface
]
LOGGING = {
@ -208,6 +210,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'InvenTree.context.status_codes',
'InvenTree.context.user_roles',
],
},
},
@ -229,6 +232,10 @@ REST_FRAMEWORK = {
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
'rest_framework.permissions.DjangoModelPermissions',
),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'
}

View 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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
var options = opts || {};
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) {
percent = 100;
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
var extraclass = '';
if (value > maximum) {
if (maximum) {
// TODO - Special color?
}
else if (value > maximum) {
extraclass='progress-bar-over';
} else if (value < maximum) {
extraclass = 'progress-bar-under';
}
var text = value;
if (maximum) {
text += ' / ';
text += maximum;
}
var id = options.id || 'progress-bar';
return `
<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-value'>${value} / ${maximum}</div>
<div class='progress-value'>${text}</div>
</div>
`;
}

View File

@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
options.pagination = true;
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.rememberOrder = true;
options.sortable = true;
options.search = true;
options.showColumns = true;
if (options.sortable == null) {
options.sortable = true;
}
if (options.search == null) {
options.search = true;
}
if (options.showColumns == null) {
options.showColumns = true;
}
// Callback to save pagination data
options.onPageChange = function(number, size) {

View File

@ -117,6 +117,7 @@ urlpatterns = [
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
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'^qr_code/', include(qr_code_urls, namespace='qr_code')),

View File

@ -22,6 +22,7 @@ from django.views.generic.base import TemplateView
from part.models import Part, PartCategory
from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting, ColorTheme
from users.models import check_user_role, RuleSet
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
from .helpers import str2bool
@ -107,31 +108,72 @@ class TreeSerializer(views.APIView):
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.
Handles jsonResponse rendering, and adds extra data for the modal forms to process
on the client side.
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
permission_required = '*'
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, allow *any* role
role_required = None
# By default, point to the modal_form template
# (this can be overridden by a child class)

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework import generics, permissions
from rest_framework import generics
from django.conf.urls import url, include
@ -28,10 +28,6 @@ class BuildList(generics.ListCreateAPIView):
queryset = Build.objects.all()
serializer_class = BuildSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -99,10 +95,6 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
queryset = Build.objects.all()
serializer_class = BuildSerializer
permission_classes = [
permissions.IsAuthenticated,
]
class BuildItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of BuildItem objects
@ -137,10 +129,6 @@ class BuildItemList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
]

View File

@ -35,25 +35,27 @@ src="{% static 'img/blank_image.png' %}"
<hr>
<h4>
{{ 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>
{% endif %}
</h4>
<div class='btn-row'>
<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'/>
</button>
{% 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'/>
</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'/>
</button>
{% endif %}
{% if build.status == BuildStatus.CANCELLED %}
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
{% endif %}
{% 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'/>
</button>
{% endif %}

View File

@ -4,6 +4,7 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from rest_framework.test import APITestCase
from rest_framework import status
@ -30,6 +31,20 @@ class BuildTestSimple(TestCase):
User.objects.create_user('testuser', 'test@testing.com', 'password')
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')
def test_build_objects(self):
@ -94,7 +109,20 @@ class TestBuildAPI(APITestCase):
def setUp(self):
# Create a user for auth
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')
@ -131,7 +159,20 @@ class TestBuildViews(TestCase):
# Create a user
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')

View File

@ -17,16 +17,18 @@ from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import str2bool, ExtractSerialNumbers
from InvenTree.status_codes import BuildStatus
class BuildIndex(ListView):
class BuildIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of Builds
"""
model = Build
template_name = 'build/index.html'
context_object_name = 'builds'
role_required = 'build.view'
def get_queryset(self):
""" Return all Build objects (order by date, newest first) """
@ -56,6 +58,7 @@ class BuildCancel(AjaxUpdateView):
ajax_form_title = _('Cancel Build')
context_object_name = 'build'
form_class = forms.CancelBuildForm
role_required = 'build.change'
def post(self, request, *args, **kwargs):
""" Handle POST request. Mark the build status as CANCELLED """
@ -94,6 +97,7 @@ class BuildAutoAllocate(AjaxUpdateView):
context_object_name = 'build'
ajax_form_title = _('Allocate Stock')
ajax_template_name = 'build/auto_allocate.html'
role_required = 'build.change'
def get_context_data(self, *args, **kwargs):
""" Get the context data for form rendering. """
@ -147,6 +151,7 @@ class BuildUnallocate(AjaxUpdateView):
form_class = forms.ConfirmBuildForm
ajax_form_title = _("Unallocate Stock")
ajax_template_name = "build/unallocate.html"
form_required = 'build.change'
def post(self, request, *args, **kwargs):
@ -184,6 +189,7 @@ class BuildComplete(AjaxUpdateView):
context_object_name = "build"
ajax_form_title = _("Complete Build")
ajax_template_name = "build/complete.html"
role_required = 'build.change'
def get_form(self):
""" Get the form object.
@ -325,6 +331,7 @@ class BuildNotes(UpdateView):
context_object_name = 'build'
template_name = 'build/notes.html'
model = Build
role_required = 'build.view'
fields = ['notes']
@ -342,9 +349,11 @@ class BuildNotes(UpdateView):
class BuildDetail(DetailView):
""" Detail view of a single Build object. """
model = Build
template_name = 'build/detail.html'
context_object_name = 'build'
role_required = 'build.view'
def get_context_data(self, **kwargs):
@ -363,6 +372,7 @@ class BuildAllocate(DetailView):
model = Build
context_object_name = 'build'
template_name = 'build/allocate.html'
role_required = ['build.change']
def get_context_data(self, **kwargs):
""" Provide extra context information for the Build allocation page """
@ -392,6 +402,7 @@ class BuildCreate(AjaxCreateView):
form_class = forms.EditBuildForm
ajax_form_title = _('Start new Build')
ajax_template_name = 'modal_form.html'
role_required = 'build.add'
def get_initial(self):
""" Get initial parameters for Build creation.
@ -427,6 +438,7 @@ class BuildUpdate(AjaxUpdateView):
context_object_name = 'build'
ajax_form_title = _('Edit Build Details')
ajax_template_name = 'modal_form.html'
role_required = 'build.change'
def get_data(self):
return {
@ -440,6 +452,7 @@ class BuildDelete(AjaxDeleteView):
model = Build
ajax_template_name = 'build/delete_build.html'
ajax_form_title = _('Delete Build')
role_required = 'build.delete'
class BuildItemDelete(AjaxDeleteView):
@ -451,6 +464,7 @@ class BuildItemDelete(AjaxDeleteView):
ajax_template_name = 'build/delete_build_item.html'
ajax_form_title = _('Unallocate Stock')
context_object_name = 'item'
role_required = 'build.delete'
def get_data(self):
return {
@ -465,6 +479,7 @@ class BuildItemCreate(AjaxCreateView):
form_class = forms.EditBuildItemForm
ajax_template_name = 'build/create_build_item.html'
ajax_form_title = _('Allocate new Part')
role_required = 'build.add'
part = None
available_stock = None
@ -618,6 +633,7 @@ class BuildItemEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html'
form_class = forms.EditBuildItemForm
ajax_form_title = _('Edit Stock Allocation')
role_required = 'build.change'
def get_data(self):
return {

View File

@ -7,7 +7,7 @@ from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
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.db.models import Q
@ -40,10 +40,6 @@ class CompanyList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -82,10 +78,6 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
class SupplierPartList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPart object
@ -170,10 +162,6 @@ class SupplierPartList(generics.ListCreateAPIView):
serializer_class = SupplierPartSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -202,7 +190,6 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = SupplierPart.objects.all()
serializer_class = SupplierPartSerializer
permission_classes = (permissions.IsAuthenticated,)
read_only_fields = [
]
@ -218,10 +205,6 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
]

View File

@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
company.image = None
company.save()
except (OperationalError, ProgrammingError):
print("Could not generate Company thumbnails")
# Getting here probably meant the database was in test mode
pass

View File

@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
# Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return
print("Reversing migration for manufacturer association")
@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
# Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return
# Link a 'manufacturer_name' to a 'Company'

View File

@ -23,7 +23,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
<hr>
<h4>
{{ 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>
{% endif %}
</h4>

View File

@ -3,6 +3,8 @@ from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.helpers import addUserPermissions
from .models import Company
@ -14,7 +16,16 @@ class CompanyTest(APITestCase):
def setUp(self):
# Create a user for auth
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')
Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ JSON API for the Order app
from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, permissions
from rest_framework import generics
from rest_framework import filters
from django.conf.urls import url, include
@ -109,10 +109,6 @@ class POList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -162,10 +158,6 @@ class PODetail(generics.RetrieveUpdateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated
]
class POLineItemList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of POLineItem objects
@ -188,10 +180,6 @@ class POLineItemList(generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs)
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
]
@ -208,10 +196,6 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView):
queryset = PurchaseOrderLineItem
serializer_class = POLineItemSerializer
permission_classes = [
permissions.IsAuthenticated,
]
class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""
@ -300,10 +284,6 @@ class SOList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -351,8 +331,6 @@ class SODetail(generics.RetrieveUpdateAPIView):
return queryset
permission_classes = [permissions.IsAuthenticated]
class SOLineItemList(generics.ListCreateAPIView):
"""
@ -398,8 +376,6 @@ class SOLineItemList(generics.ListCreateAPIView):
return queryset
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend]
filter_fields = [
@ -414,8 +390,6 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
queryset = SalesOrderLineItem.objects.all()
serializer_class = SOLineItemSerializer
permission_classes = [permissions.IsAuthenticated]
class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
"""

View File

@ -24,7 +24,7 @@ src="{% static 'img/blank_image.png' %}"
<hr>
<h4>
{{ 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>
{% endif %}
</h4>
@ -32,29 +32,31 @@ src="{% static 'img/blank_image.png' %}"
<p>
<div class='btn-row'>
<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>
</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 %}
<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>
</button>
{% 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>
</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>
</button>
{% endif %}
{% 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>
</button>
{% 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>
</p>

View File

@ -28,9 +28,11 @@
<div class='col-sm-6'>
<h4>{% trans "Order Notes" %}</h4>
</div>
{% if roles.purchase_order.change %}
<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>
</div>
{% endif %}
</div>
<hr>
<div class='panel panel-default'>

View File

@ -14,7 +14,6 @@
{% include "attachment_table.html" with attachments=order.attachments.all %}
{% endblock %}
{% block js_ready %}

View File

@ -12,7 +12,7 @@
<hr>
<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>
{% endif %}
</div>
@ -209,12 +209,12 @@ $("#po-table").inventreeTable({
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-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
{% endif %}
{% if order.status == PurchaseOrderStatus.PLACED %}
{% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
if (row.received < row.quantity) {
html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
}

View File

@ -14,7 +14,9 @@ InvenTree | {% trans "Purchase Orders" %}
<div id='table-buttons'>
<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>
{% endif %}
<div class='filter-list' id='filter-list-purchaseorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>

View File

@ -34,19 +34,17 @@ src="{% static 'img/blank_image.png' %}"
<hr>
<h4>
{{ 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>
{% endif %}
</h4>
<p>{{ order.description }}</p>
<div class='btn-row'>
<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'>
<span class='fas fa-edit icon-green'></span>
</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 %}
<button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
<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>
</button>
{% 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>
{% endblock %}

View File

@ -14,7 +14,9 @@ InvenTree | {% trans "Sales Orders" %}
<div id='table-buttons'>
<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>
{% endif %}
<div class='filter-list' id='filter-list-salesorder'>
<!-- An empty div in which the filter list will be constructed -->
</div>

View File

@ -6,6 +6,7 @@ from __future__ import unicode_literals
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from InvenTree.status_codes import PurchaseOrderStatus
@ -32,7 +33,21 @@ class OrderViewTestCase(TestCase):
# Create a user
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')

View File

@ -28,19 +28,22 @@ from . import forms as order_forms
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import DownloadFile, str2bool
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
logger = logging.getLogger(__name__)
class PurchaseOrderIndex(ListView):
class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
""" List view for all purchase orders """
model = PurchaseOrder
template_name = 'order/purchase_orders.html'
context_object_name = 'orders'
role_required = 'purchase_order.view'
def get_queryset(self):
""" Retrieve the list of purchase orders,
ensure that the most recent ones are returned first. """
@ -55,19 +58,21 @@ class PurchaseOrderIndex(ListView):
return ctx
class SalesOrderIndex(ListView):
class SalesOrderIndex(InvenTreeRoleMixin, ListView):
model = SalesOrder
template_name = 'order/sales_orders.html'
context_object_name = 'orders'
role_required = 'sales_order.view'
class PurchaseOrderDetail(DetailView):
class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for a PurchaseOrder object """
context_object_name = 'order'
queryset = PurchaseOrder.objects.all().prefetch_related('lines')
template_name = 'order/purchase_order_detail.html'
role_required = 'purchase_order.view'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@ -75,12 +80,13 @@ class PurchaseOrderDetail(DetailView):
return ctx
class SalesOrderDetail(DetailView):
class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for a SalesOrder object """
context_object_name = 'order'
queryset = SalesOrder.objects.all().prefetch_related('lines')
template_name = 'order/sales_order_detail.html'
role_required = 'sales_order.view'
class PurchaseOrderAttachmentCreate(AjaxCreateView):
@ -92,6 +98,7 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
form_class = order_forms.EditPurchaseOrderAttachmentForm
ajax_form_title = _("Add Purchase Order Attachment")
ajax_template_name = "modal_form.html"
role_required = 'purchase_order.add'
def post_save(self, **kwargs):
self.object.user = self.request.user
@ -139,6 +146,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _('Add Sales Order Attachment')
role_required = 'sales_order.add'
def post_save(self, **kwargs):
self.object.user = self.request.user
@ -174,6 +182,7 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
model = PurchaseOrderAttachment
form_class = order_forms.EditPurchaseOrderAttachmentForm
ajax_form_title = _("Edit Attachment")
role_required = 'purchase_order.change'
def get_data(self):
return {
@ -195,6 +204,7 @@ class SalesOrderAttachmentEdit(AjaxUpdateView):
model = SalesOrderAttachment
form_class = order_forms.EditSalesOrderAttachmentForm
ajax_form_title = _("Edit Attachment")
role_required = 'sales_order.change'
def get_data(self):
return {
@ -216,6 +226,7 @@ class PurchaseOrderAttachmentDelete(AjaxDeleteView):
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
role_required = 'purchase_order.delete'
def get_data(self):
return {
@ -230,6 +241,7 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
ajax_form_title = _("Delete Attachment")
ajax_template_name = "order/delete_attachment.html"
context_object_name = "attachment"
role_required = 'sales_order.delete'
def get_data(self):
return {
@ -237,12 +249,13 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
}
class PurchaseOrderNotes(UpdateView):
class PurchaseOrderNotes(InvenTreeRoleMixin, UpdateView):
""" View for updating the 'notes' field of a PurchaseOrder """
context_object_name = 'order'
template_name = 'order/order_notes.html'
model = PurchaseOrder
role_required = 'purchase_order.view'
fields = ['notes']
@ -259,12 +272,13 @@ class PurchaseOrderNotes(UpdateView):
return ctx
class SalesOrderNotes(UpdateView):
class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
""" View for editing the 'notes' field of a SalesORder """
context_object_name = 'order'
template_name = 'order/sales_order_notes.html'
model = SalesOrder
role_required = 'sales_order.view'
fields = ['notes']
@ -286,6 +300,7 @@ class PurchaseOrderCreate(AjaxCreateView):
model = PurchaseOrder
ajax_form_title = _("Create Purchase Order")
form_class = order_forms.EditPurchaseOrderForm
role_required = 'purchase_order.add'
def get_initial(self):
initials = super().get_initial().copy()
@ -317,6 +332,7 @@ class SalesOrderCreate(AjaxCreateView):
model = SalesOrder
ajax_form_title = _("Create Sales Order")
form_class = order_forms.EditSalesOrderForm
role_required = 'sales_order.add'
def get_initial(self):
initials = super().get_initial().copy()
@ -347,6 +363,7 @@ class PurchaseOrderEdit(AjaxUpdateView):
model = PurchaseOrder
ajax_form_title = _('Edit Purchase Order')
form_class = order_forms.EditPurchaseOrderForm
role_required = 'purchase_order.change'
def get_form(self):
@ -367,6 +384,7 @@ class SalesOrderEdit(AjaxUpdateView):
model = SalesOrder
ajax_form_title = _('Edit Sales Order')
form_class = order_forms.EditSalesOrderForm
role_required = 'sales_order.change'
def get_form(self):
form = super().get_form()
@ -384,6 +402,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
ajax_form_title = _('Cancel Order')
ajax_template_name = 'order/order_cancel.html'
form_class = order_forms.CancelPurchaseOrderForm
role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs):
""" Mark the PO as 'CANCELLED' """
@ -417,6 +436,7 @@ class SalesOrderCancel(AjaxUpdateView):
ajax_form_title = _("Cancel sales order")
ajax_template_name = "order/sales_order_cancel.html"
form_class = order_forms.CancelSalesOrderForm
role_required = 'sales_order.change'
def post(self, request, *args, **kwargs):
@ -451,6 +471,7 @@ class PurchaseOrderIssue(AjaxUpdateView):
ajax_form_title = _('Issue Order')
ajax_template_name = "order/order_issue.html"
form_class = order_forms.IssuePurchaseOrderForm
role_required = 'purchase_order.change'
def post(self, request, *args, **kwargs):
""" Mark the purchase order as 'PLACED' """
@ -486,6 +507,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
ajax_template_name = "order/order_complete.html"
ajax_form_title = _("Complete Order")
context_object_name = 'order'
role_required = 'purchase_order.change'
def get_context_data(self):
@ -520,6 +542,7 @@ class SalesOrderShip(AjaxUpdateView):
context_object_name = 'order'
ajax_template_name = 'order/sales_order_ship.html'
ajax_form_title = _('Ship Order')
role_required = 'sales_order.change'
def post(self, request, *args, **kwargs):
@ -563,6 +586,7 @@ class PurchaseOrderExport(AjaxView):
"""
model = PurchaseOrder
role_required = 'purchase_order.view'
def get(self, request, *args, **kwargs):
@ -594,6 +618,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
form_class = order_forms.ReceivePurchaseOrderForm
ajax_form_title = _("Receive Parts")
ajax_template_name = "order/receive_parts.html"
role_required = 'purchase_order.change'
# Where the parts will be going (selected in POST request)
destination = None
@ -779,6 +804,11 @@ class OrderParts(AjaxView):
ajax_form_title = _("Order Parts")
ajax_template_name = 'order/order_wizard/select_parts.html'
role_required = [
'part.view',
'purchase_order.change',
]
# List of Parts we wish to order
parts = []
suppliers = []
@ -1085,6 +1115,7 @@ class POLineItemCreate(AjaxCreateView):
context_object_name = 'line'
form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_form_title = _('Add Line Item')
role_required = 'purchase_order.add'
def post(self, request, *arg, **kwargs):
@ -1199,6 +1230,7 @@ class SOLineItemCreate(AjaxCreateView):
context_order_name = 'line'
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Add Line Item')
role_required = 'sales_order.add'
def get_form(self, *args, **kwargs):
@ -1250,6 +1282,7 @@ class SOLineItemEdit(AjaxUpdateView):
model = SalesOrderLineItem
form_class = order_forms.EditSalesOrderLineItemForm
ajax_form_title = _('Edit Line Item')
role_required = 'sales_order.change'
def get_form(self):
form = super().get_form()
@ -1268,6 +1301,7 @@ class POLineItemEdit(AjaxUpdateView):
form_class = order_forms.EditPurchaseOrderLineItemForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Line Item')
role_required = 'purchase_order.change'
def get_form(self):
form = super().get_form()
@ -1285,7 +1319,8 @@ class POLineItemDelete(AjaxDeleteView):
model = PurchaseOrderLineItem
ajax_form_title = _('Delete Line Item')
ajax_template_name = 'order/po_lineitem_delete.html'
role_required = 'purchase_order.delete'
def get_data(self):
return {
'danger': _('Deleted line item'),
@ -1297,6 +1332,7 @@ class SOLineItemDelete(AjaxDeleteView):
model = SalesOrderLineItem
ajax_form_title = _("Delete Line Item")
ajax_template_name = "order/so_lineitem_delete.html"
role_required = 'sales_order.delete'
def get_data(self):
return {
@ -1310,6 +1346,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Allocate Stock to Order')
role_required = 'sales_order.add'
def get_initial(self):
initials = super().get_initial().copy()
@ -1379,6 +1416,7 @@ class SalesOrderAllocationEdit(AjaxUpdateView):
model = SalesOrderAllocation
form_class = order_forms.EditSalesOrderAllocationForm
ajax_form_title = _('Edit Allocation Quantity')
role_required = 'sales_order.change'
def get_form(self):
form = super().get_form()
@ -1396,3 +1434,4 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
ajax_form_title = _("Remove allocation")
context_object_name = 'allocation'
ajax_template_name = "order/so_allocation_delete.html"
role_required = 'sales_order.delete'

View File

@ -44,6 +44,10 @@ class PartCategoryTree(TreeSerializer):
def get_items(self):
return PartCategory.objects.all().prefetch_related('parts', 'children')
permission_classes = [
permissions.IsAuthenticated,
]
class CategoryList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartCategory objects.
@ -55,10 +59,6 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
permission_classes = [
permissions.IsAuthenticated,
]
def get_queryset(self):
"""
Custom filtering:
@ -119,10 +119,6 @@ class PartSalePriceList(generics.ListCreateAPIView):
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend
]
@ -182,8 +178,6 @@ class PartTestTemplateList(generics.ListCreateAPIView):
return queryset
permission_classes = [permissions.IsAuthenticated]
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
@ -221,10 +215,6 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
queryset = Part.objects.all()
serializer_class = part_serializers.PartThumbSerializerUpdate
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend
]
@ -246,10 +236,6 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
def get_serializer(self, *args, **kwargs):
try:
@ -580,10 +566,6 @@ class PartList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -676,10 +658,6 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
queryset = PartParameterTemplate.objects.all()
serializer_class = part_serializers.PartParameterTemplateSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
filters.OrderingFilter,
]
@ -699,10 +677,6 @@ class PartParameterList(generics.ListCreateAPIView):
queryset = PartParameter.objects.all()
serializer_class = part_serializers.PartParameterSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend
]
@ -765,23 +739,36 @@ class BomList(generics.ListCreateAPIView):
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?
part = self.request.query_params.get('part', None)
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(part=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:
queryset = queryset.filter(sub_part=sub_part)
return queryset
# Filter by "trackable" status of the sub-part
trackable = params.get('trackable', None)
permission_classes = [
permissions.IsAuthenticated,
]
if trackable is not None:
trackable = str2bool(trackable)
queryset = queryset.filter(sub_part__trackable=trackable)
return queryset
filter_backends = [
DjangoFilterBackend,
@ -799,10 +786,6 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
permission_classes = [
permissions.IsAuthenticated,
]
class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """

View File

@ -37,4 +37,4 @@ class PartConfig(AppConfig):
part.image = None
part.save()
except (OperationalError, ProgrammingError):
print("Could not generate Part thumbnails")
pass

View File

@ -231,7 +231,8 @@ class EditBomItemForm(HelperForm):
'quantity',
'reference',
'overage',
'note'
'note',
'optional',
]
# Prevent editing of the part associated with this BomItem

View 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'),
),
]

View File

@ -111,6 +111,58 @@ class PartCategory(InvenTreeTree):
""" True if there are any parts in this category """
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')
def before_delete_part_category(sender, instance, using, **kwargs):
@ -382,7 +434,7 @@ class Part(MPTTModel):
return _('Next available serial numbers are') + ' ' + text
else:
text = str(latest)
text = str(latest + 1)
return _('Next available serial number is') + ' ' + text
@ -732,12 +784,13 @@ class Part(MPTTModel):
""" 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:
quantity = 0
query = stock_items.aggregate(
quantity=Coalesce(Sum('quantity'), Decimal(0))
)
return quantity
return query['quantity']
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)
sub_part: Link to the child part (the part that will be consumed)
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)
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
@ -1480,6 +1534,8 @@ class BomItem(models.Model):
# 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'))
optional = models.BooleanField(default=False, help_text=_("This BOM item is optional"))
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
help_text=_('Estimated build wastage quantity (absolute or percentage)')
)

View File

@ -403,6 +403,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'quantity',
'reference',
'price_range',
'optional',
'overage',
'note',
'validated',

View File

@ -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 "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button>
{% 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>
{% 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>
{% 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>
{% endif %}
</div>

View File

@ -1,15 +1,18 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'part/tabs.html' with tab='build' %}
<h3>Part Builds</h3>
<h3>{% trans "Part Builds" %}</h3>
<div id='button-toolbar'>
<div class='button-toolbar container-flui' style='float: right';>
{% 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 %}
<div class='filter-list' id='filter-list-build'>
<!-- Empty div for filters -->

View File

@ -9,7 +9,7 @@
{% if category %}
<h3>
{{ 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>
{% endif %}
</h3>
@ -20,17 +20,23 @@
{% endif %}
<p>
<div class='btn-group action-buttons'>
{% if roles.part.add %}
<button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
<span class='fas fa-plus-circle icon-green'/>
</button>
{% endif %}
{% if category %}
{% if roles.part.change %}
<button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
<span class='fas fa-edit icon-blue'/>
</button>
{% endif %}
{% if roles.part.delete %}
<button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
<span class='fas fa-trash-alt icon-red'/>
</button>
{% endif %}
{% endif %}
</div>
</p>
</div>
@ -104,11 +110,15 @@
<div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group'>
<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>
{% endif %}
<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>
<ul class='dropdown-menu'>
{% if roles.part.change %}
<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-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
@ -120,8 +130,11 @@
</div>
</div>
{% block category_tables %}
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
</table>
{% endblock category_tables %}
{% endblock %}
{% block js_load %}
@ -177,6 +190,7 @@
location.href = url;
});
{% if roles.part.add %}
$("#part-create").click(function() {
launchModalForm(
"{% url 'part-create' %}",
@ -204,6 +218,7 @@
}
);
});
{% endif %}
{% if category %}
$("#cat-edit").click(function () {

View 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 %}

View 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 %}

View 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>

View File

@ -29,7 +29,9 @@
<h4>{% trans "Part Notes" %}</h4>
</div>
<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>
{% endif %}
</div>
</div>
<hr>

View File

@ -10,7 +10,9 @@
<div id='button-toolbar'>
<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>
{% endif %}
</div>
</div>
@ -30,8 +32,12 @@
<td>
{{ param.template.units }}
<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>
{% 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>
{% endif %}
</div>
</td>
</tr>
@ -48,6 +54,7 @@
$('#param-table').inventreeTable({
});
{% if roles.part.add %}
$('#param-create').click(function() {
launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
reload: true,
@ -59,6 +66,7 @@
}],
});
});
{% endif %}
$('.param-edit').click(function() {
var button = $(this);

View File

@ -28,7 +28,7 @@
<div class="media-body">
<h3>
{{ 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>
{% endif %}
{% if not part.active %}
@ -56,26 +56,36 @@
<button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
<span id='part-price-icon' class='fas fa-dollar-sign'/>
</button>
<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'/>
</button>
{% endif %}
{% 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'/>
</button>
{% endif %}
{% endif %}
{% endif %}
<!-- Part actions -->
{% if roles.part.add or roles.part.change or roles.part.delete %}
<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>
<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>
{% endif %}
{% if roles.part.change %}
<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>
{% endif %}
</ul>
</div>
{% endif %}
</div>
<table class='table table-condensed'>
<col width='25'>
@ -274,6 +284,7 @@
});
});
{% if roles.part.change %}
$("#part-edit").click(function() {
launchModalForm(
"{% url 'part-edit' part.id %}",
@ -282,6 +293,7 @@
}
);
});
{% endif %}
$("#part-order").click(function() {
launchModalForm("{% url 'order-parts' %}", {
@ -292,6 +304,7 @@
});
});
{% if roles.part.add %}
$("#part-duplicate").click(function() {
launchModalForm(
"{% 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() {
launchModalForm(
"{% url 'part-delete' part.id %}",

View File

@ -26,14 +26,17 @@
{% if part.assembly %}
<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>
{% if roles.build.view %}
<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 %}
{% if part.component or part.used_in_count > 0 %}
<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>
{% endif %}
{% if part.purchaseable %}
{% if part.purchaseable and roles.purchase_order.view %}
{% if part.is_template == False %}
<li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
<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>
</li>
{% endif %}
{% if part.salable %}
{% if part.salable and roles.sales_order.view %}
<li {% if tab == 'sales-prices' %}class='active'{% endif %}>
<a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
</li>

View File

@ -3,6 +3,7 @@ from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from part.models import Part
from stock.models import StockItem
@ -29,7 +30,26 @@ class PartAPITest(APITestCase):
def setUp(self):
# Create a user for auth
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')

View File

@ -1,7 +1,7 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from .models import Part, PartCategory
from .models import Part, PartCategory, PartParameter, PartParameterTemplate
class CategoryTest(TestCase):
@ -15,6 +15,7 @@ class CategoryTest(TestCase):
'category',
'part',
'location',
'params',
]
def setUp(self):
@ -94,6 +95,31 @@ class CategoryTest(TestCase):
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):
# Test that an illegal character is prohibited in a category name

View File

@ -3,6 +3,7 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from .models import Part
@ -23,7 +24,24 @@ class PartViewTestCase(TestCase):
# Create a user
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')
@ -140,12 +158,14 @@ class PartTests(PartViewTestCase):
""" Tests for Part forms """
def test_part_edit(self):
response = self.client.get(reverse('part-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
keys = response.context.keys()
data = str(response.content)
self.assertEqual(response.status_code, 200)
self.assertIn('part', 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')
self.assertEqual(response.status_code, 200)
# TODO - Create a new attachment using this view
def test_invalid_create(self):
""" test creation of an attachment for an invalid part """

View File

@ -77,7 +77,8 @@ part_category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
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 = [

View File

@ -38,17 +38,21 @@ from .admin import PartResource
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.helpers import DownloadFile, str2bool
class PartIndex(ListView):
class PartIndex(InvenTreeRoleMixin, ListView):
""" View for displaying list of Part objects
"""
model = Part
template_name = 'part/category.html'
context_object_name = 'parts'
role_required = 'part.view'
def get_queryset(self):
return Part.objects.all().select_related('category')
@ -76,6 +80,8 @@ class PartAttachmentCreate(AjaxCreateView):
ajax_form_title = _("Add part attachment")
ajax_template_name = "modal_form.html"
role_required = 'part.add'
def post_save(self):
""" Record the user that uploaded the attachment """
self.object.user = self.request.user
@ -123,6 +129,8 @@ class PartAttachmentEdit(AjaxUpdateView):
form_class = part_forms.EditPartAttachmentForm
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit attachment')
role_required = 'part.change'
def get_data(self):
return {
@ -145,6 +153,8 @@ class PartAttachmentDelete(AjaxDeleteView):
ajax_template_name = "attachment_delete.html"
context_object_name = "attachment"
role_required = 'part.delete'
def get_data(self):
return {
'danger': _('Deleted part attachment')
@ -157,6 +167,8 @@ class PartTestTemplateCreate(AjaxCreateView):
model = PartTestTemplate
form_class = part_forms.EditPartTestTemplateForm
ajax_form_title = _("Create Test Template")
role_required = 'part.add'
def get_initial(self):
@ -185,6 +197,8 @@ class PartTestTemplateEdit(AjaxUpdateView):
form_class = part_forms.EditPartTestTemplateForm
ajax_form_title = _("Edit Test Template")
role_required = 'part.change'
def get_form(self):
form = super().get_form()
@ -199,6 +213,8 @@ class PartTestTemplateDelete(AjaxDeleteView):
model = PartTestTemplate
ajax_form_title = _("Delete Test Template")
role_required = 'part.delete'
class PartSetCategory(AjaxUpdateView):
""" View for settings the part category for multiple parts at once """
@ -207,6 +223,8 @@ class PartSetCategory(AjaxUpdateView):
ajax_form_title = _('Set Part Category')
form_class = part_forms.SetPartCategoryForm
role_required = 'part.change'
category = None
parts = []
@ -290,6 +308,8 @@ class MakePartVariant(AjaxCreateView):
ajax_form_title = _('Create Variant')
ajax_template_name = 'part/variant_part.html'
role_required = 'part.add'
def get_part_template(self):
return get_object_or_404(Part, id=self.kwargs['pk'])
@ -368,6 +388,8 @@ class PartDuplicate(AjaxCreateView):
ajax_form_title = _("Duplicate Part")
ajax_template_name = "part/copy_part.html"
role_required = 'part.add'
def get_data(self):
return {
'success': _('Copied part')
@ -491,6 +513,8 @@ class PartCreate(AjaxCreateView):
ajax_form_title = _('Create new part')
ajax_template_name = 'part/create_part.html'
role_required = 'part.add'
def get_data(self):
return {
'success': _("Created new part"),
@ -613,6 +637,8 @@ class PartNotes(UpdateView):
template_name = 'part/notes.html'
model = Part
role_required = 'part.change'
fields = ['notes']
def get_success_url(self):
@ -634,7 +660,7 @@ class PartNotes(UpdateView):
return ctx
class PartDetail(DetailView):
class PartDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for Part object
"""
@ -642,6 +668,8 @@ class PartDetail(DetailView):
queryset = Part.objects.all().select_related('category')
template_name = 'part/detail.html'
role_required = 'part.view'
# Add in some extra context information based on query params
def get_context_data(self, **kwargs):
""" Provide extra context data to template
@ -706,6 +734,8 @@ class PartQRCode(QRCodeView):
ajax_form_title = _("Part QR Code")
role_required = 'part.view'
def get_qr_data(self):
""" Generate QR code data for the Part """
@ -722,8 +752,11 @@ class PartImageUpload(AjaxUpdateView):
model = Part
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Upload Part Image')
form_class = part_forms.PartImageForm
role_required = 'part.change'
def get_data(self):
return {
'success': _('Updated part image'),
@ -737,6 +770,8 @@ class PartImageSelect(AjaxUpdateView):
ajax_template_name = 'part/select_image.html'
ajax_form_title = _('Select Part Image')
role_required = 'part.change'
fields = [
'image',
]
@ -778,6 +813,8 @@ class PartEdit(AjaxUpdateView):
ajax_form_title = _('Edit Part Properties')
context_object_name = 'part'
role_required = 'part.change'
def get_form(self):
""" Create form for Part editing.
Overrides default get_form() method to limit the choices
@ -802,6 +839,8 @@ class BomValidate(AjaxUpdateView):
context_object_name = 'part'
form_class = part_forms.BomValidateForm
role_required = 'part.change'
def get_context(self):
return {
'part': self.get_object(),
@ -832,7 +871,7 @@ class BomValidate(AjaxUpdateView):
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.
The BOM upload process is as follows:
@ -868,6 +907,8 @@ class BomUpload(FormView):
missing_columns = []
allowed_parts = []
role_required = ('part.change', 'part.add')
def get_success_url(self):
part = self.get_object()
return reverse('upload-bom', kwargs={'pk': part.id})
@ -1466,6 +1507,8 @@ class BomUpload(FormView):
class PartExport(AjaxView):
""" Export a CSV file containing information on multiple parts """
role_required = 'part.view'
def get_parts(self, request):
""" Extract part list from the POST parameters.
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
"""
role_required = 'part.view'
model = Part
def get(self, request, *args, **kwargs):
@ -1596,6 +1641,8 @@ class BomExport(AjaxView):
form_class = part_forms.BomExportForm
ajax_form_title = _("Export Bill of Materials")
role_required = 'part.view'
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(request, self.form_class())
@ -1645,6 +1692,8 @@ class PartDelete(AjaxDeleteView):
ajax_form_title = _('Confirm Part Deletion')
context_object_name = 'part'
role_required = 'part.delete'
success_url = '/part/'
def get_data(self):
@ -1661,6 +1710,8 @@ class PartPricing(AjaxView):
ajax_form_title = _("Part Pricing")
form_class = part_forms.PartPriceForm
role_required = ['sales_order.view', 'part.view']
def get_part(self):
try:
return Part.objects.get(id=self.kwargs['pk'])
@ -1778,6 +1829,8 @@ class PartPricing(AjaxView):
class PartParameterTemplateCreate(AjaxCreateView):
""" View for creating a new PartParameterTemplate """
role_required = 'part.add'
model = PartParameterTemplate
form_class = part_forms.EditPartParameterTemplateForm
ajax_form_title = _('Create Part Parameter Template')
@ -1786,6 +1839,8 @@ class PartParameterTemplateCreate(AjaxCreateView):
class PartParameterTemplateEdit(AjaxUpdateView):
""" View for editing a PartParameterTemplate """
role_required = 'part.change'
model = PartParameterTemplate
form_class = part_forms.EditPartParameterTemplateForm
ajax_form_title = _('Edit Part Parameter Template')
@ -1794,6 +1849,8 @@ class PartParameterTemplateEdit(AjaxUpdateView):
class PartParameterTemplateDelete(AjaxDeleteView):
""" View for deleting an existing PartParameterTemplate """
role_required = 'part.delete'
model = PartParameterTemplate
ajax_form_title = _("Delete Part Parameter Template")
@ -1801,6 +1858,8 @@ class PartParameterTemplateDelete(AjaxDeleteView):
class PartParameterCreate(AjaxCreateView):
""" View for creating a new PartParameter """
role_required = 'part.add'
model = PartParameter
form_class = part_forms.EditPartParameterForm
ajax_form_title = _('Create Part Parameter')
@ -1851,6 +1910,8 @@ class PartParameterCreate(AjaxCreateView):
class PartParameterEdit(AjaxUpdateView):
""" View for editing a PartParameter """
role_required = 'part.change'
model = PartParameter
form_class = part_forms.EditPartParameterForm
ajax_form_title = _('Edit Part Parameter')
@ -1865,17 +1926,62 @@ class PartParameterEdit(AjaxUpdateView):
class PartParameterDelete(AjaxDeleteView):
""" View for deleting a PartParameter """
role_required = 'part.delete'
model = PartParameter
ajax_template_name = 'part/param_delete.html'
ajax_form_title = _('Delete Part Parameter')
class CategoryDetail(DetailView):
class CategoryDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for PartCategory """
model = PartCategory
context_object_name = 'category'
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):
@ -1885,6 +1991,8 @@ class CategoryEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit Part Category')
role_required = 'part.change'
def get_context_data(self, **kwargs):
context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
@ -1922,6 +2030,8 @@ class CategoryDelete(AjaxDeleteView):
context_object_name = 'category'
success_url = '/part/'
role_required = 'part.delete'
def get_data(self):
return {
'danger': _('Part category was deleted'),
@ -1936,6 +2046,8 @@ class CategoryCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html'
form_class = part_forms.EditCategoryForm
role_required = 'part.add'
def get_context_data(self, **kwargs):
""" Add extra context data to template.
@ -1971,12 +2083,14 @@ class CategoryCreate(AjaxCreateView):
return initials
class BomItemDetail(DetailView):
class BomItemDetail(InvenTreeRoleMixin, DetailView):
""" Detail view for BomItem """
context_object_name = 'item'
queryset = BomItem.objects.all()
template_name = 'part/bom-detail.html'
role_required = 'part.view'
class BomItemCreate(AjaxCreateView):
""" Create view for making a new BomItem object """
@ -1985,6 +2099,8 @@ class BomItemCreate(AjaxCreateView):
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Create BOM item')
role_required = 'part.add'
def get_form(self):
""" Override get_form() method to reduce Part selection options.
@ -2051,6 +2167,8 @@ class BomItemEdit(AjaxUpdateView):
ajax_template_name = 'modal_form.html'
ajax_form_title = _('Edit BOM item')
role_required = 'part.change'
def get_form(self):
""" Override get_form() method to filter part selection options
@ -2099,6 +2217,8 @@ class BomItemDelete(AjaxDeleteView):
context_object_name = 'item'
ajax_form_title = _('Confim BOM item deletion')
role_required = 'part.delete'
class PartSalePriceBreakCreate(AjaxCreateView):
""" View for creating a sale price break for a part """
@ -2106,6 +2226,8 @@ class PartSalePriceBreakCreate(AjaxCreateView):
model = PartSellPriceBreak
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Add Price Break')
role_required = 'part.add'
def get_data(self):
return {
@ -2156,6 +2278,8 @@ class PartSalePriceBreakEdit(AjaxUpdateView):
form_class = part_forms.EditPartSalePriceBreakForm
ajax_form_title = _('Edit Price Break')
role_required = 'part.change'
def get_form(self):
form = super().get_form()
@ -2170,3 +2294,5 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
model = PartSellPriceBreak
ajax_form_title = _("Delete Price Break")
ajax_template_name = "modal_delete_form.html"
role_required = 'part.delete'

View File

@ -52,6 +52,10 @@ class StockCategoryTree(TreeSerializer):
def get_items(self):
return StockLocation.objects.all().prefetch_related('stock_items', 'children')
permission_classes = [
permissions.IsAuthenticated,
]
class StockDetail(generics.RetrieveUpdateDestroyAPIView):
""" API detail endpoint for Stock object
@ -68,7 +72,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = StockItem.objects.all()
serializer_class = StockItemSerializer
permission_classes = (permissions.IsAuthenticated,)
def get_queryset(self, *args, **kwargs):
@ -289,10 +292,6 @@ class StockLocationList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -695,10 +694,6 @@ class StockList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -744,10 +739,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -799,7 +790,6 @@ class StockTrackingList(generics.ListCreateAPIView):
queryset = StockItemTracking.objects.all()
serializer_class = StockTrackingSerializer
permission_classes = [permissions.IsAuthenticated]
def get_serializer(self, *args, **kwargs):
try:
@ -871,7 +861,6 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = StockLocation.objects.all()
serializer_class = LocationSerializer
permission_classes = (permissions.IsAuthenticated,)
stock_endpoints = [

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from django import forms
from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
from report.models import TestReport
from part.models import Part
from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
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):
"""
Form for uninstalling a stock item which is installed in another item.

View 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),
),
]

View File

@ -130,6 +130,7 @@ class StockItem(MPTTModel):
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field
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)
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)
@ -142,6 +143,7 @@ class StockItem(MPTTModel):
build_order=None,
belongs_to=None,
customer=None,
is_building=False,
status__in=StockStatus.AVAILABLE_CODES
)
@ -273,11 +275,25 @@ class StockItem(MPTTModel):
# TODO - Find a test than can be perfomed...
pass
# Ensure that the item cannot be assigned to itself
if self.belongs_to and self.belongs_to.pk == self.pk:
raise ValidationError({
'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):
return reverse('stock-item-detail', kwargs={'pk': self.id})
@ -389,6 +405,10 @@ class StockItem(MPTTModel):
related_name='build_outputs',
)
is_building = models.BooleanField(
default=False,
)
purchase_order = models.ForeignKey(
'order.PurchaseOrder',
on_delete=models.SET_NULL,
@ -600,12 +620,13 @@ class StockItem(MPTTModel):
return self.installedItemCount() > 0
@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
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
notes: Any notes associated with the operation
"""
@ -614,18 +635,29 @@ class StockItem(MPTTModel):
if self.belongs_to is not None:
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
self.belongs_to = otherItem
self.save()
if stock_item is None:
stock_item = otherItem
# Add a transaction note!
self.addTransactionNote(
_('Installed in stock item') + ' ' + str(otherItem.pk),
# Assign the other stock item into this one
stock_item.belongs_to = self
stock_item.save()
# Add a transaction note to the other item
stock_item.addTransactionNote(
_('Installed into stock item') + ' ' + str(self.pk),
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
@ -645,16 +677,31 @@ class StockItem(MPTTModel):
# 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.location = location
self.save()
if location:
url = location.get_absolute_url()
else:
url = ''
# Add a transaction note!
self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location),
user,
notes=notes
notes=notes,
url=url
)
@property
@ -688,6 +735,10 @@ class StockItem(MPTTModel):
if self.customer is not None:
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
if self.status in StockStatus.UNAVAILABLE_CODES:
return False
@ -838,20 +889,20 @@ class StockItem(MPTTModel):
# Do not split a serialized part
if self.serialized:
return
return self
try:
quantity = Decimal(quantity)
except (InvalidOperation, ValueError):
return
return self
# Doesn't make sense for a zero quantity
if quantity <= 0:
return
return self
# Also doesn't make sense to split the full amount
if quantity >= self.quantity:
return
return self
# Create a new StockItem object, duplicating relevant fields
# Nullify the PK so a new record is created

View File

@ -65,7 +65,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% else %}
<a href='{% url "part-detail" item.part.pk %}'>{{ item.part.full_name }}</a> &times {% decimal item.quantity %}
{% 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>
{% endif %}
</h4>

View 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 %}

View File

@ -10,19 +10,7 @@
<h4>{% trans "Installed Stock Items" %}</h4>
<hr>
<div id='button-toolbar'>
<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>
<table class='table table-striped table-condensed' id='installed-table'></table>
{% endblock %}
@ -30,135 +18,14 @@
{{ block.super }}
$('#installed-table').inventreeTable({
formatNoMatches: function() {
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,
title: '{% trans 'Select' %}',
searchable: false,
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',
]
});
loadInstalledInTable(
$('#installed-table'),
{
stock_item: {{ item.pk }},
part: {{ item.part.pk }},
quantity: {{ item.quantity }},
}
);
$('#multi-item-uninstall').click(function() {

View File

@ -1,6 +1,8 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
Create serialized items from this stock item.<br>
Select quantity to serialize, and unique serial numbers.
{% trans "Create serialized items from this stock item." %}
<br>
{% trans "Select quantity to serialize, and unique serial numbers." %}
{% endblock %}

View File

@ -8,7 +8,7 @@
{% if location %}
<h3>
{{ 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>
{% endif %}
</h3>

View File

@ -3,6 +3,8 @@ from rest_framework import status
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.helpers import addUserPermissions
from .models import StockLocation
@ -22,6 +24,20 @@ class StockAPITestCase(APITestCase):
# Create a user for auth
User = get_user_model()
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')
def doPost(self, url, data={}):

View File

@ -7,7 +7,9 @@ import datetime
from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemTestResult
from part.models import Part
from build.models import Build
class StockTest(TestCase):
@ -47,6 +49,35 @@ class StockTest(TestCase):
Part.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):
self.assertEqual(StockLocation.objects.count(), 7)

View File

@ -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'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
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'),

View File

@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
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):
"""
View for uninstalling one or more StockItems,

View 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 %}

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}
InvenTree | Index
InvenTree | {% trans "Index" %}
{% endblock %}
{% block content %}
@ -9,18 +9,26 @@ InvenTree | Index
<hr>
<div class='col-sm-6'>
{% if roles.part.view %}
{% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
{% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% endif %}
{% if roles.build.view %}
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
{% endif %}
</div>
<div class='col-sm-6'>
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
{% if roles.stock.view %}
{% include "InvenTree/low_stock.html" with collapse_id="order" %}
{% 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" %}
{% endif %}
</div>
{% endblock %}

View File

@ -39,6 +39,7 @@
<link rel="stylesheet" href="{% static 'css/select2.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-table-filter-control.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<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-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-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/moment.js' %}"></script>

View File

@ -169,6 +169,10 @@ function loadBomTable(table, options) {
// Let's make it a bit more pretty
text = parseFloat(text);
if (row.optional) {
text += " ({% trans "Optional" %})";
}
if (row.overage) {
text += "<small> (+" + row.overage + ") </small>";
}

View File

@ -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={}) {
/* Load part listing data into specified table.
*

View File

@ -470,10 +470,16 @@ function loadStockTable(table, options) {
if (row.customer) {
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
} else if (row.build_order) {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
} 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>`;
} else {
if (row.build_order) {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
} 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>`;
}
}
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
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
} else if (row.customer) {
var text = "{% trans "Shipped to customer" %}";
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) {
return renderLink(value, `/stock/location/${row.location}/`);
@ -798,4 +807,301 @@ function createNewStockItem(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();
},
}
);
}

View File

@ -15,9 +15,16 @@
</div>
<div class="navbar-collapse collapse">
<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>
{% endif %}
{% if roles.stock.view %}
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
{% endif %}
{% if roles.build.view %}
<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'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
<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>
</ul>
</li>
{% endif %}
{% if roles.sales_order.view %}
<li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
<ul class='dropdown-menu'>
@ -33,6 +42,7 @@
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% include "search_form.html" %}

View File

@ -1,3 +1,3 @@
<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>

View File

@ -6,19 +6,27 @@
<button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>{% trans "Export" %}</button>
{% if read_only %}
{% else %}
{% if roles.stock.add %}
<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">
<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">
{% 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-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-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>
{% endif %}
{% if roles.stock.delete %}
<li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% endif %}
</div>
<div class='filter-list' id='filter-list-stock'>
<!-- An empty div in which the filter list will be constructed -->

View File

@ -1,3 +1,132 @@
# -*- coding: utf-8 -*-
# from __future__ import unicode_literals
# from django.contrib import admin
from __future__ import unicode_literals
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)

View File

@ -1,8 +1,33 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig
class UsersConfig(AppConfig):
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)

View 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')},
},
),
]

View 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),
),
]

View 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'),
),
]

View File

View File

@ -1 +1,378 @@
# -*- 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

View File

@ -1,4 +1,159 @@
# -*- 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)

View File

@ -25,5 +25,6 @@ django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export
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

View File

@ -22,7 +22,8 @@ def apps():
'part',
'report',
'stock',
'InvenTree'
'InvenTree',
'users',
]
def localDir():