2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-20 03:36:30 +00:00

feat(backend): Add user profile ()

* Add user profile

* fix choice set

* ensure primary_group is valid

* add missing migrations

* fix tests

* merge migrations

* add migration test

* add new model to ruleset

* ensure changed to the m2m conenction also validate primary grups

* move signals

* fix import?

* patch user language through

* use set methods correctly

* bump api

* refactoring to make debugging and extending easier

* fix dum recurrsion problem

* fix user pk lookup

* rename migration

* add user and group page

* cleanup

* add hoverCard for user / owner / group render

* include owner_model in owner responses

* move user serializers to users

* add profile to list

* add brief serializer for profiles

* ensure profile is present in most apis

* extend rendered data

* store and observe langauge in profile

* reduce unneeded complexity

* enable access to full profle (including internal fields) in me serializer

* move theme to a single object

* persist theme settings

* fix radius lookup

* remove debug message

* fix filter

* remove unused field

* remove image fields

* add setting to control showing profiles

* fix settings

* update test

* fix theme reload

* Add contact UI

* Add profile edit screen

* fix test

* Add testing for user theme panel

* fix var name

* complete coverage of theme

* Add test for new pages

* make test more reliable in strict mode

* remove step

* fix ref

* add verbose names

* fix used setting

* extend tests

* fix permissions

* fix lookup

* use lookup to enuse ursls stay valid

* update migrations

* Add position field

* fix permissions
This commit is contained in:
Matthias Mair
2025-03-04 12:57:20 +01:00
committed by GitHub
parent 8bca48dbdd
commit 0d1ab4e75a
42 changed files with 1648 additions and 355 deletions

@@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 317
INVENTREE_API_VERSION = 318
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v318 - 2025-02-25 : https://github.com/inventree/InvenTree/pull/9116
- Adds user profile API endpoints
v317 - 2025-02-26 : https://github.com/inventree/InvenTree/pull/9143
- Default 'overdue' field to False in Build serializer
- Add allow_null to various fields in Build, Settings, Order, Part, and Stock serializers

@@ -2,6 +2,7 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.urls import reverse
import structlog
from rest_framework import exceptions, serializers
@@ -401,9 +402,11 @@ class InvenTreeMetadata(SimpleMetadata):
# Special case for special models
if field_info['model'] == 'user':
field_info['api_url'] = '/api/user/'
field_info['api_url'] = (reverse('api-user-list'),)
elif field_info['model'] == 'group':
field_info['api_url'] = reverse('api-group-list')
elif field_info['model'] == 'contenttype':
field_info['api_url'] = '/api/contenttype/'
field_info['api_url'] = reverse('api-contenttype-list')
elif hasattr(model, 'get_api_url'):
field_info['api_url'] = model.get_api_url()
else:

