mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-20 03:36:30 +00:00
feat(backend): Add user profile (#9116)
* 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:
src
backend
InvenTree
InvenTree
build
common
importer
order
part
plugin
base
barcodes
report
stock
users
frontend
@@ -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([
|
||||
|
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
182
src/backend/InvenTree/users/migrations/0014_userprofile.py
Normal file
@@ -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)
|
||||
|
Reference in New Issue
Block a user