diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 67614c8a91..2559d1312a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 392 +INVENTREE_API_VERSION = 393 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v393 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10437 + - Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint + v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374 - Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' param in SupplierPartList API endpoint diff --git a/src/backend/InvenTree/InvenTree/fields.py b/src/backend/InvenTree/InvenTree/fields.py index 08bcbf84c4..ea2dd7fe28 100644 --- a/src/backend/InvenTree/InvenTree/fields.py +++ b/src/backend/InvenTree/InvenTree/fields.py @@ -217,12 +217,25 @@ class InvenTreeNotesField(models.TextField): class InvenTreeOutputOption: """Represents an available output option with description, flag name, and default value.""" - def __init__(self, description: str, flag: str, default=None): + DEFAULT_DESCRIPTIONS = { + 'part_detail': 'Include detailed information about the related part in the response', + 'item_detail': 'Include detailed information about the item in the response', + 'order_detail': 'Include detailed information about the sales order in the response', + 'location_detail': 'Include detailed information about the stock location in the response', + 'customer_detail': 'Include detailed information about the customer in the response', + 'supplier_detail': 'Include detailed information about the supplier in the response', + } + + def __init__(self, flag: str, default=False, description: str = ''): """Initialize the output option.""" - self.description = description self.flag = flag self.default = default + if description is None: + self.description = self.DEFAULT_DESCRIPTIONS.get(flag, '') + else: + self.description = description + class OutputConfiguration: """Holds all available output options for a view. @@ -231,7 +244,26 @@ class OutputConfiguration: into a dictionary of boolean flags, which can then be applied to serializers. """ - OPTIONS: list[InvenTreeOutputOption] + OPTIONS: list[InvenTreeOutputOption] = [] + + def __init_subclass__(cls, **kwargs): + """Validates that subclass defines OPTIONS attribute with correct type.""" + super().__init_subclass__(**kwargs) + + options = cls.OPTIONS + # Type validation - ensure it's a list + if not isinstance(options, list): + raise TypeError( + f"Class {cls.__name__} 'OPTIONS' must be a list, got {type(options).__name__}" + ) + + # Type validation - Ensure list contains InvenTreeOutputOption instances + for i, option in enumerate(options): + if not isinstance(option, InvenTreeOutputOption): + raise TypeError( + f"Class {cls.__name__} 'OPTIONS[{i}]' must be an instance of InvenTreeOutputOption, " + f'got {type(option).__name__}' + ) @classmethod def format_params(cls, params: dict) -> dict[str, bool]: diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 8975e4fc10..39bfd595d4 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -5,23 +5,28 @@ import datetime from django.contrib.auth import get_user, login from django.contrib.auth.models import Group, User from django.contrib.auth.password_validation import password_changed, validate_password +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.db.models import Q from django.urls import include, path from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic.base import RedirectView import structlog +from django_filters import rest_framework as rest_filters +from django_filters.rest_framework.filterset import FilterSet from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import exceptions from rest_framework.generics import DestroyAPIView, GenericAPIView from rest_framework.response import Response -import InvenTree.helpers import InvenTree.permissions +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ( ListAPI, ListCreateAPI, + OutputOptionsMixin, RetrieveAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, @@ -46,6 +51,53 @@ from users.serializers import ( logger = structlog.get_logger('inventree') +class OwnerFilter(FilterSet): + """filter set for OwnerList.""" + + is_active = rest_filters.BooleanFilter(method='filter_is_active') + + class Meta: + """Meta class for owner filter.""" + + model = Owner + fields = ['is_active'] + + def filter_is_active(self, queryset, name, value): + """Filter by active status.""" + if value is None: + return queryset + + # Get ContentType for User model + user_content_type = ContentType.objects.get_for_model(User) + + active_user_ids = list( + User.objects.filter(is_active=value).values_list('pk', flat=True) + ) + + # Filter based on owner type + q_filter = Q() + + # If owner_type is not 'user', include all + q_filter |= ~Q(owner_type=user_content_type) + + # If owner_type is 'user', only include active/inactive users + if active_user_ids: + q_filter |= Q(owner_type=user_content_type, owner_id__in=active_user_ids) + elif value is False: + # If value is False and we want inactive users + # Get all user IDs that are NOT in active_user_ids + all_user_ids = list(User.objects.values_list('pk', flat=True)) + inactive_user_ids = [ + uid for uid in all_user_ids if uid not in active_user_ids + ] + if inactive_user_ids: + q_filter |= Q( + owner_type=user_content_type, owner_id__in=inactive_user_ids + ) + + return queryset.filter(q_filter) + + class OwnerList(ListAPI): """List API endpoint for Owner model. @@ -55,6 +107,8 @@ class OwnerList(ListAPI): queryset = Owner.objects.all() serializer_class = OwnerSerializer permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] + filterset_class = OwnerFilter + filter_backends = SEARCH_ORDER_FILTER def filter_queryset(self, queryset): """Implement text search for the "owner" model. @@ -69,19 +123,12 @@ class OwnerList(ListAPI): but until we determine a better way, this is what we have... """ search_term = str(self.request.query_params.get('search', '')).lower() - is_active = self.request.query_params.get('is_active', None) + queryset = queryset.select_related('owner_type').prefetch_related('owner') queryset = super().filter_queryset(queryset) results = [] - # Get a list of all matching users, depending on the *is_active* flag - if is_active is not None: - is_active = InvenTree.helpers.str2bool(is_active) - matching_user_ids = User.objects.filter(is_active=is_active).values_list( - 'pk', flat=True - ) - for result in queryset.all(): name = str(result.name()).lower().strip() search_match = True @@ -96,14 +143,6 @@ class OwnerList(ListAPI): if not search_match: continue - if is_active is not None: - # Skip any users which do not match the required *is_active* value - if ( - result.owner_type.name == 'user' - and result.owner_id not in matching_user_ids - ): - continue - # If we get here, there is no reason *not* to include this result results.append(result) @@ -253,21 +292,6 @@ class GroupMixin: def get_serializer(self, *args, **kwargs): """Return serializer instance for this endpoint.""" - # Do we wish to include extra detail? - params = self.request.query_params - - kwargs['role_detail'] = InvenTree.helpers.str2bool( - params.get('role_detail', True) - ) - - kwargs['permission_detail'] = InvenTree.helpers.str2bool( - params.get('permission_detail', None) - ) - - kwargs['user_detail'] = InvenTree.helpers.str2bool( - params.get('user_detail', None) - ) - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -280,13 +304,30 @@ class GroupMixin: return super().get_queryset().prefetch_related('rule_sets', 'user_set') -class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI): +class GroupOutputOptions(OutputConfiguration): + """Holds all available output options for Group views.""" + + OPTIONS = [ + InvenTreeOutputOption('user_detail', description='Include user details'), + InvenTreeOutputOption( + 'permission_detail', description='Include permission details' + ), + InvenTreeOutputOption( + 'role_detail', description='Include role details', default=True + ), + ] + + +class GroupDetail(GroupMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): """Detail endpoint for a particular auth group.""" + output_options = GroupOutputOptions -class GroupList(GroupMixin, ListCreateAPI): + +class GroupList(GroupMixin, OutputOptionsMixin, ListCreateAPI): """List endpoint for all auth groups.""" + output_options = GroupOutputOptions filter_backends = SEARCH_ORDER_FILTER search_fields = ['name'] ordering_fields = ['name'] diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index ce919825e6..473b70946b 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -395,3 +395,25 @@ class UserTokenTests(InvenTreeAPITestCase): # Get token without auth (should fail) self.client.logout() self.get(reverse('api-token'), expected_code=401) + + +class GroupDetialTests(InvenTreeAPITestCase): + """Tests for the GroupDetail API endpoint.""" + + fixtures = ['users'] + + def test_group_list(self): + """Test the GroupDetail API endpoint.""" + url = reverse('api-group-detail', kwargs={'pk': 1}) + + response = self.get(url, {'user_detail': 'true'}, expected_code=200) + self.assertIn('users', response.data) + + response = self.get(url, {'role_detail': 'true'}, expected_code=200) + self.assertIn('roles', response.data) + + response = self.get(url, {'permission_detail': 'true'}, expected_code=200) + self.assertIn('permissions', response.data) + + response = self.get(url, {'permission_detail': 'false'}, expected_code=200) + self.assertNotIn('permissions', response.data)