From 12c2ea3b375cd6ea3ae84758d533fb33fb44b166 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 10:54:54 +1100 Subject: [PATCH 01/19] Refactor multi-bom deletion --- InvenTree/part/templates/part/bom.html | 37 ++++++++++---------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html index 54f069257b..f7b366bccb 100644 --- a/InvenTree/part/templates/part/bom.html +++ b/InvenTree/part/templates/part/bom.html @@ -111,34 +111,25 @@ { accept: function() { - // Delete each row one at a time! - function deleteRow(idx) { + // Keep track of each DELETE request + var requests = []; - if (idx >= rows.length) { - // All selected rows deleted - reload the table - $("#bom-table").bootstrapTable('refresh'); - } + rows.forEach(function(row) { + requests.push( + inventreeDelete( + `/api/bom/${row.pk}/`, + ) + ); + }); - var row = rows[idx]; - - var url = `/api/bom/${row.pk}/`; - - inventreeDelete( - url, - { - complete: function(xhr, status) { - deleteRow(idx + 1); - } - } - ) - } - - // Start the deletion! - deleteRow(0); + // Wait for *all* the requests to complete + $.when.apply($, requests).then(function() { + $('#bom-table').bootstrapTable('refresh'); + }); } } ); - }); + }); $('#bom-upload').click(function() { location.href = "{% url 'upload-bom' part.id %}"; From 1dfda5b0edc284247d16e486d4a39391dc8d3bc1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 13:03:03 +1100 Subject: [PATCH 02/19] Fix display for top-level category --- InvenTree/part/templates/part/category.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index f71d8c7578..464482b29b 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -138,13 +138,13 @@

{% block heading %} - + {% trans "Part Categories" %} {% endblock %}

{% block details %} - +
{% endblock %}
From cd8c6fa81aaaf166b47c7d66decc755c5c75a7d2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 13:53:23 +1100 Subject: [PATCH 03/19] Add RolePermission for API endpoints --- InvenTree/InvenTree/api.py | 81 ++++++++++++++++++++++++++++++++++++++ InvenTree/part/api.py | 9 ++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index c204c0befb..0180ad22b5 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -20,6 +20,8 @@ from rest_framework.views import APIView from .views import AjaxView from .version import inventreeVersion, inventreeInstanceName +from users.models import check_user_role, RuleSet + from plugins import plugins as inventree_plugins @@ -70,6 +72,85 @@ class AttachmentMixin: attachment.save() +class RolePermission(permissions.BasePermission): + """ + Role mixin for API endpoints, allowing us to specify the user "role" + which is required for certain operations. + + Each endpoint can have one or more of the following actions: + - GET + - POST + - PUT + - PATCH + - DELETE + + Specify the required "role" using the role_required attribute. + + e.g. + + role_required = "part" + + The RoleMixin class will then determine if the user has the required permission + to perform the specified action. + + For example, a DELETE action will be rejected unless the user has the "part.remove" permission + + """ + + def has_permission(self, request, view): + """ + Determine if the current user has the specified permissions + """ + + # First, check that the user is authenticated! + auth = permissions.IsAuthenticated() + + if not auth.has_permission(request, view): + return False + + user = request.user + + # Superuser can do it all + if False and user.is_superuser: + return True + + # Map the request method to a permission type + rolemap = { + 'GET': 'view', + 'OPTIONS': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + } + + permission = rolemap[request.method] + + role = getattr(view, 'role_required', None) + + if not role: + raise AttributeError(f"'role_required' not specified for view {type(view).__name__}") + + roles = [] + + if type(role) is str: + roles = [role] + elif type(role) in [list, tuple]: + roles = role + else: + raise TypeError(f"'role_required' is of incorrect type ({type(role)}) for view {type(view).__name__}") + + for role in roles: + + if role not in RuleSet.RULESET_NAMES: + raise ValueError(f"Role '{role}' is not a valid role") + + if not check_user_role(user, role, permission): + return False + + # All checks passed + return True + class ActionPluginView(APIView): """ Endpoint for running custom action plugins. diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 88d691797e..ded9a64cbb 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -29,7 +29,8 @@ from . import serializers as part_serializers from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull -from InvenTree.api import AttachmentMixin +from InvenTree.api import AttachmentMixin, RolePermission + from InvenTree.status_codes import BuildStatus @@ -105,6 +106,12 @@ class CategoryList(generics.ListCreateAPIView): 'description', ] + role_required = 'part' + + permission_classes = [ + RolePermission, + ] + class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single PartCategory object """ From 36359fc5478b6d8b9718885d57d751c60fd3b3be Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 14:05:26 +1100 Subject: [PATCH 04/19] RolePermission is now default for API endpoints --- InvenTree/InvenTree/api.py | 79 --------------------------- InvenTree/InvenTree/permissions.py | 86 ++++++++++++++++++++++++++++++ InvenTree/InvenTree/settings.py | 1 + InvenTree/part/api.py | 6 +-- 4 files changed, 88 insertions(+), 84 deletions(-) create mode 100644 InvenTree/InvenTree/permissions.py diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 0180ad22b5..afd4d91de1 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -70,86 +70,7 @@ class AttachmentMixin: attachment = serializer.save() attachment.user = self.request.user attachment.save() - - -class RolePermission(permissions.BasePermission): - """ - Role mixin for API endpoints, allowing us to specify the user "role" - which is required for certain operations. - - Each endpoint can have one or more of the following actions: - - GET - - POST - - PUT - - PATCH - - DELETE - - Specify the required "role" using the role_required attribute. - - e.g. - - role_required = "part" - - The RoleMixin class will then determine if the user has the required permission - to perform the specified action. - - For example, a DELETE action will be rejected unless the user has the "part.remove" permission - - """ - - def has_permission(self, request, view): - """ - Determine if the current user has the specified permissions - """ - - # First, check that the user is authenticated! - auth = permissions.IsAuthenticated() - - if not auth.has_permission(request, view): - return False - - user = request.user - - # Superuser can do it all - if False and user.is_superuser: - return True - - # Map the request method to a permission type - rolemap = { - 'GET': 'view', - 'OPTIONS': 'view', - 'POST': 'add', - 'PUT': 'change', - 'PATCH': 'change', - 'DELETE': 'delete', - } - - permission = rolemap[request.method] - - role = getattr(view, 'role_required', None) - - if not role: - raise AttributeError(f"'role_required' not specified for view {type(view).__name__}") - roles = [] - - if type(role) is str: - roles = [role] - elif type(role) in [list, tuple]: - roles = role - else: - raise TypeError(f"'role_required' is of incorrect type ({type(role)}) for view {type(view).__name__}") - - for role in roles: - - if role not in RuleSet.RULESET_NAMES: - raise ValueError(f"Role '{role}' is not a valid role") - - if not check_user_role(user, role, permission): - return False - - # All checks passed - return True class ActionPluginView(APIView): """ diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py new file mode 100644 index 0000000000..daa57136b3 --- /dev/null +++ b/InvenTree/InvenTree/permissions.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from rest_framework import permissions + +import users.models + + +class RolePermission(permissions.BasePermission): + """ + Role mixin for API endpoints, allowing us to specify the user "role" + which is required for certain operations. + + Each endpoint can have one or more of the following actions: + - GET + - POST + - PUT + - PATCH + - DELETE + + Specify the required "role" using the role_required attribute. + + e.g. + + role_required = "part" + + The RoleMixin class will then determine if the user has the required permission + to perform the specified action. + + For example, a DELETE action will be rejected unless the user has the "part.remove" permission + + """ + + def has_permission(self, request, view): + """ + Determine if the current user has the specified permissions + """ + + # First, check that the user is authenticated! + auth = permissions.IsAuthenticated() + + if not auth.has_permission(request, view): + return False + + user = request.user + + # Superuser can do it all + if False and user.is_superuser: + return True + + # Map the request method to a permission type + rolemap = { + 'GET': 'view', + 'OPTIONS': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + } + + permission = rolemap[request.method] + + role = getattr(view, 'role_required', None) + + if not role: + raise AttributeError(f"'role_required' not specified for view {type(view).__name__}") + + roles = [] + + if type(role) is str: + roles = [role] + elif type(role) in [list, tuple]: + roles = role + else: + raise TypeError(f"'role_required' is of incorrect type ({type(role)}) for view {type(view).__name__}") + + for role in roles: + + if role not in users.models.RuleSet.RULESET_NAMES: + raise ValueError(f"Role '{role}' is not a valid role") + + if not users.models.check_user_role(user, role, permission): + return False + + # All checks passed + return True \ No newline at end of file diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 70760624c6..5dbfb845bc 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -278,6 +278,7 @@ REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions', + 'InvenTree.permissions.RolePermission', ), 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' } diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index ded9a64cbb..71e1d63314 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -29,7 +29,7 @@ from . import serializers as part_serializers from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull -from InvenTree.api import AttachmentMixin, RolePermission +from InvenTree.api import AttachmentMixin from InvenTree.status_codes import BuildStatus @@ -108,10 +108,6 @@ class CategoryList(generics.ListCreateAPIView): role_required = 'part' - permission_classes = [ - RolePermission, - ] - class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single PartCategory object """ From 0e971c468bc324c7279b8f2d23cef6c2f9d8f24b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 14:07:18 +1100 Subject: [PATCH 05/19] Remove test code --- InvenTree/InvenTree/api.py | 4 +--- InvenTree/InvenTree/permissions.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index afd4d91de1..c204c0befb 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -20,8 +20,6 @@ from rest_framework.views import APIView from .views import AjaxView from .version import inventreeVersion, inventreeInstanceName -from users.models import check_user_role, RuleSet - from plugins import plugins as inventree_plugins @@ -70,7 +68,7 @@ class AttachmentMixin: attachment = serializer.save() attachment.user = self.request.user attachment.save() - + class ActionPluginView(APIView): """ diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index daa57136b3..f879559f11 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -45,7 +45,7 @@ class RolePermission(permissions.BasePermission): user = request.user # Superuser can do it all - if False and user.is_superuser: + if user.is_superuser: return True # Map the request method to a permission type From 81e9fd7a448829f7bde7b6aac98d49ffa587cceb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 14:26:37 +1100 Subject: [PATCH 06/19] Escape hatch if role not required --- InvenTree/InvenTree/permissions.py | 3 ++- InvenTree/part/api.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index f879559f11..f4aa7f532c 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -63,7 +63,8 @@ class RolePermission(permissions.BasePermission): role = getattr(view, 'role_required', None) if not role: - raise AttributeError(f"'role_required' not specified for view {type(view).__name__}") + # Role not specified - allow access + return True roles = [] diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 71e1d63314..72cd035514 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -373,6 +373,8 @@ class PartList(generics.ListCreateAPIView): queryset = Part.objects.all() + role_required = 'part' + starred_parts = None def get_serializer(self, *args, **kwargs): From 20740035e88a3c76ef43c814a17dccb1671e0330 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 14:37:50 +1100 Subject: [PATCH 07/19] Add role to API endpoints in 'part' app --- InvenTree/part/api.py | 87 +++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 58 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 72cd035514..d10e87763d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -38,6 +38,8 @@ class PartCategoryTree(TreeSerializer): title = "Parts" model = PartCategory + + queryset = PartCategory.objects.all() @property def root_url(self): @@ -46,9 +48,7 @@ class PartCategoryTree(TreeSerializer): def get_items(self): return PartCategory.objects.all().prefetch_related('parts', 'children') - permission_classes = [ - permissions.IsAuthenticated, - ] + role_required = 'part' class CategoryList(generics.ListCreateAPIView): @@ -114,6 +114,8 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() + role_required = 'part' + class CategoryParameters(generics.ListAPIView): """ API endpoint for accessing a list of PartCategoryParameterTemplate objects. @@ -124,6 +126,8 @@ class CategoryParameters(generics.ListAPIView): queryset = PartCategoryParameterTemplate.objects.all() serializer_class = part_serializers.CategoryParameterTemplateSerializer + role_required = 'part' + def get_queryset(self): """ Custom filtering: @@ -168,6 +172,8 @@ class PartSalePriceList(generics.ListCreateAPIView): queryset = PartSellPriceBreak.objects.all() serializer_class = part_serializers.PartSalePriceSerializer + role_required = 'part' + filter_backends = [ DjangoFilterBackend ] @@ -185,6 +191,8 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = PartAttachment.objects.all() serializer_class = part_serializers.PartAttachmentSerializer + role_required = 'part' + filter_backends = [ DjangoFilterBackend, ] @@ -202,6 +210,8 @@ class PartTestTemplateList(generics.ListCreateAPIView): queryset = PartTestTemplate.objects.all() serializer_class = part_serializers.PartTestTemplateSerializer + role_required = 'part' + def filter_queryset(self, queryset): """ Filter the test list queryset. @@ -243,6 +253,8 @@ class PartThumbs(generics.ListAPIView): API endpoint for retrieving information on available Part thumbnails """ + role_required = 'part' + queryset = Part.objects.all() serializer_class = part_serializers.PartThumbSerializer @@ -279,6 +291,8 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): queryset = Part.objects.all() serializer_class = part_serializers.PartThumbSerializerUpdate + role_required = 'part' + filter_backends = [ DjangoFilterBackend ] @@ -287,6 +301,8 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single Part object """ + role_required = 'part' + queryset = Part.objects.all() serializer_class = part_serializers.PartSerializer @@ -694,55 +710,6 @@ class PartList(generics.ListCreateAPIView): ] -class PartStarDetail(generics.RetrieveDestroyAPIView): - """ API endpoint for viewing or removing a PartStar object """ - - queryset = PartStar.objects.all() - serializer_class = part_serializers.PartStarSerializer - - -class PartStarList(generics.ListCreateAPIView): - """ API endpoint for accessing a list of PartStar objects. - - - GET: Return list of PartStar objects - - POST: Create a new PartStar object - """ - - queryset = PartStar.objects.all() - serializer_class = part_serializers.PartStarSerializer - - def create(self, request, *args, **kwargs): - - # Override the user field (with the logged-in user) - data = request.data.copy() - data['user'] = str(request.user.id) - - serializer = self.get_serializer(data=data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - permission_classes = [ - permissions.IsAuthenticated, - ] - - filter_backends = [ - DjangoFilterBackend, - filters.SearchFilter - ] - - filter_fields = [ - 'part', - 'user', - ] - - search_fields = [ - 'partname' - ] - - class PartParameterTemplateList(generics.ListCreateAPIView): """ API endpoint for accessing a list of PartParameterTemplate objects. @@ -750,6 +717,8 @@ class PartParameterTemplateList(generics.ListCreateAPIView): - POST: Create a new PartParameterTemplate object """ + role_required = 'part' + queryset = PartParameterTemplate.objects.all() serializer_class = part_serializers.PartParameterTemplateSerializer @@ -769,6 +738,8 @@ class PartParameterList(generics.ListCreateAPIView): - POST: Create a new PartParameter object """ + role_required = 'part' + queryset = PartParameter.objects.all() serializer_class = part_serializers.PartParameterSerializer @@ -789,6 +760,8 @@ class BomList(generics.ListCreateAPIView): - POST: Create a new BomItem object """ + role_required = 'part' + serializer_class = part_serializers.BomItemSerializer def list(self, request, *args, **kwargs): @@ -928,6 +901,8 @@ class BomList(generics.ListCreateAPIView): class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ + role_required = 'part' + queryset = BomItem.objects.all() serializer_class = part_serializers.BomItemSerializer @@ -935,6 +910,8 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): class BomItemValidate(generics.UpdateAPIView): """ API endpoint for validating a BomItem """ + role_required = 'part' + # Very simple serializers class BomItemValidationSerializer(serializers.Serializer): @@ -980,12 +957,6 @@ part_api_urls = [ url(r'^attachment/', include([ url(r'^$', PartAttachmentList.as_view(), name='api-part-attachment-list'), ])), - - # Base URL for PartStar API endpoints - url(r'^star/', include([ - url(r'^(?P\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'), - url(r'^$', PartStarList.as_view(), name='api-part-star-list'), - ])), # Base URL for part sale pricing url(r'^sale-price/', include([ From cd5bc395f260c0dd6df469a16ff6c270b8f4f449 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 16:03:38 +1100 Subject: [PATCH 08/19] PEP fixes --- InvenTree/InvenTree/permissions.py | 2 +- InvenTree/part/api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index f4aa7f532c..9f34c0d918 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -84,4 +84,4 @@ class RolePermission(permissions.BasePermission): return False # All checks passed - return True \ No newline at end of file + return True diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index d10e87763d..c20d6bee60 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -12,12 +12,12 @@ from django.db.models import Q, F, Count, Prefetch, Sum from rest_framework import status from rest_framework.response import Response from rest_framework import filters, serializers -from rest_framework import generics, permissions +from rest_framework import generics from django.conf.urls import url, include from django.urls import reverse -from .models import Part, PartCategory, BomItem, PartStar +from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak From 5c61c18dc4bfbdb572a75a1ec40bb7ccadf7d29e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 17:52:06 +1100 Subject: [PATCH 09/19] Add API endpoint which provides list of role permissions available to current user --- InvenTree/part/templates/part/category.html | 3 +- InvenTree/users/{views.py => api.py} | 47 +++++++++++++++++++++ InvenTree/users/urls.py | 9 ++-- 3 files changed, 54 insertions(+), 5 deletions(-) rename InvenTree/users/{views.py => api.py} (67%) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 464482b29b..0d1d11e7fd 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -144,7 +144,8 @@
{% block details %} -
+ +
{% endblock %}
diff --git a/InvenTree/users/views.py b/InvenTree/users/api.py similarity index 67% rename from InvenTree/users/views.py rename to InvenTree/users/api.py index 97e5f48355..a109884ea9 100644 --- a/InvenTree/users/views.py +++ b/InvenTree/users/api.py @@ -1,3 +1,9 @@ + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from rest_framework import generics + from rest_framework import generics, permissions from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -9,6 +15,47 @@ from rest_framework.response import Response from rest_framework import status + +from .models import RuleSet, check_user_role + + +class RoleDetails(APIView): + """ + API endpoint which lists the available role permissions + for the current user + + (Requires authentication) + """ + + permission_classes = [ + permissions.IsAuthenticated + ] + + def get(self, request, *args, **kwargs): + + user = request.user + + data = {} + + for ruleset in RuleSet.RULESET_CHOICES: + + role, text = ruleset + + permissions = [] + + for permission in RuleSet.RULESET_PERMISSIONS: + if check_user_role(user, role, permission): + + permissions.append(permission) + + if len(permissions) > 0: + data[role] = permissions + else: + data[role] = None + + return Response(data) + + class UserDetail(generics.RetrieveAPIView): """ Detail endpoint for a single user """ diff --git a/InvenTree/users/urls.py b/InvenTree/users/urls.py index 312789b55b..df05ae684a 100644 --- a/InvenTree/users/urls.py +++ b/InvenTree/users/urls.py @@ -1,11 +1,12 @@ from django.conf.urls import url -from . import views +from . import api user_urls = [ - url(r'^(?P[0-9]+)/?$', views.UserDetail.as_view(), name='user-detail'), + url(r'^(?P[0-9]+)/?$', api.UserDetail.as_view(), name='user-detail'), - url(r'token', views.GetAuthToken.as_view(), name='api-token'), + url(r'roles', api.RoleDetails.as_view(), name='api-roles'), + url(r'token', api.GetAuthToken.as_view(), name='api-token'), - url(r'^$', views.UserList.as_view()), + url(r'^$', api.UserList.as_view()), ] From 2460965fef0d2212d386116b3708a1dd9d6d9310 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 17:55:56 +1100 Subject: [PATCH 10/19] Add some more context data --- InvenTree/users/api.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index a109884ea9..79cf9d2134 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -35,7 +35,7 @@ class RoleDetails(APIView): user = request.user - data = {} + roles = {} for ruleset in RuleSet.RULESET_CHOICES: @@ -49,9 +49,17 @@ class RoleDetails(APIView): permissions.append(permission) if len(permissions) > 0: - data[role] = permissions + roles[role] = permissions else: - data[role] = None + roles[role] = None + + data = { + 'user': user.pk, + 'username': user.username, + 'roles': roles, + 'is_staff': user.is_staff, + 'is_superuser': user.is_superuser, + } return Response(data) From aad92902f2206b7c287e41764ee0bc7b637ca61d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 20:37:12 +1100 Subject: [PATCH 11/19] Unit tests for new role view --- InvenTree/InvenTree/api_tester.py | 40 +++++++++++++++++++++++ InvenTree/InvenTree/test_api.py | 53 +++++++++++++++++++++++++++---- InvenTree/users/api.py | 5 --- InvenTree/users/urls.py | 2 +- 4 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 InvenTree/InvenTree/api_tester.py diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py new file mode 100644 index 0000000000..a38077000a --- /dev/null +++ b/InvenTree/InvenTree/api_tester.py @@ -0,0 +1,40 @@ +""" +Helper functions for performing API unit tests +""" + +from django.contrib.auth import get_user_model +from rest_framework.test import APITestCase + + +class InvenTreeAPITestCase(APITestCase): + """ + Base class for running InvenTree API tests + """ + + # User information + username = 'testuser' + password = 'mypassword' + email = 'test@testing.com' + + auto_login = True + + def setUp(self): + + super().setUp() + + # Create a user to log in with + self.user = get_user_model().objects.create_user( + username=self.username, + password=self.password, + email=self.email + ) + + if self.auto_login: + self.client.login(username=self.username, password=self.password) + + def setRoles(self, roles): + """ + Set the user roles for the registered user + """ + + pass \ No newline at end of file diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index f44542656f..9035f72435 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -6,10 +6,14 @@ from rest_framework import status from django.urls import reverse from django.contrib.auth import get_user_model +from InvenTree.api_tester import InvenTreeAPITestCase + +from users.models import RuleSet + from base64 import b64encode -class APITests(APITestCase): +class APITests(InvenTreeAPITestCase): """ Tests for the InvenTree API """ fixtures = [ @@ -19,15 +23,13 @@ class APITests(APITestCase): 'category', ] - username = 'test_user' - password = 'test_pass' - token = None + auto_login = False + def setUp(self): - # Create a user (but do not log in!) - get_user_model().objects.create_user(self.username, 'user@email.com', self.password) + super().setUp() def basicAuth(self): # Use basic authentication @@ -78,3 +80,42 @@ class APITests(APITestCase): self.assertIn('instance', data) self.assertEquals('InvenTree', data['server']) + + def test_role_view(self): + """ + Test that we can access the 'roles' view for the logged in user. + + Also tests that it is *not* accessible if the client is not logged in. + """ + + url = reverse('api-user-roles') + + response = self.client.get(url, format='json') + + # Not logged in, so cannot access user role data + self.assertTrue(response.status_code in [401, 403]) + + # Now log in! + self.basicAuth() + + response = self.client.get(url, format='json') + + self.assertEqual(response.status_code, 200) + + data = response.data + + self.assertIn('user', data) + self.assertIn('username', data) + self.assertIn('is_staff', data) + self.assertIn('is_superuser', data) + self.assertIn('roles', data) + + roles = data['roles'] + + role_names = roles.keys() + + # By default, no roles are assigned to the user... + for rule in RuleSet.RULESET_NAMES: + self.assertIn(rule, role_names) + self.assertIsNone(roles[rule]) + \ No newline at end of file diff --git a/InvenTree/users/api.py b/InvenTree/users/api.py index 79cf9d2134..5447bb5547 100644 --- a/InvenTree/users/api.py +++ b/InvenTree/users/api.py @@ -1,9 +1,6 @@ - # -*- coding: utf-8 -*- from __future__ import unicode_literals -from rest_framework import generics - from rest_framework import generics, permissions from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -14,8 +11,6 @@ from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework import status - - from .models import RuleSet, check_user_role diff --git a/InvenTree/users/urls.py b/InvenTree/users/urls.py index df05ae684a..7d8d23883f 100644 --- a/InvenTree/users/urls.py +++ b/InvenTree/users/urls.py @@ -5,7 +5,7 @@ from . import api user_urls = [ url(r'^(?P[0-9]+)/?$', api.UserDetail.as_view(), name='user-detail'), - url(r'roles', api.RoleDetails.as_view(), name='api-roles'), + url(r'roles', api.RoleDetails.as_view(), name='api-user-roles'), url(r'token', api.GetAuthToken.as_view(), name='api-token'), url(r'^$', api.UserList.as_view()), From 0f6cdd0037ac0054db930ef7f65dbfaac5fd0177 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 21:04:09 +1100 Subject: [PATCH 12/19] Improve unit testing for new role endpoint --- InvenTree/InvenTree/api_tester.py | 50 +++++++++++++++++++++++++++-- InvenTree/InvenTree/test_api.py | 52 +++++++++++++++++++++++++++---- InvenTree/users/models.py | 10 +++--- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index a38077000a..58a1f83c43 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -3,6 +3,7 @@ Helper functions for performing API unit tests """ from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from rest_framework.test import APITestCase @@ -16,8 +17,12 @@ class InvenTreeAPITestCase(APITestCase): password = 'mypassword' email = 'test@testing.com' + superuser = False auto_login = True + # Set list of roles automatically associated with the user + roles = [] + def setUp(self): super().setUp() @@ -29,12 +34,53 @@ class InvenTreeAPITestCase(APITestCase): email=self.email ) + # Create a group for the user + self.group = Group.objects.create(name='my_test_group') + self.user.groups.add(self.group) + + if self.superuser: + self.user.is_superuser = True + self.user.save() + + for role in self.roles: + self.assignRole(role) + if self.auto_login: self.client.login(username=self.username, password=self.password) - def setRoles(self, roles): + def assignRole(self, role): """ Set the user roles for the registered user """ - pass \ No newline at end of file + # role is of the format 'rule.permission' e.g. 'part.add' + + rule, perm = role.split('.') + + for ruleset in self.group.rule_sets.all(): + + if ruleset.name == rule: + + if perm == 'view': + ruleset.can_view = True + elif perm == 'change': + ruleset.can_change = True + elif perm == 'delete': + ruleset.can_delete = True + elif perm == 'add': + ruleset.can_add = True + + ruleset.save() + break + + def get(self, url, code=200): + """ + Issue a GET request + """ + + response = self.client.get(url, format='json') + + self.assertEqual(response.status_code, code) + + return response + \ No newline at end of file diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 9035f72435..3de90a1f82 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -98,9 +98,7 @@ class APITests(InvenTreeAPITestCase): # Now log in! self.basicAuth() - response = self.client.get(url, format='json') - - self.assertEqual(response.status_code, 200) + response = self.get(url) data = response.data @@ -114,8 +112,50 @@ class APITests(InvenTreeAPITestCase): role_names = roles.keys() - # By default, no roles are assigned to the user... + # By default, 'view' permissions are provided for rule in RuleSet.RULESET_NAMES: self.assertIn(rule, role_names) - self.assertIsNone(roles[rule]) - \ No newline at end of file + + self.assertIn('view', roles[rule]) + + self.assertNotIn('add', roles[rule]) + self.assertNotIn('change', roles[rule]) + self.assertNotIn('delete', roles[rule]) + + def test_with_superuser(self): + """ + Superuser should have *all* roles assigned + """ + + self.user.is_superuser = True + self.user.save() + + self.basicAuth() + + response = self.get(reverse('api-user-roles')) + + roles = response.data['roles'] + + for rule in RuleSet.RULESET_NAMES: + self.assertIn(rule, roles.keys()) + + for perm in ['view', 'add', 'change', 'delete']: + self.assertIn(perm, roles[rule]) + + def test_with_roles(self): + """ + Assign some roles to the user + """ + + self.basicAuth() + response = self.get(reverse('api-user-roles')) + + self.assignRole('part.delete') + self.assignRole('build.change') + response = self.get(reverse('api-user-roles')) + + roles = response.data['roles'] + + # New role permissions should have been added now + self.assertIn('delete', roles['part']) + self.assertIn('change', roles['build']) diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index dfe0acc968..614383ab30 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -67,15 +67,19 @@ class RuleSet(models.Model): 'part_partparametertemplate', 'part_partparameter', 'part_partrelated', + 'part_partstar', ], 'stock_location': [ 'stock_stocklocation', + 'label_stocklocationlabel', ], 'stock': [ 'stock_stockitem', 'stock_stockitemattachment', 'stock_stockitemtracking', 'stock_stockitemtestresult', + 'report_testreport', + 'label_stockitemlabel', ], 'build': [ 'part_part', @@ -86,6 +90,7 @@ class RuleSet(models.Model): 'build_buildorderattachment', 'stock_stockitem', 'stock_stocklocation', + 'report_buildreport', ], 'purchase_order': [ 'company_company', @@ -115,14 +120,9 @@ class RuleSet(models.Model): 'common_colortheme', 'common_inventreesetting', 'company_contact', - 'label_stockitemlabel', - 'label_stocklocationlabel', 'report_reportasset', 'report_reportsnippet', - 'report_testreport', - 'report_buildreport', 'report_billofmaterialsreport', - 'part_partstar', 'users_owner', # Third-party tables From 6e3cb326fba2578e1d8a01730c24492693d030d0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 21:36:40 +1100 Subject: [PATCH 13/19] Use better introspection, no longer required "role_required" --- InvenTree/InvenTree/permissions.py | 28 ++++++-------------------- InvenTree/part/api.py | 32 ------------------------------ InvenTree/users/models.py | 19 ++++++++++++++++++ 3 files changed, 25 insertions(+), 54 deletions(-) diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 9f34c0d918..2b7f5d4085 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -60,28 +60,12 @@ class RolePermission(permissions.BasePermission): permission = rolemap[request.method] - role = getattr(view, 'role_required', None) + # Extract the model name associated with this request + model = view.serializer_class.Meta.model - if not role: - # Role not specified - allow access - return True - - roles = [] + # And the specific database table + table = model._meta.db_table - if type(role) is str: - roles = [role] - elif type(role) in [list, tuple]: - roles = role - else: - raise TypeError(f"'role_required' is of incorrect type ({type(role)}) for view {type(view).__name__}") + result = users.models.RuleSet.check_table_permission(user, table, permission) - for role in roles: - - if role not in users.models.RuleSet.RULESET_NAMES: - raise ValueError(f"Role '{role}' is not a valid role") - - if not users.models.check_user_role(user, role, permission): - return False - - # All checks passed - return True + return result diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c20d6bee60..9a0e3a2a68 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -48,8 +48,6 @@ class PartCategoryTree(TreeSerializer): def get_items(self): return PartCategory.objects.all().prefetch_related('parts', 'children') - role_required = 'part' - class CategoryList(generics.ListCreateAPIView): """ API endpoint for accessing a list of PartCategory objects. @@ -106,16 +104,12 @@ class CategoryList(generics.ListCreateAPIView): 'description', ] - role_required = 'part' - class CategoryDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single PartCategory object """ serializer_class = part_serializers.CategorySerializer queryset = PartCategory.objects.all() - role_required = 'part' - class CategoryParameters(generics.ListAPIView): """ API endpoint for accessing a list of PartCategoryParameterTemplate objects. @@ -126,8 +120,6 @@ class CategoryParameters(generics.ListAPIView): queryset = PartCategoryParameterTemplate.objects.all() serializer_class = part_serializers.CategoryParameterTemplateSerializer - role_required = 'part' - def get_queryset(self): """ Custom filtering: @@ -172,8 +164,6 @@ class PartSalePriceList(generics.ListCreateAPIView): queryset = PartSellPriceBreak.objects.all() serializer_class = part_serializers.PartSalePriceSerializer - role_required = 'part' - filter_backends = [ DjangoFilterBackend ] @@ -191,8 +181,6 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = PartAttachment.objects.all() serializer_class = part_serializers.PartAttachmentSerializer - role_required = 'part' - filter_backends = [ DjangoFilterBackend, ] @@ -210,8 +198,6 @@ class PartTestTemplateList(generics.ListCreateAPIView): queryset = PartTestTemplate.objects.all() serializer_class = part_serializers.PartTestTemplateSerializer - role_required = 'part' - def filter_queryset(self, queryset): """ Filter the test list queryset. @@ -253,8 +239,6 @@ class PartThumbs(generics.ListAPIView): API endpoint for retrieving information on available Part thumbnails """ - role_required = 'part' - queryset = Part.objects.all() serializer_class = part_serializers.PartThumbSerializer @@ -291,8 +275,6 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): queryset = Part.objects.all() serializer_class = part_serializers.PartThumbSerializerUpdate - role_required = 'part' - filter_backends = [ DjangoFilterBackend ] @@ -301,8 +283,6 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single Part object """ - role_required = 'part' - queryset = Part.objects.all() serializer_class = part_serializers.PartSerializer @@ -389,8 +369,6 @@ class PartList(generics.ListCreateAPIView): queryset = Part.objects.all() - role_required = 'part' - starred_parts = None def get_serializer(self, *args, **kwargs): @@ -717,8 +695,6 @@ class PartParameterTemplateList(generics.ListCreateAPIView): - POST: Create a new PartParameterTemplate object """ - role_required = 'part' - queryset = PartParameterTemplate.objects.all() serializer_class = part_serializers.PartParameterTemplateSerializer @@ -738,8 +714,6 @@ class PartParameterList(generics.ListCreateAPIView): - POST: Create a new PartParameter object """ - role_required = 'part' - queryset = PartParameter.objects.all() serializer_class = part_serializers.PartParameterSerializer @@ -760,8 +734,6 @@ class BomList(generics.ListCreateAPIView): - POST: Create a new BomItem object """ - role_required = 'part' - serializer_class = part_serializers.BomItemSerializer def list(self, request, *args, **kwargs): @@ -901,8 +873,6 @@ class BomList(generics.ListCreateAPIView): class BomDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single BomItem object """ - role_required = 'part' - queryset = BomItem.objects.all() serializer_class = part_serializers.BomItemSerializer @@ -910,8 +880,6 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView): class BomItemValidate(generics.UpdateAPIView): """ API endpoint for validating a BomItem """ - role_required = 'part' - # Very simple serializers class BomItemValidationSerializer(serializers.Serializer): diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 614383ab30..478a8c3659 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -166,6 +166,25 @@ class RuleSet(models.Model): can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items')) + @classmethod + def check_table_permission(cls, user, table, permission): + """ + Check if the provided user has the specified permission against the table + """ + + # If the table does *not* require permissions + if table in cls.RULESET_IGNORE: + return True + + # Work out which roles touch the given table + for role in cls.RULESET_NAMES: + if table in cls.RULESET_MODELS[role]: + + if check_user_role(user, role, permission): + return True + + return False + @staticmethod def get_model_permission_string(model, permission): """ From ee744be5fe71b0bfcc691d3758834e7397a02a43 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 21:45:37 +1100 Subject: [PATCH 14/19] Refactor API unit tests for part --- InvenTree/InvenTree/permissions.py | 13 +++++--- InvenTree/part/test_api.py | 52 +++++++++++------------------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/InvenTree/InvenTree/permissions.py b/InvenTree/InvenTree/permissions.py index 2b7f5d4085..836943b7a8 100644 --- a/InvenTree/InvenTree/permissions.py +++ b/InvenTree/InvenTree/permissions.py @@ -60,11 +60,16 @@ class RolePermission(permissions.BasePermission): permission = rolemap[request.method] - # Extract the model name associated with this request - model = view.serializer_class.Meta.model + try: + # Extract the model name associated with this request + model = view.serializer_class.Meta.model - # And the specific database table - table = model._meta.db_table + # And the specific database table + table = model._meta.db_table + except AttributeError: + # We will assume that if the serializer class does *not* have a Meta, + # then we don't need a permission + return True result = users.models.RuleSet.check_table_permission(user, table, permission) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index e31512e1d8..0229b2e485 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -9,10 +9,11 @@ from part.models import Part from stock.models import StockItem from company.models import Company +from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.status_codes import StockStatus -class PartAPITest(APITestCase): +class PartAPITest(InvenTreeAPITestCase): """ Series of tests for the Part DRF API - Tests for Part API @@ -27,32 +28,16 @@ class PartAPITest(APITestCase): 'test_templates', ] + roles = [ + 'part.change', + 'part.add', + 'part.delete', + 'part_category.change', + 'part_category.add', + ] + def setUp(self): - # Create a user for auth - user = get_user_model() - - 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') + super().setUp() def test_get_categories(self): """ Test that we can retrieve list of part categories """ @@ -254,7 +239,7 @@ class PartAPITest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) -class PartAPIAggregationTest(APITestCase): +class PartAPIAggregationTest(InvenTreeAPITestCase): """ Tests to ensure that the various aggregation annotations are working correctly... """ @@ -268,13 +253,14 @@ class PartAPIAggregationTest(APITestCase): 'test_templates', ] - def setUp(self): - # Create a user for auth - user = get_user_model() - - user.objects.create_user('testuser', 'test@testing.com', 'password') + roles = [ + 'part.view', + 'part.change', + ] - self.client.login(username='testuser', password='password') + def setUp(self): + + super().setUp() # Add a new part self.part = Part.objects.create( From 9d099c81a7ab674f12c3ca845985200354518c9c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 21:53:54 +1100 Subject: [PATCH 15/19] Refactor API tests for stock --- InvenTree/InvenTree/api_tester.py | 10 +++++- InvenTree/stock/test_api.py | 58 ++++++++++++------------------- InvenTree/users/models.py | 1 + 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 58a1f83c43..e153514ffc 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -83,4 +83,12 @@ class InvenTreeAPITestCase(APITestCase): self.assertEqual(response.status_code, code) return response - \ No newline at end of file + + def post(self, url, data): + """ + Issue a POST request + """ + + response = self.client.post(url, data=data, format='json') + + return response \ No newline at end of file diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index c8f6cc77b8..b9f08423fb 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -14,13 +14,14 @@ from django.contrib.auth import get_user_model from InvenTree.helpers import addUserPermissions from InvenTree.status_codes import StockStatus +from InvenTree.api_tester import InvenTreeAPITestCase from common.models import InvenTreeSetting from .models import StockItem, StockLocation -class StockAPITestCase(APITestCase): +class StockAPITestCase(InvenTreeAPITestCase): fixtures = [ 'category', @@ -32,34 +33,16 @@ class StockAPITestCase(APITestCase): 'stock_tests', ] + roles = [ + 'stock.change', + 'stock.add', + 'stock_location.change', + 'stock_location.add', + ] + def setUp(self): - # Create a user for auth - user = get_user_model() - self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') - - self.user.is_staff = True - self.user.save() - - # 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={}): - response = self.client.post(url, data=data, format='json') - - return response + super().setUp() class StockLocationTest(StockAPITestCase): @@ -232,6 +215,9 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock(expired=1) self.assertEqual(len(response), 20) + self.user.is_staff = True + self.user.save() + # Now, ensure that the expiry date feature is enabled! InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user) @@ -449,7 +435,7 @@ class StocktakeTest(StockAPITestCase): data = {} # POST with a valid action - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, "must contain list", status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ @@ -457,7 +443,7 @@ class StocktakeTest(StockAPITestCase): }] # POST without a PK - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) # POST with a PK but no quantity @@ -465,14 +451,14 @@ class StocktakeTest(StockAPITestCase): 'pk': 10 }] - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'must contain a valid pk', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ 'pk': 1234 }] - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ @@ -480,7 +466,7 @@ class StocktakeTest(StockAPITestCase): 'quantity': '10x0d' }] - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'must contain a valid quantity', status_code=status.HTTP_400_BAD_REQUEST) data['items'] = [{ @@ -488,7 +474,7 @@ class StocktakeTest(StockAPITestCase): 'quantity': "-1.234" }] - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'must not be less than zero', status_code=status.HTTP_400_BAD_REQUEST) # Test with a single item @@ -499,7 +485,7 @@ class StocktakeTest(StockAPITestCase): } } - response = self.doPost(url, data) + response = self.post(url, data) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_transfer(self): @@ -518,13 +504,13 @@ class StocktakeTest(StockAPITestCase): url = reverse('api-stock-transfer') - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, "Moved 1 parts to", status_code=status.HTTP_200_OK) # Now try one which will fail due to a bad location data['location'] = 'not a location' - response = self.doPost(url, data) + response = self.post(url, data) self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST) diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 478a8c3659..3952c00e91 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -183,6 +183,7 @@ class RuleSet(models.Model): if check_user_role(user, role, permission): return True + print("failed permission check for", table, permission) return False @staticmethod From d76b873c00f63cfb496da24632f469e3c51d63be Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 22:00:22 +1100 Subject: [PATCH 16/19] Refactor build API unit tests --- InvenTree/InvenTree/api_tester.py | 8 ++-- InvenTree/InvenTree/test_api.py | 2 - InvenTree/build/test_api.py | 74 ++++++++++++------------------- 3 files changed, 32 insertions(+), 52 deletions(-) diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index e153514ffc..2e69e40969 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -12,7 +12,7 @@ class InvenTreeAPITestCase(APITestCase): Base class for running InvenTree API tests """ - # User information + # User information username = 'testuser' password = 'mypassword' email = 'test@testing.com' @@ -73,12 +73,12 @@ class InvenTreeAPITestCase(APITestCase): ruleset.save() break - def get(self, url, code=200): + def get(self, url, data={}, code=200): """ Issue a GET request """ - response = self.client.get(url, format='json') + response = self.client.get(url, data, format='json') self.assertEqual(response.status_code, code) @@ -91,4 +91,4 @@ class InvenTreeAPITestCase(APITestCase): response = self.client.post(url, data=data, format='json') - return response \ No newline at end of file + return response diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 3de90a1f82..52765db2a7 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -1,10 +1,8 @@ """ Low level tests for the InvenTree API """ -from rest_framework.test import APITestCase from rest_framework import status from django.urls import reverse -from django.contrib.auth import get_user_model from InvenTree.api_tester import InvenTreeAPITestCase diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index bcfd600e9e..01ec847d95 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -13,9 +13,10 @@ from part.models import Part from build.models import Build from InvenTree.status_codes import BuildStatus +from InvenTree.api_tester import InvenTreeAPITestCase -class BuildAPITest(APITestCase): +class BuildAPITest(InvenTreeAPITestCase): """ Series of tests for the Build DRF API """ @@ -27,33 +28,16 @@ class BuildAPITest(APITestCase): 'bom', 'build', ] + + # Required roles to access Build API endpoints + roles = [ + 'build.change', + 'build.add' + ] def setUp(self): - # Create a user for auth - user = get_user_model() - - 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') + super().setUp() class BuildListTest(BuildAPITest): @@ -63,34 +47,26 @@ class BuildListTest(BuildAPITest): url = reverse('api-build-list') - def get(self, status_code=200, data={}): - - response = self.client.get(self.url, data, format='json') - - self.assertEqual(response.status_code, status_code) - - return response.data - def test_get_all_builds(self): """ Retrieve *all* builds via the API """ - builds = self.get() + builds = self.get(self.url) - self.assertEqual(len(builds), 5) + self.assertEqual(len(builds.data), 5) - builds = self.get(data={'active': True}) - self.assertEqual(len(builds), 1) + builds = self.get(self.url, data={'active': True}) + self.assertEqual(len(builds.data), 1) - builds = self.get(data={'status': BuildStatus.COMPLETE}) - self.assertEqual(len(builds), 4) + builds = self.get(self.url, data={'status': BuildStatus.COMPLETE}) + self.assertEqual(len(builds.data), 4) - builds = self.get(data={'overdue': False}) - self.assertEqual(len(builds), 5) + builds = self.get(self.url, data={'overdue': False}) + self.assertEqual(len(builds.data), 5) - builds = self.get(data={'overdue': True}) - self.assertEqual(len(builds), 0) + builds = self.get(self.url, data={'overdue': True}) + self.assertEqual(len(builds.data), 0) def test_overdue(self): """ @@ -109,7 +85,9 @@ class BuildListTest(BuildAPITest): target_date=in_the_past ) - builds = self.get(data={'overdue': True}) + response = self.get(self.url, data={'overdue': True}) + + builds = response.data self.assertEqual(len(builds), 1) @@ -152,11 +130,15 @@ class BuildListTest(BuildAPITest): Build.objects.rebuild() # Search by parent - builds = self.get(data={'parent': parent.pk}) + response = self.get(self.url, data={'parent': parent.pk}) + + builds = response.data self.assertEqual(len(builds), 5) # Search by ancestor - builds = self.get(data={'ancestor': parent.pk}) + response = self.get(self.url, data={'ancestor': parent.pk}) + + builds = response.data self.assertEqual(len(builds), 20) From 5a536be22df4f02a2930438f313ac982289537a2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 26 Feb 2021 22:08:06 +1100 Subject: [PATCH 17/19] More test refactoring --- InvenTree/company/test_api.py | 33 ++++++++++++++------------------- InvenTree/label/test_api.py | 17 +++++++++-------- InvenTree/order/test_api.py | 31 ++++++++++++++----------------- 3 files changed, 37 insertions(+), 44 deletions(-) diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index f466a4a223..36271ee64c 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -4,29 +4,24 @@ from django.urls import reverse from django.contrib.auth import get_user_model from InvenTree.helpers import addUserPermissions +from InvenTree.api_tester import InvenTreeAPITestCase from .models import Company -class CompanyTest(APITestCase): +class CompanyTest(InvenTreeAPITestCase): """ Series of tests for the Company DRF API """ - def setUp(self): - # Create a user for auth - user = get_user_model() - self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') - - perms = [ - 'view_company', - 'change_company', - 'add_company', - ] + roles = [ + 'purchase_order.add', + 'purchase_order.change', + ] - addUserPermissions(self.user, perms) - - self.client.login(username='testuser', password='password') + def setUp(self): + + super().setUp() Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True) Company.objects.create(name='Drippy Cup Co.', description='Customer', is_customer=True, is_supplier=False) @@ -36,24 +31,24 @@ class CompanyTest(APITestCase): url = reverse('api-company-list') # There should be two companies - response = self.client.get(url, format='json') + response = self.get(url) self.assertEqual(len(response.data), 3) data = {'is_customer': True} # There should only be one customer - response = self.client.get(url, data, format='json') + response = self.get(url, data) self.assertEqual(len(response.data), 1) data = {'is_supplier': True} # There should be two suppliers - response = self.client.get(url, data, format='json') + response = self.get(url, data) self.assertEqual(len(response.data), 2) def test_company_detail(self): url = reverse('api-company-detail', kwargs={'pk': 1}) - response = self.client.get(url, format='json') + response = self.get(url) self.assertEqual(response.data['name'], 'ACME') @@ -68,5 +63,5 @@ class CompanyTest(APITestCase): # Test search functionality in company list url = reverse('api-company-list') data = {'search': 'cup'} - response = self.client.get(url, data, format='json') + response = self.get(url, data) self.assertEqual(len(response.data), 2) diff --git a/InvenTree/label/test_api.py b/InvenTree/label/test_api.py index 1fc0a3e0da..dddab52de3 100644 --- a/InvenTree/label/test_api.py +++ b/InvenTree/label/test_api.py @@ -8,8 +8,10 @@ from rest_framework.test import APITestCase from django.urls import reverse from django.contrib.auth import get_user_model +from InvenTree.api_tester import InvenTreeAPITestCase -class TestReportTests(APITestCase): + +class TestReportTests(InvenTreeAPITestCase): """ Tests for the StockItem TestReport templates """ @@ -21,17 +23,16 @@ class TestReportTests(APITestCase): 'stock', ] + roles = [ + 'stock.view', + 'stock_location.view', + ] + list_url = reverse('api-stockitem-testreport-list') def setUp(self): - user = get_user_model() - self.user = user.objects.create_user('testuser', 'test@testing.com', 'password') - - self.user.is_staff = True - self.user.save() - - self.client.login(username='testuser', password='password') + super().setUp() def do_list(self, filters={}): diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 58599f1eb3..632b8580b2 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -10,10 +10,12 @@ from rest_framework import status from django.urls import reverse from django.contrib.auth import get_user_model +from InvenTree.api_tester import InvenTreeAPITestCase + from .models import PurchaseOrder, SalesOrder -class OrderTest(APITestCase): +class OrderTest(InvenTreeAPITestCase): fixtures = [ 'category', @@ -26,25 +28,20 @@ class OrderTest(APITestCase): 'sales_order', ] + roles = [ + 'purchase_order.change', + 'sales_order.change', + ] + def setUp(self): - - # Create a user for auth - get_user_model().objects.create_user('testuser', 'test@testing.com', 'password') - self.client.login(username='testuser', password='password') - - def doGet(self, url, data={}): - - return self.client.get(url, data=data, format='json') - - def doPost(self, url, data={}): - return self.client.post(url, data=data, format='json') + super().setUp() def filter(self, filters, count): """ Test API filters """ - response = self.doGet( + response = self.get( self.LIST_URL, filters ) @@ -98,7 +95,7 @@ class PurchaseOrderTest(OrderTest): url = '/api/order/po/1/' - response = self.doGet(url) + response = self.get(url) self.assertEqual(response.status_code, 200) @@ -111,7 +108,7 @@ class PurchaseOrderTest(OrderTest): url = reverse('api-po-attachment-list') - response = self.doGet(url) + response = self.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -161,7 +158,7 @@ class SalesOrderTest(OrderTest): url = '/api/order/so/1/' - response = self.doGet(url) + response = self.get(url) self.assertEqual(response.status_code, 200) @@ -173,6 +170,6 @@ class SalesOrderTest(OrderTest): url = reverse('api-so-attachment-list') - response = self.doGet(url) + response = self.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) From b315b958b065d197ae9363673e6f45c721b43aa8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 27 Feb 2021 07:56:38 +1100 Subject: [PATCH 18/19] PEP fixes --- InvenTree/build/test_api.py | 4 ---- InvenTree/company/test_api.py | 3 --- InvenTree/label/test_api.py | 3 --- InvenTree/order/test_api.py | 2 -- InvenTree/part/test_api.py | 3 --- InvenTree/stock/test_api.py | 3 --- 6 files changed, 18 deletions(-) diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 01ec847d95..02bcde6bb4 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -3,11 +3,7 @@ from __future__ import unicode_literals from datetime import datetime, timedelta -from rest_framework.test import APITestCase - 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 build.models import Build diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 36271ee64c..219a8ac019 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -1,9 +1,6 @@ -from rest_framework.test import APITestCase from rest_framework import status from django.urls import reverse -from django.contrib.auth import get_user_model -from InvenTree.helpers import addUserPermissions from InvenTree.api_tester import InvenTreeAPITestCase from .models import Company diff --git a/InvenTree/label/test_api.py b/InvenTree/label/test_api.py index dddab52de3..92e7733891 100644 --- a/InvenTree/label/test_api.py +++ b/InvenTree/label/test_api.py @@ -3,10 +3,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from rest_framework.test import APITestCase - from django.urls import reverse -from django.contrib.auth import get_user_model from InvenTree.api_tester import InvenTreeAPITestCase diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 632b8580b2..cb92b8b384 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -4,11 +4,9 @@ Tests for the Order API from datetime import datetime, timedelta -from rest_framework.test import APITestCase from rest_framework import status from django.urls import reverse -from django.contrib.auth import get_user_model from InvenTree.api_tester import InvenTreeAPITestCase diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 0229b2e485..d917b6ebb2 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,9 +1,6 @@ -from rest_framework.test import APITestCase 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 diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index b9f08423fb..17cab7ffe3 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -7,12 +7,9 @@ from __future__ import unicode_literals from datetime import datetime, timedelta -from rest_framework.test import APITestCase from rest_framework import status from django.urls import reverse -from django.contrib.auth import get_user_model -from InvenTree.helpers import addUserPermissions from InvenTree.status_codes import StockStatus from InvenTree.api_tester import InvenTreeAPITestCase From b6d87850dc953a9874a5f98ea0aa4861dd7d1a89 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 27 Feb 2021 08:00:00 +1100 Subject: [PATCH 19/19] Hide stock buttons on top-level stock location display --- InvenTree/stock/templates/stock/location.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 817b096f74..5c2b4e04db 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -48,15 +48,14 @@
  • {% trans "Check-in Items" %}
  • - {% endif %} {% if owner_control.value == "False" or owner_control.value == "True" and user in owners or user.is_superuser %} - {% if roles.stock.change %} -
    - -
    {% endif %} - {% endif %} + {% endif %} + {% endif %} {% endif %}