Coverage for src/backend/InvenTree/users/api.py: 86%
205 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""DRF API definition for the 'users' app."""
3import datetime
5from django.contrib.auth import get_user, login
6from django.contrib.auth.models import Group, User
7from django.contrib.auth.password_validation import password_changed, validate_password
8from django.contrib.contenttypes.models import ContentType
9from django.core.exceptions import ValidationError
10from django.db.models import Q
11from django.urls import include, path
12from django.views.decorators.csrf import ensure_csrf_cookie
13from django.views.generic.base import RedirectView
15import django_filters.rest_framework.filters as rest_filters
16import structlog
17from django_filters.rest_framework.filterset import FilterSet
18from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
19from rest_framework import exceptions
20from rest_framework.generics import DestroyAPIView, GenericAPIView
21from rest_framework.response import Response
23import InvenTree.permissions
24from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
25from InvenTree.filters import SEARCH_ORDER_FILTER
26from InvenTree.mixins import (
27 ListAPI,
28 ListCreateAPI,
29 OutputOptionsMixin,
30 RetrieveAPI,
31 RetrieveUpdateAPI,
32 RetrieveUpdateDestroyAPI,
33 SerializerContextMixin,
34 UpdateAPI,
35)
36from InvenTree.schema import exclude_from_schema
37from InvenTree.settings import FRONTEND_URL_BASE
38from users.models import ApiToken, Owner, RuleSet, UserProfile
39from users.serializers import (
40 ApiTokenSerializer,
41 ExtendedUserSerializer,
42 GetAuthTokenSerializer,
43 GroupSerializer,
44 MeUserSerializer,
45 OwnerSerializer,
46 RoleSerializer,
47 RuleSetSerializer,
48 UserCreateSerializer,
49 UserProfileSerializer,
50 UserSetPasswordSerializer,
51)
53logger = structlog.get_logger('inventree')
56class OwnerFilter(FilterSet):
57 """filter set for OwnerList."""
59 is_active = rest_filters.BooleanFilter(method='filter_is_active')
61 class Meta:
62 """Meta class for owner filter."""
64 model = Owner
65 fields = ['is_active']
67 def filter_is_active(self, queryset, name, value):
68 """Filter by active status."""
69 if value is None: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 return queryset
72 # Get ContentType for User model
73 user_content_type = ContentType.objects.get_for_model(User)
75 active_user_ids = list(
76 User.objects.filter(is_active=value).values_list('pk', flat=True)
77 )
79 # Filter based on owner type
80 q_filter = Q()
82 # If owner_type is not 'user', include all
83 q_filter |= ~Q(owner_type=user_content_type)
85 # If owner_type is 'user', only include active/inactive users
86 if active_user_ids:
87 q_filter |= Q(owner_type=user_content_type, owner_id__in=active_user_ids)
88 elif value is False: 88 ↛ 100line 88 didn't jump to line 100 because the condition on line 88 was always true
89 # If value is False and we want inactive users
90 # Get all user IDs that are NOT in active_user_ids
91 all_user_ids = list(User.objects.values_list('pk', flat=True))
92 inactive_user_ids = [
93 uid for uid in all_user_ids if uid not in active_user_ids
94 ]
95 if inactive_user_ids: 95 ↛ 100line 95 didn't jump to line 100 because the condition on line 95 was always true
96 q_filter |= Q(
97 owner_type=user_content_type, owner_id__in=inactive_user_ids
98 )
100 return queryset.filter(q_filter)
103class OwnerList(ListAPI):
104 """List API endpoint for Owner model.
106 Cannot create a new Owner object via the API, but can view existing instances.
107 """
109 queryset = Owner.objects.all()
110 serializer_class = OwnerSerializer
111 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
112 filterset_class = OwnerFilter
113 filter_backends = SEARCH_ORDER_FILTER
115 def filter_queryset(self, queryset):
116 """Implement text search for the "owner" model.
118 Note that an "owner" can be either a group, or a user,
119 so we cannot do a direct text search.
121 A "hack" here is to post-process the queryset and simply
122 remove any values which do not match.
124 It is not necessarily "efficient" to do it this way,
125 but until we determine a better way, this is what we have...
126 """
127 search_term = str(self.request.query_params.get('search', '')).lower()
129 queryset = queryset.select_related('owner_type').prefetch_related('owner')
130 queryset = super().filter_queryset(queryset)
132 results = []
134 for result in queryset.all():
135 name = str(result.name()).lower().strip()
136 search_match = True
138 # Extract search term f
139 if search_term:
140 for entry in search_term.strip().split(' '): 140 ↛ 145line 140 didn't jump to line 145 because the loop on line 140 didn't complete
141 if entry not in name: 141 ↛ 140line 141 didn't jump to line 140 because the condition on line 141 was always true
142 search_match = False
143 break
145 if not search_match:
146 continue
148 # If we get here, there is no reason *not* to include this result
149 results.append(result)
151 return results
154class OwnerDetail(RetrieveAPI):
155 """Detail API endpoint for Owner model.
157 Cannot edit or delete
158 """
160 queryset = Owner.objects.all()
161 serializer_class = OwnerSerializer
162 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
165class RoleDetails(RetrieveAPI):
166 """API endpoint which lists the available role permissions for the current user."""
168 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
169 serializer_class = RoleSerializer
171 def get_object(self):
172 """Overwritten to always return current user."""
173 return self.request.user
176class UserDetail(RetrieveUpdateDestroyAPI):
177 """Detail endpoint for a single user.
179 Permissions:
180 - Staff users (who also have the 'admin' role) can perform write operations
181 - Otherwise authenticated users have read-only access
182 """
184 queryset = User.objects.all()
185 serializer_class = ExtendedUserSerializer
186 permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
188 def perform_destroy(self, instance):
189 """Override destroy method to ensure sessions are deleted first."""
190 # Remove all sessions for this user
191 if sessions := instance.usersession_set.all():
192 sessions.delete()
193 # Normally delete the user
194 return super().perform_destroy(instance)
197class UserDetailSetPassword(UpdateAPI):
198 """Allows superusers to set the password for a user."""
200 queryset = User.objects.all()
201 serializer_class = UserSetPasswordSerializer
202 permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
204 def get_object(self):
205 """Return the user object for this endpoint."""
206 return self.get_queryset().get(pk=self.kwargs['pk'])
208 def perform_update(self, serializer):
209 """Set the password for the user."""
210 user: User = serializer.instance
212 password: str = serializer.validated_data.get('password', None)
213 overwrite: bool = serializer.validated_data.get('override_warning', False)
215 if password:
216 if not overwrite:
217 try:
218 validate_password(password=password, user=user)
219 except ValidationError as e:
220 raise exceptions.ValidationError({'password': str(e)})
222 user.set_password(password)
223 password_changed(password=password, user=user)
224 user.save()
227class MeUserDetail(RetrieveUpdateAPI, UserDetail):
228 """Detail endpoint for current user.
230 Permissions:
231 - User can edit their own details via this endpoint
232 - Only a subset of fields are available here
233 """
235 serializer_class = MeUserSerializer
236 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
238 rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
240 def get_object(self):
241 """Always return the current user object."""
242 return self.request.user
244 def get_permission_model(self):
245 """Return the model for the permission check.
247 Note that for this endpoint, the current user can *always* edit their own details.
248 """
249 return None
252class UserList(ListCreateAPI):
253 """List endpoint for detail on all users.
255 Permissions:
256 - Staff users (who also have the 'admin' role) can perform write operations
257 - Otherwise authenticated users have read-only access
258 """
260 queryset = User.objects.all().prefetch_related('groups')
261 serializer_class = UserCreateSerializer
263 # User must have the right role, AND be a staff user, else read-only
264 permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
266 filter_backends = SEARCH_ORDER_FILTER
268 search_fields = ['first_name', 'last_name', 'username']
270 ordering_fields = [
271 'email',
272 'username',
273 'first_name',
274 'last_name',
275 'is_staff',
276 'is_superuser',
277 'is_active',
278 ]
280 filterset_fields = ['is_staff', 'is_active', 'is_superuser']
283class GroupMixin(SerializerContextMixin):
284 """Mixin for Group API endpoints to add permissions filter.
286 Permissions:
287 - Staff users (who also have the 'admin' role) can perform write operations
288 - Otherwise authenticated users have read-only access
289 """
291 queryset = Group.objects.all()
292 serializer_class = GroupSerializer
293 permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
296class GroupOutputOptions(OutputConfiguration):
297 """Holds all available output options for Group views."""
299 OPTIONS = [
300 InvenTreeOutputOption('user_detail', description='Include user details'),
301 InvenTreeOutputOption(
302 'permission_detail', description='Include permission details'
303 ),
304 InvenTreeOutputOption(
305 'role_detail', description='Include role details', default=True
306 ),
307 ]
310class GroupDetail(GroupMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI):
311 """Detail endpoint for a particular auth group."""
313 output_options = GroupOutputOptions
316class GroupList(GroupMixin, OutputOptionsMixin, ListCreateAPI):
317 """List endpoint for all auth groups."""
319 output_options = GroupOutputOptions
320 filter_backends = SEARCH_ORDER_FILTER
321 search_fields = ['name']
322 ordering_fields = ['name']
325class RuleSetMixin:
326 """Mixin for RuleSet API endpoints."""
328 queryset = RuleSet.objects.all()
329 serializer_class = RuleSetSerializer
330 permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
333class RuleSetList(RuleSetMixin, ListAPI):
334 """List endpoint for all RuleSet instances."""
336 filter_backends = SEARCH_ORDER_FILTER
338 search_fields = ['name']
339 ordering_fields = ['name']
340 filterset_fields = ['group', 'name']
343class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI):
344 """Detail endpoint for a particular RuleSet instance."""
347class GetAuthToken(GenericAPIView):
348 """Return authentication token for an authenticated user."""
350 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
351 serializer_class = GetAuthTokenSerializer
353 @extend_schema(
354 parameters=[
355 OpenApiParameter(
356 name='name',
357 type=str,
358 location=OpenApiParameter.QUERY,
359 description='Name of the token',
360 )
361 ],
362 responses={200: OpenApiResponse(response=GetAuthTokenSerializer())},
363 )
364 def get(self, request, *args, **kwargs):
365 """Return an API token if the user is authenticated.
367 - If the user already has a matching token, delete it and create a new one
368 - Existing tokens are *never* exposed again via the API
369 - Once the token is provided, it can be used for auth until it expires
370 """
371 if not request.user.is_authenticated: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 raise exceptions.NotAuthenticated() # pragma: no cover
374 user = request.user
375 name = request.query_params.get('name', '')
377 name = ApiToken.sanitize_name(name)
379 today = datetime.date.today()
381 # Find existing token, which has not expired
382 token = ApiToken.objects.filter(
383 user=user, name=name, revoked=False, expiry__gte=today
384 ).first()
386 if not token:
387 # User is authenticated, and requesting a token against the provided name.
388 token = ApiToken.objects.create(user=request.user, name=name)
390 logger.info(
391 "Created new API token for user '%s' (name='%s')", user.username, name
392 )
394 # Add some metadata about the request
395 token.set_metadata('user_agent', request.headers.get('user-agent', ''))
396 token.set_metadata('remote_addr', request.META.get('REMOTE_ADDR', ''))
397 token.set_metadata('remote_host', request.META.get('REMOTE_HOST', ''))
398 token.set_metadata('remote_user', request.META.get('REMOTE_USER', ''))
399 token.set_metadata('server_name', request.META.get('SERVER_NAME', ''))
400 token.set_metadata('server_port', request.META.get('SERVER_PORT', ''))
402 data = {'token': token.key, 'name': token.name, 'expiry': token.expiry}
404 # Ensure that the users session is logged in
405 if not get_user(request).is_authenticated: 405 ↛ 408line 405 didn't jump to line 408 because the condition on line 405 was always true
406 login(request, user, backend='django.contrib.auth.backends.ModelBackend')
408 return Response(data)
411class TokenMixin:
412 """Mixin for API token endpoints."""
414 permission_classes = (InvenTree.permissions.IsAuthenticatedOrReadScope,)
415 serializer_class = ApiTokenSerializer
417 def get_queryset(self):
418 """Only return data for current user."""
419 if self.request.user.is_superuser and self.request.query_params.get(
420 'all_users', False
421 ):
422 return ApiToken.objects.all()
423 return ApiToken.objects.filter(user=self.request.user)
425 @extend_schema(
426 parameters=[
427 OpenApiParameter(
428 name='all_users',
429 type=bool,
430 location=OpenApiParameter.QUERY,
431 description='Display tokens for all users (superuser only)',
432 )
433 ]
434 )
435 def get(self, request, *args, **kwargs):
436 """Details for a user token."""
437 return super().get(request, *args, **kwargs)
440class TokenListView(TokenMixin, ListCreateAPI):
441 """List of user tokens for current user."""
443 filter_backends = SEARCH_ORDER_FILTER
444 search_fields = ['name', 'key']
445 ordering_fields = [
446 'created',
447 'expiry',
448 'last_seen',
449 'user',
450 'name',
451 'revoked',
452 'revoked',
453 ]
454 filterset_fields = ['revoked', 'user']
455 queryset = ApiToken.objects.none()
457 def create(self, request, *args, **kwargs):
458 """Create token and show key to user."""
459 resp = super().create(request, *args, **kwargs)
460 resp.data['token'] = self.serializer_class.Meta.model.objects.get(
461 id=resp.data['id']
462 ).key
463 return resp
465 def get(self, request, *args, **kwargs):
466 """List of user tokens for current user."""
467 return super().get(request, *args, **kwargs)
470class TokenDetailView(TokenMixin, DestroyAPIView, RetrieveAPI):
471 """Details for a user token."""
473 def perform_destroy(self, instance):
474 """Revoke token."""
475 instance.revoked = True
476 instance.save()
479class LoginRedirect(RedirectView):
480 """Redirect to the correct starting page after backend login."""
482 def get_redirect_url(self, *args, **kwargs):
483 """Return the URL to redirect to."""
484 return f'/{FRONTEND_URL_BASE}/logged-in/'
487class UserProfileDetail(RetrieveUpdateAPI):
488 """Detail endpoint for the user profile.
490 Permissions:
491 - Any authenticated user has write access against this endpoint
492 - The endpoint always returns the profile associated with the current user
493 """
495 queryset = UserProfile.objects.all()
496 serializer_class = UserProfileSerializer
497 permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
499 def get_object(self):
500 """Return the profile of the current user."""
501 return self.request.user.profile
504user_urls = [
505 # Legacy endpoints (to avoid breaking existing API clients)
506 # TODO @matmair - remove these legacy endpoints in the next breaking release
507 path(
508 'roles/',
509 exclude_from_schema(RoleDetails, '/api/user/me/roles/').as_view(),
510 name='api-user-roles_legacy',
511 ),
512 path(
513 'token/',
514 ensure_csrf_cookie(
515 exclude_from_schema(GetAuthToken, '/api/user/me/token/').as_view()
516 ),
517 name='api-token_legacy',
518 ),
519 path(
520 'profile/',
521 exclude_from_schema(UserProfileDetail, '/api/user/me/profile/').as_view(),
522 name='api-user-profile_legacy',
523 ),
524 # Individual user endpoints
525 path(
526 'me/',
527 include([
528 path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
529 path('roles/', RoleDetails.as_view(), name='api-user-roles'),
530 path(
531 'token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'
532 ),
533 path('', MeUserDetail.as_view(), name='api-user-me'),
534 ]),
535 ),
536 # User related endpoints
537 path(
538 'tokens/',
539 include([
540 path('<int:pk>/', TokenDetailView.as_view(), name='api-token-detail'),
541 path('', TokenListView.as_view(), name='api-token-list'),
542 ]),
543 ),
544 path(
545 'owner/',
546 include([
547 path('<int:pk>/', OwnerDetail.as_view(), name='api-owner-detail'),
548 path('', OwnerList.as_view(), name='api-owner-list'),
549 ]),
550 ),
551 path(
552 'group/',
553 include([
554 path('<int:pk>/', GroupDetail.as_view(), name='api-group-detail'),
555 path('', GroupList.as_view(), name='api-group-list'),
556 ]),
557 ),
558 path(
559 'ruleset/',
560 include([
561 path('<int:pk>/', RuleSetDetail.as_view(), name='api-ruleset-detail'),
562 path('', RuleSetList.as_view(), name='api-ruleset-list'),
563 ]),
564 ),
565 path(
566 '<int:pk>/',
567 include([
568 path(
569 'set-password/',
570 UserDetailSetPassword.as_view(),
571 name='api-user-set-password',
572 ),
573 path('', UserDetail.as_view(), name='api-user-detail'),
574 ]),
575 ),
576 path('', UserList.as_view(), name='api-user-list'),
577]