mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-03 15:52:51 +00:00
Refactor API endpoint: Users (1/6) (#10437)
* Enhance Owner model filtering with optimized search and active status filtersI * enhance output options for Group API endpoints and add tests for GroupDetail * update api_version
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
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
|
- Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' param in SupplierPartList API endpoint
|
||||||
|
|
||||||
|
@@ -217,12 +217,25 @@ class InvenTreeNotesField(models.TextField):
|
|||||||
class InvenTreeOutputOption:
|
class InvenTreeOutputOption:
|
||||||
"""Represents an available output option with description, flag name, and default value."""
|
"""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."""
|
"""Initialize the output option."""
|
||||||
self.description = description
|
|
||||||
self.flag = flag
|
self.flag = flag
|
||||||
self.default = default
|
self.default = default
|
||||||
|
|
||||||
|
if description is None:
|
||||||
|
self.description = self.DEFAULT_DESCRIPTIONS.get(flag, '')
|
||||||
|
else:
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
|
||||||
class OutputConfiguration:
|
class OutputConfiguration:
|
||||||
"""Holds all available output options for a view.
|
"""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.
|
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
|
@classmethod
|
||||||
def format_params(cls, params: dict) -> dict[str, bool]:
|
def format_params(cls, params: dict) -> dict[str, bool]:
|
||||||
|
@@ -5,23 +5,28 @@ import datetime
|
|||||||
from django.contrib.auth import get_user, login
|
from django.contrib.auth import get_user, login
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.auth.password_validation import password_changed, validate_password
|
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.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Q
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.views.generic.base import RedirectView
|
from django.views.generic.base import RedirectView
|
||||||
|
|
||||||
import structlog
|
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 drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework.generics import DestroyAPIView, GenericAPIView
|
from rest_framework.generics import DestroyAPIView, GenericAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
import InvenTree.helpers
|
|
||||||
import InvenTree.permissions
|
import InvenTree.permissions
|
||||||
|
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
ListAPI,
|
ListAPI,
|
||||||
ListCreateAPI,
|
ListCreateAPI,
|
||||||
|
OutputOptionsMixin,
|
||||||
RetrieveAPI,
|
RetrieveAPI,
|
||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
@@ -46,6 +51,53 @@ from users.serializers import (
|
|||||||
logger = structlog.get_logger('inventree')
|
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):
|
class OwnerList(ListAPI):
|
||||||
"""List API endpoint for Owner model.
|
"""List API endpoint for Owner model.
|
||||||
|
|
||||||
@@ -55,6 +107,8 @@ class OwnerList(ListAPI):
|
|||||||
queryset = Owner.objects.all()
|
queryset = Owner.objects.all()
|
||||||
serializer_class = OwnerSerializer
|
serializer_class = OwnerSerializer
|
||||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||||
|
filterset_class = OwnerFilter
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Implement text search for the "owner" model.
|
"""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...
|
but until we determine a better way, this is what we have...
|
||||||
"""
|
"""
|
||||||
search_term = str(self.request.query_params.get('search', '')).lower()
|
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)
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
results = []
|
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():
|
for result in queryset.all():
|
||||||
name = str(result.name()).lower().strip()
|
name = str(result.name()).lower().strip()
|
||||||
search_match = True
|
search_match = True
|
||||||
@@ -96,14 +143,6 @@ class OwnerList(ListAPI):
|
|||||||
if not search_match:
|
if not search_match:
|
||||||
continue
|
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
|
# If we get here, there is no reason *not* to include this result
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
@@ -253,21 +292,6 @@ class GroupMixin:
|
|||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
"""Return serializer instance for this endpoint."""
|
"""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()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
@@ -280,13 +304,30 @@ class GroupMixin:
|
|||||||
return super().get_queryset().prefetch_related('rule_sets', 'user_set')
|
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."""
|
"""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."""
|
"""List endpoint for all auth groups."""
|
||||||
|
|
||||||
|
output_options = GroupOutputOptions
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
search_fields = ['name']
|
search_fields = ['name']
|
||||||
ordering_fields = ['name']
|
ordering_fields = ['name']
|
||||||
|
@@ -395,3 +395,25 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||||||
# Get token without auth (should fail)
|
# Get token without auth (should fail)
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
self.get(reverse('api-token'), expected_code=401)
|
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)
|
||||||
|
Reference in New Issue
Block a user