@@ -6,7 +6,6 @@ from copy import deepcopy
from decimal import Decimal
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -15,7 +14,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
from djmoney.money import Money
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.exceptions import ValidationError
from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin
from rest_framework.serializers import DecimalField
@@ -400,153 +399,6 @@ class InvenTreeTagModelSerializer(InvenTreeTaggitSerializer, InvenTreeModelSeria
"""Combination of InvenTreeTaggitSerializer and InvenTreeModelSerializer."""
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = [
*UserSerializer.Meta.fields,
'groups',
'is_staff',
'is_superuser',
'is_active',
]
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(
_('You do not have permission to change this user role.')
)
return super().validate(attrs)
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_('Only superusers can create new users'))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
from InvenTree.tasks import email_user, offload_task
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
subject = _('Welcome to InvenTree')
# Send the user an onboarding email (from current site)
offload_task(
email_user, instance.pk, str(subject), str(message), force_async=True
)
return instance
class InvenTreeAttachmentSerializerField(serializers.FileField):
"""Override the DRF native FileField serializer, to remove the leading server path.

@@ -36,13 +36,12 @@ from InvenTree.serializers import (
InvenTreeDecimalField,
InvenTreeModelSerializer,
NotesFieldMixin,
UserSerializer,
)
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
from stock.status_codes import StockStatus
from users.serializers import OwnerSerializer
from users.serializers import OwnerSerializer, UserSerializer
from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus

@@ -23,10 +23,9 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer
from users.serializers import OwnerSerializer, UserSerializer
class SettingsValueField(serializers.Field):

@@ -1074,6 +1074,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'DISPLAY_PROFILE_INFO': {
'name': _('Display User Profiles'),
'description': _('Display Users Profiles on their profile page'),
'default': True,
'validator': bool,
},
'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'),

@@ -12,8 +12,8 @@ import importer.registry
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from users.serializers import UserSerializer
class DataImportColumnMapSerializer(InvenTreeModelSerializer):

@@ -50,7 +50,6 @@ from InvenTree.serializers import (
InvenTreeModelSerializer,
InvenTreeMoneySerializer,
NotesFieldMixin,
UserSerializer,
)
from order.status_codes import (
PurchaseOrderStatusGroups,
@@ -60,7 +59,7 @@ from order.status_codes import (
)
from part.serializers import PartBriefSerializer
from stock.status_codes import StockStatus
from users.serializers import OwnerSerializer
from users.serializers import OwnerSerializer, UserSerializer
class TotalPriceMixin(serializers.Serializer):

@@ -36,6 +36,7 @@ from build.status_codes import BuildStatusGroups
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.tasks import offload_task
from users.serializers import UserSerializer
from .models import (
BomItem,
@@ -1215,9 +1216,7 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
quantity = serializers.FloatField()
user_detail = InvenTree.serializers.UserSerializer(
source='user', read_only=True, many=False
)
user_detail = UserSerializer(source='user', read_only=True, many=False)
cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True)
cost_min_currency = InvenTree.serializers.InvenTreeCurrencySerializer()
@@ -1245,9 +1244,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
read_only_fields = ['date', 'report', 'part_count', 'user']
user_detail = InvenTree.serializers.UserSerializer(
source='user', read_only=True, many=False
)
user_detail = UserSerializer(source='user', read_only=True, many=False)
report = InvenTree.serializers.InvenTreeAttachmentSerializerField(read_only=True)

@@ -10,12 +10,12 @@ import company.models
import order.models
import plugin.base.barcodes.helper
import stock.models
from InvenTree.serializers import UserSerializer
from order.status_codes import (
PurchaseOrderStatus,
PurchaseOrderStatusGroups,
SalesOrderStatusGroups,
)
from users.serializers import UserSerializer
class BarcodeScanResultSerializer(serializers.ModelSerializer):

@@ -11,8 +11,8 @@ import report.models
from InvenTree.serializers import (
InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer,
UserSerializer,
)
from users.serializers import UserSerializer
class ReportSerializerBase(InvenTreeModelSerializer):

@@ -31,6 +31,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from users.serializers import UserSerializer
from .models import (
StockItem,
@@ -223,7 +224,7 @@ class StockItemTestResultSerializer(
if template_detail is not True:
self.fields.pop('template_detail', None)
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
user_detail = UserSerializer(source='user', read_only=True)
template = serializers.PrimaryKeyRelatedField(
queryset=part_models.PartTestTemplate.objects.all(),
@@ -1272,9 +1273,7 @@ class StockTrackingSerializer(
item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True)
user_detail = InvenTree.serializers.UserSerializer(
source='user', many=False, read_only=True
)
user_detail = UserSerializer(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True)

@@ -25,18 +25,17 @@ from InvenTree.mixins import (
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
from InvenTree.serializers import (
ExtendedUserSerializer,
MeUserSerializer,
UserCreateSerializer,
)
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner
from users.models import ApiToken, Owner, UserProfile
from users.serializers import (
ApiTokenSerializer,
ExtendedUserSerializer,
GroupSerializer,
MeUserSerializer,
OwnerSerializer,
RoleSerializer,
UserCreateSerializer,
UserProfileSerializer,
)
logger = structlog.get_logger('inventree')
@@ -297,6 +296,18 @@ class LoginRedirect(RedirectView):
return f'/{FRONTEND_URL_BASE}/logged-in/'
class UserProfileDetail(RetrieveUpdateAPI):
"""Detail endpoint for the user profile."""
queryset = UserProfile.objects.all()
serializer_class = UserProfileSerializer
permission_classes = [permissions.IsAuthenticated]
def get_object(self):
"""Return the profile of the current user."""
return self.request.user.profile
user_urls = [
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
@@ -308,6 +319,7 @@ user_urls = [
]),
),
path('me/', MeUserDetail.as_view(), name='api-user-me'),
path('profile/', UserProfileDetail.as_view(), name='api-user-profile'),
path(
'owner/',
include([

@@ -0,0 +1,182 @@
# Generated by Django 4.2.19 on 2025-03-03 00:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def backfill_user_profiles(apps, schema_editor):
User = apps.get_model('auth', 'User')
UserProfile = apps.get_model('users', 'UserProfile')
for user in User.objects.all():
UserProfile.objects.get_or_create(user=user)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("auth", "0012_alter_user_first_name_max_length"),
("users", "0013_migrate_mfa_20240408_1659"),
]
operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"metadata",
models.JSONField(
blank=True,
help_text="JSON metadata field, for use by external plugins",
null=True,
verbose_name="Plugin Metadata",
),
),
(
"language",
models.CharField(
blank=True,
help_text="Preferred language for the user",
max_length=10,
null=True,
verbose_name="Language",
),
),
(
"theme",
models.JSONField(
blank=True,
help_text="Settings for the web UI as JSON - do not edit manually!",
null=True,
verbose_name="Theme",
),
),
(
"widgets",
models.JSONField(
blank=True,
help_text="Settings for the dashboard widgets as JSON - do not edit manually!",
null=True,
verbose_name="Widgets",
),
),
(
"displayname",
models.CharField(
blank=True,
help_text="Chosen display name for the user",
max_length=255,
null=True,
verbose_name="Display Name",
),
),
(
"position",
models.CharField(
blank=True,
help_text="Main job title or position",
max_length=255,
null=True,
verbose_name="Position",
),
),
(
"status",
models.CharField(
blank=True,
help_text="User status message",
max_length=2000,
null=True,
verbose_name="Status",
),
),
(
"location",
models.CharField(
blank=True,
help_text="User location information",
max_length=2000,
null=True,
verbose_name="Location",
),
),
(
"active",
models.BooleanField(
default=True,
help_text="User is actively using the system",
verbose_name="Active",
),
),
(
"contact",
models.CharField(
blank=True,
help_text="Preferred contact information for the user",
max_length=255,
null=True,
verbose_name="Contact",
),
),
(
"type",
models.CharField(
choices=[
("bot", "Bot"),
("internal", "Internal"),
("external", "External"),
("guest", "Guest"),
],
default="internal",
help_text="Which type of user is this?",
max_length=10,
verbose_name="Type",
),
),
(
"organisation",
models.CharField(
blank=True,
help_text="Users primary organisation/affiliation",
max_length=255,
null=True,
verbose_name="Organisation",
),
),
(
"primary_group",
models.ForeignKey(
blank=True,
help_text="Primary group for the user",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="primary_users",
to="auth.group",
verbose_name="Primary Group",
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"abstract": False,
},
),
migrations.RunPython(backfill_user_profiles),
]

@@ -11,7 +11,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Q, UniqueConstraint
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.db.utils import IntegrityError
from django.dispatch import receiver
from django.urls import reverse
@@ -348,6 +348,7 @@ class RuleSet(models.Model):
'common_selectionlistentry',
'common_selectionlist',
'users_owner',
'users_userprofile', # User profile is handled in the serializer - only own user can change
# Third-party tables
'error_report_error',
'exchange_rate',
@@ -924,3 +925,153 @@ def create_missing_rule_sets(sender, instance, **kwargs):
As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions.
"""
update_group_roles(instance)
class UserProfile(InvenTree.models.MetadataMixin):
"""Model to store additional user profile information."""
class UserType(models.TextChoices):
"""Enumeration for user types."""
BOT = 'bot', _('Bot')
INTERNAL = 'internal', _('Internal')
EXTERNAL = 'external', _('External')
GUEST = 'guest', _('Guest')
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name='profile', verbose_name=_('User')
)
language = models.CharField(
max_length=10,
blank=True,
null=True,
verbose_name=_('Language'),
help_text=_('Preferred language for the user'),
)
theme = models.JSONField(
blank=True,
null=True,
verbose_name=_('Theme'),
help_text=_('Settings for the web UI as JSON - do not edit manually!'),
)
widgets = models.JSONField(
blank=True,
null=True,
verbose_name=_('Widgets'),
help_text=_(
'Settings for the dashboard widgets as JSON - do not edit manually!'
),
)
displayname = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Display Name'),
help_text=_('Chosen display name for the user'),
)
position = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Position'),
help_text=_('Main job title or position'),
)
status = models.CharField(
max_length=2000,
blank=True,
null=True,
verbose_name=_('Status'),
help_text=_('User status message'),
)
location = models.CharField(
max_length=2000,
blank=True,
null=True,
verbose_name=_('Location'),
help_text=_('User location information'),
)
active = models.BooleanField(
default=True,
verbose_name=_('Active'),
help_text=_('User is actively using the system'),
)
contact = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Contact'),
help_text=_('Preferred contact information for the user'),
)
type = models.CharField(
max_length=10,
choices=UserType.choices,
default=UserType.INTERNAL,
verbose_name=_('Type'),
help_text=_('Which type of user is this?'),
)
organisation = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_('Organisation'),
help_text=_('Users primary organisation/affiliation'),
)
primary_group = models.ForeignKey(
Group,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='primary_users',
verbose_name=_('Primary Group'),
help_text=_('Primary group for the user'),
)
def __str__(self):
"""Return string representation of the user profile."""
return f'{self.user.username} user profile'
def save(self, *args, **kwargs):
"""Ensure primary_group is a group that the user is a member of."""
if self.primary_group and self.primary_group not in self.user.groups.all():
self.primary_group = None
super().save(*args, **kwargs)
# Signal to create or update user profile when user is saved
@receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs):
"""Create or update user profile when user is saved."""
if created:
UserProfile.objects.create(user=instance)
instance.profile.save()
# Validate groups
@receiver(post_save, sender=Group)
def validate_primary_group_on_save(sender, instance, **kwargs):
"""Validate primary_group on user profiles when a group is created or updated."""
for user in instance.user_set.all():
profile = user.profile
if profile.primary_group and profile.primary_group not in user.groups.all():
profile.primary_group = None
profile.save()
@receiver(post_delete, sender=Group)
def validate_primary_group_on_delete(sender, instance, **kwargs):
"""Validate primary_group on user profiles when a group is deleted."""
for user in instance.user_set.all():
profile = user.profile
if profile.primary_group == instance:
profile.primary_group = None
profile.save()
@receiver(m2m_changed, sender=User.groups.through)
def validate_primary_group_on_group_change(sender, instance, action, **kwargs):
"""Validate primary_group on user profiles when a group is added or removed."""
if action in ['post_add', 'post_remove']:
profile = instance.profile
if profile.primary_group and profile.primary_group not in instance.groups.all():
profile.primary_group = None
profile.save()

