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
|
||||
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
|
||||
|
||||
|
@@ -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]:
|
||||
|
@@ -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']
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user