2
0
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:
Reza
2025-10-01 09:53:35 +03:30
committed by GitHub
parent 3527e1a359
commit 40700dfbcf
4 changed files with 136 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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