Merge branch 'master' of git://github.com/inventree/InvenTree into user_unique_group_validation

This commit is contained in:
eeintech 2020-10-06 10:04:35 -05:00
commit 19a2326638
49 changed files with 1781 additions and 1032 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

@ -210,6 +210,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'InvenTree.context.status_codes',
'InvenTree.context.user_roles',
],
},
},
@ -231,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

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

@ -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
]
@ -796,10 +770,6 @@ class BomList(generics.ListCreateAPIView):
return queryset
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
@ -816,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

@ -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>
@ -180,6 +190,7 @@
location.href = url;
});
{% if roles.part.add %}
$("#part-create").click(function() {
launchModalForm(
"{% url 'part-create' %}",
@ -207,6 +218,7 @@
}
);
});
{% endif %}
{% if category %}
$("#cat-edit").click(function () {

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

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

@ -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,12 +1926,14 @@ 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
@ -1878,6 +1941,8 @@ class CategoryDetail(DetailView):
queryset = PartCategory.objects.all().prefetch_related('children')
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()
@ -1926,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()
@ -1963,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'),
@ -1977,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.
@ -2012,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 """
@ -2026,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.
@ -2092,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
@ -2140,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 """
@ -2147,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 {
@ -2197,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()
@ -2211,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

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

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

@ -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,24 +9,24 @@ InvenTree | Index
<hr>
<div class='col-sm-6'>
{% if perms.part.view_part %}
{% 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/starred_parts.html" with collapse_id="starred" %}
{% endif %}
{% if perms.build.view_build %}
{% if roles.build.view %}
{% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
{% endif %}
</div>
<div class='col-sm-6'>
{% if perms.stock.view_stockitem %}
{% 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 perms.order.view_purchaseorder %}
{% if roles.purchase_order.view %}
{% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
{% endif %}
{% if perms.order.view_salesorder %}
{% if roles.sales_order.view %}
{% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
{% endif %}
</div>

View File

@ -15,16 +15,16 @@
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
{% if perms.part.view_part or perms.part.view_partcategory %}
{% if roles.part.view %}
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
{% endif %}
{% if perms.stock.view_stockitem or perms.part.view_stocklocation %}
{% if roles.stock.view %}
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
{% endif %}
{% if perms.build.view_build %}
{% if roles.build.view %}
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
{% endif %}
{% if perms.order.view_purchaseorder %}
{% 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'>
@ -34,7 +34,7 @@
</ul>
</li>
{% endif %}
{% if perms.order.view_salesorder %}
{% 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'>

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

@ -119,15 +119,12 @@ class RoleGroupAdmin(admin.ModelAdmin):
# Save inlines before model
# https://stackoverflow.com/a/14860703/12794913
def save_model(self, request, obj, form, change):
if obj is not None:
# Save model immediately only if in 'Add role' view
super().save_model(request, obj, form, change)
else:
pass # don't actually save the parent instance
pass # don't actually save the parent instance
def save_formset(self, request, form, formset, change):
formset.save() # this will save the children
form.instance.save() # form.instance is the parent
# update_fields is required to trigger permissions update
form.instance.save(update_fields=['name']) # form.instance is the parent
class InvenTreeUserAdmin(UserAdmin):

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

@ -36,6 +36,10 @@ class RuleSet(models.Model):
choice[0] for choice in RULESET_CHOICES
]
RULESET_PERMISSIONS = [
'view', 'add', 'change', 'delete',
]
RULESET_MODELS = {
'admin': [
'auth_group',
@ -134,9 +138,9 @@ class RuleSet(models.Model):
can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items'))
can_add = models.BooleanField(verbose_name=_('Add'), default=False, help_text=_('Permission to add items'))
can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit 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'))
@ -155,8 +159,15 @@ class RuleSet(models.Model):
model=model
)
def __str__(self):
return self.name
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):
@ -171,6 +182,10 @@ class RuleSet(models.Model):
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.
@ -329,3 +344,35 @@ def create_missing_rule_sets(sender, instance, **kwargs):
"""
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

@ -137,7 +137,8 @@ class RuleSetModelTest(TestCase):
rule.save()
group.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))
@ -151,7 +152,8 @@ class RuleSetModelTest(TestCase):
rule.save()
group.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)