diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index 44e7ec383f..1bdfb79ceb 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -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 = {
diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py
index 7de41eef15..f9d856f566 100644
--- a/InvenTree/InvenTree/context.py
+++ b/InvenTree/InvenTree/context.py
@@ -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}
diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 9b470902b1..13b770539c 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -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)
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 21b8a0ead1..c6f8b40069 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -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'
}
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index d940229ebe..198903db9a 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -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
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
from .helpers import str2bool
@@ -107,31 +108,66 @@ 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('.')
+
+ # 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)
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index c0faee6c15..d4e458c506 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -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,
]
diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 915433b055..ed3da576d5 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -35,7 +35,7 @@ src="{% static 'img/blank_image.png' %}"
{{ build.quantity }} x {{ build.part.full_name }}
- {% if user.is_staff and perms.build.change_build %}
+ {% if user.is_staff and roles.build.change %}
{% endif %}
diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py
index 0e54d7d7fb..548ac96016 100644
--- a/InvenTree/company/api.py
+++ b/InvenTree/company/api.py
@@ -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,
]
diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html
index 73ebecf979..f20107277d 100644
--- a/InvenTree/company/templates/company/company_base.html
+++ b/InvenTree/company/templates/company/company_base.html
@@ -23,7 +23,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
{{ company.name }}
- {% if user.is_staff and perms.company.change_company %}
+ {% if user.is_staff and roles.company.change %}
{% endif %}
{{ part.full_name }}
- {% if user.is_staff and perms.part.change_part %}
+ {% if user.is_staff and roles.part.change %}
{% endif %}
{% if not part.active %}
@@ -56,26 +56,36 @@
-
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 3b116fa445..328ad3ef9e 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -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')
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index bc09784a47..d8c345d243 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -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 """
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index dc8d07f5cd..6498774285 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -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'
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 55bc62a44e..ba802b75d9 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -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 = [
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index b3fb9af743..928aa6b7a1 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -65,7 +65,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
{% else %}
{{ item.part.full_name }} × {% decimal item.quantity %}
{% endif %}
-{% if user.is_staff and perms.stock.change_stockitem %}
+{% if user.is_staff and roles.stock.change %}
{% endif %}
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html
index 2f319f4925..d411891078 100644
--- a/InvenTree/stock/templates/stock/location.html
+++ b/InvenTree/stock/templates/stock/location.html
@@ -8,7 +8,7 @@
{% if location %}
{{ location.name }}
- {% if user.is_staff and perms.stock.change_stocklocation %}
+ {% if user.is_staff and roles.stock.change %}
{% endif %}
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index a522bc5415..8348a3e331 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -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={}):
diff --git a/InvenTree/templates/403.html b/InvenTree/templates/403.html
new file mode 100644
index 0000000000..372bd9fe27
--- /dev/null
+++ b/InvenTree/templates/403.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block page_title %}
+InvenTree | {% trans "Permission Denied" %}
+{% endblock %}
+
+{% block content %}
+
+
+
{% trans "Permission Denied" %}
+
+
+ {% trans "You do not have permission to view this page." %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index a9e4ae92ea..8e59d51d2b 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -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
- {% 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 %}
- {% 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 %}
\ No newline at end of file
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index c41d2181c9..b4b1b9d50f 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -6,19 +6,27 @@
{% trans "Export" %}
{% if read_only %}
{% else %}
+ {% if roles.stock.add %}
{% trans "New Stock Item" %}
+ {% endif %}
+ {% if roles.stock.change or roles.stock.delete %}