@@ -3,12 +3,14 @@
from django.contrib.auth.models import Group, Permission, User
from django.core.exceptions import AppRegistryNotReady
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from InvenTree.serializers import InvenTreeModelSerializer
from .models import ApiToken, Owner, RuleSet, check_user_role
from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role
class OwnerSerializer(InvenTreeModelSerializer):
@@ -18,9 +20,10 @@ class OwnerSerializer(InvenTreeModelSerializer):
"""Metaclass defines serializer fields."""
model = Owner
fields = ['pk', 'owner_id', 'name', 'label']
fields = ['pk', 'owner_id', 'owner_model', 'name', 'label']
name = serializers.CharField(read_only=True)
owner_model = serializers.CharField(read_only=True, source='owner._meta.model_name')
label = serializers.CharField(read_only=True)
@@ -148,3 +151,189 @@ class ApiTokenSerializer(InvenTreeModelSerializer):
'user',
'in_use',
]
class BriefUserProfileSerializer(InvenTreeModelSerializer):
"""Brief serializer for the UserProfile model."""
class Meta:
"""Meta options for BriefUserProfileSerializer."""
model = UserProfile
fields = [
'displayname',
'position',
'status',
'location',
'active',
'contact',
'type',
'organisation',
'primary_group',
]
class UserProfileSerializer(BriefUserProfileSerializer):
"""Serializer for the UserProfile model."""
class Meta(BriefUserProfileSerializer.Meta):
"""Meta options for UserProfileSerializer."""
fields = [
'language',
'theme',
'widgets',
*BriefUserProfileSerializer.Meta.fields,
]
class UserSerializer(InvenTreeModelSerializer):
"""Serializer for a User."""
class Meta:
"""Metaclass defines serializer fields."""
model = User
fields = ['pk', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
groups = GroupSerializer(read_only=True, many=True)
class Meta(UserSerializer.Meta):
"""Metaclass defines serializer fields."""
fields = [
*UserSerializer.Meta.fields,
'groups',
'is_staff',
'is_superuser',
'is_active',
'profile',
]
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
profile = BriefUserProfileSerializer(many=False, read_only=True)
def validate(self, attrs):
"""Expanded validation for changing user role."""
# Check if is_staff or is_superuser is in attrs
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
request_user = self.context['request'].user
if role_change:
if request_user.is_superuser:
# Superusers can change any role
pass
elif request_user.is_staff and 'is_superuser' not in attrs:
# Staff can change any role except is_superuser
pass
else:
raise PermissionDenied(
_('You do not have permission to change this user role.')
)
return super().validate(attrs)
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
profile = UserProfileSerializer(many=False, read_only=True)
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
if not self.context['request'].user.is_superuser:
raise serializers.ValidationError(_('Only superusers can create new users'))
# Generate a random password
password = User.objects.make_random_password(length=14)
attrs.update({'password': password})
return super().validate(attrs)
def create(self, validated_data):
"""Send an e email to the user after creation."""
from InvenTree.helpers_model import get_base_url
from InvenTree.tasks import email_user, offload_task
base_url = get_base_url()
instance = super().create(validated_data)
# Make sure the user cannot login until they have set a password
instance.set_unusable_password()
message = (
_('Your account has been created.')
+ '\n\n'
+ _('Please use the password reset function to login')
)
if base_url:
message += f'\n\nURL: {base_url}'
subject = _('Welcome to InvenTree')
# Send the user an onboarding email (from current site)
offload_task(
email_user, instance.pk, str(subject), str(message), force_async=True
)
return instance

@@ -26,6 +26,38 @@ class TestForwardMigrations(MigratorTestCase):
self.assertEqual(User.objects.count(), 2)
class TestBackfillUserProfiles(MigratorTestCase):
"""Test backfill migration for user profiles."""
migrate_from = ('users', '0012_alter_ruleset_can_view')
migrate_to = ('users', '0014_userprofile')
def prepare(self):
"""Setup the initial state of the database before migrations."""
User = self.old_state.apps.get_model('auth', 'user')
User.objects.create(
username='fred', email='fred@example.org', password='password'
)
User.objects.create(
username='brad', email='brad@example.org', password='password'
)
def test_backfill_user_profiles(self):
"""Test that user profiles are created during the migration."""
User = self.new_state.apps.get_model('auth', 'user')
UserProfile = self.new_state.apps.get_model('users', 'UserProfile')
self.assertEqual(User.objects.count(), 2)
self.assertEqual(UserProfile.objects.count(), 2)
fred = User.objects.get(username='fred')
brad = User.objects.get(username='brad')
self.assertIsNotNone(UserProfile.objects.get(user=fred))
self.assertIsNotNone(UserProfile.objects.get(user=brad))
class MFAMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the users app."""

@@ -247,7 +247,8 @@ class OwnerModelTest(InvenTreeTestCase):
self.assertEqual(response_detail['username'], self.username)
response_me = self.do_request(reverse('api-user-me'), {}, 200)
self.assertEqual(response_detail, response_me)
self.assertIn('language', response_me['profile'])
self.assertIn('theme', response_me['profile'])
def test_token(self):
"""Test token mechanisms."""
@@ -349,3 +350,97 @@ class AdminTest(AdminTestCase):
)
# Additionally test str fnc
self.assertEqual(str(my_token), my_token.token)
class UserProfileTest(InvenTreeAPITestCase):
"""Tests for the user profile API endpoints."""
def test_profile_retrieve(self):
"""Test retrieving the user profile."""
response = self.client.get(reverse('api-user-profile'))
self.assertEqual(response.status_code, 200)
self.assertIn('language', response.data)
self.assertIn('theme', response.data)
self.assertIn('widgets', response.data)
self.assertIn('displayname', response.data)
self.assertIn('position', response.data)
self.assertIn('status', response.data)
self.assertIn('location', response.data)
self.assertIn('active', response.data)
self.assertIn('contact', response.data)
self.assertIn('type', response.data)
self.assertIn('organisation', response.data)
self.assertIn('primary_group', response.data)
def test_profile_update(self):
"""Test updating the user profile."""
data = {
'language': 'en',
'theme': {'color': 'blue'},
'widgets': {'widget1': 'value1'},
'displayname': 'Test User',
'status': 'Active',
'location': 'Test Location',
'active': True,
'contact': 'test@example.com',
'type': 'internal',
'organisation': 'Test Organisation',
'primary_group': self.group.pk,
}
response = self.patch(reverse('api-user-profile'), data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['language'], data['language'])
self.assertEqual(response.data['theme'], data['theme'])
self.assertEqual(response.data['widgets'], data['widgets'])
self.assertEqual(response.data['displayname'], data['displayname'])
self.assertEqual(response.data['status'], data['status'])
self.assertEqual(response.data['location'], data['location'])
self.assertEqual(response.data['active'], data['active'])
self.assertEqual(response.data['contact'], data['contact'])
self.assertEqual(response.data['type'], data['type'])
self.assertEqual(response.data['organisation'], data['organisation'])
self.assertEqual(response.data['primary_group'], data['primary_group'])
def test_primary_group_validation(self):
"""Test that primary_group is a group that the user is a member of."""
new_group = Group.objects.create(name='New Group')
profile = self.user.profile
profile.primary_group = new_group
profile.save()
self.assertIsNone(profile.primary_group)
def test_validate_primary_group_on_save(self):
"""Test validate_primary_group_on_save signal handler."""
group = Group.objects.create(name='Test Group')
self.user.groups.add(group)
profile = self.user.profile
profile.primary_group = group
profile.save()
# Ensure primary_group is set correctly
self.assertEqual(profile.primary_group, group)
# Remove user from group and save group
self.user.groups.remove(group)
# Ensure primary_group is set to None
profile.refresh_from_db()
self.assertIsNone(profile.primary_group)
def test_validate_primary_group_on_delete(self):
"""Test validate_primary_group_on_delete signal handler."""
group = Group.objects.create(name='Test Group')
self.user.groups.add(group)
profile = self.user.profile
profile.primary_group = group
profile.save()
# Ensure primary_group is set correctly
self.assertEqual(profile.primary_group, group)
# Delete group
group.delete()
# Ensure primary_group is set to None
profile.refresh_from_db()
self.assertIsNone(profile.primary_group)