mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-12 03:40:30 +00:00
feat: Add First Time User Guide and tipp backend data structure (#12978)
* add backend stuff * update str method * add to admin * [FR] Add FTUE Fixes #2907 * bump deps * bump api * add missing ruleset entry * fix init * add jumpoff to nav * try disableing specific test * fix lookup * fix mounting * disable more * make tour conditional * only show ftue once * remove temp disable * add actual content * enable using the menu as well * reorder steps * finish propagation * only allow delete * lower testing runtime for now * fix nitpicks * revert frontend debug changes * fix migration * patch out ftu behaviour on the backend * rename to first time use guide * change tipp (DE) to tip (EN) * deduplicate notification
This commit is contained in:
1 parent
9b8fdeb06d
commit
2f139f94b4
25 files changed
+764
-250
No files matched your search
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 556
|
||||
INVENTREE_API_VERSION = 557
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v557 -> 2026-10-15 : https://github.com/inventree/InvenTree/pull/12978
|
||||
- Adds tips endpoints
|
||||
|
||||
v556 -> 2026-10-14 : https://github.com/inventree/InvenTree/pull/13017
|
||||
- Adds 'is_internal' field (and filter) to the Company API endpoint
|
||||
- Adds 'INVENTREE_DEFAULT_INTERNAL_COMPANY' global setting
|
||||
|
||||
@@ -11,7 +11,8 @@ from django.db.models import Q
|
||||
from django.utils.text import smart_split
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.models import ApiToken, Owner, RuleSet
|
||||
from common.admin_shared import NoEditAdminMixin
|
||||
from users.models import ApiToken, Owner, RuleSet, TipResult
|
||||
from users.ruleset import RULESET_CHOICES
|
||||
|
||||
User = get_user_model()
|
||||
@@ -207,5 +208,10 @@ class OwnerAdmin(admin.ModelAdmin):
|
||||
return queryset, True
|
||||
|
||||
|
||||
@admin.register(TipResult)
|
||||
class DataOutputAdmin(NoEditAdminMixin, admin.ModelAdmin):
|
||||
"""Custom admin interface for the TipResult model."""
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, InvenTreeUserAdmin)
|
||||
@@ -36,7 +36,7 @@ from InvenTree.mixins import (
|
||||
)
|
||||
from InvenTree.schema import exclude_from_schema
|
||||
from InvenTree.settings import FRONTEND_URL_BASE
|
||||
from users.models import ApiToken, Owner, RuleSet, UserProfile
|
||||
from users.models import ApiToken, Owner, RuleSet, TipResult, UserProfile
|
||||
from users.serializers import (
|
||||
ApiTokenSerializer,
|
||||
ExtendedUserSerializer,
|
||||
@@ -46,6 +46,7 @@ from users.serializers import (
|
||||
OwnerSerializer,
|
||||
RoleSerializer,
|
||||
RuleSetSerializer,
|
||||
TipResultSerializer,
|
||||
UserCreateSerializer,
|
||||
UserProfileSerializer,
|
||||
UserSetPasswordSerializer,
|
||||
@@ -572,6 +573,19 @@ class UserProfileDetail(RetrieveUpdateAPI):
|
||||
return self.request.user.profile
|
||||
|
||||
|
||||
class TipResultDetail(RetrieveUpdateAPI):
|
||||
"""Detail endpoint for tip result manipulation."""
|
||||
|
||||
queryset = TipResult.objects.all()
|
||||
serializer_class = TipResultSerializer
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Limit retrieval and updates to results owned by the current user."""
|
||||
TipResult.backfill(self.request.user)
|
||||
return super().get_queryset().filter(user=self.request.user)
|
||||
|
||||
|
||||
user_urls = [
|
||||
# Legacy endpoints (to avoid breaking existing API clients)
|
||||
# TODO @matmair - remove these legacy endpoints in the next breaking release
|
||||
@@ -601,6 +615,7 @@ user_urls = [
|
||||
path(
|
||||
'token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'
|
||||
),
|
||||
path('tips/<int:pk>/', TipResultDetail.as_view(), name='api-user-tip'),
|
||||
path('', MeUserDetail.as_view(), name='api-user-me'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-08 22:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0017_apitoken_hmac_digest_apitoken_issued_by_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TipResult',
|
||||
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')),
|
||||
('tip_id', models.CharField(max_length=255, verbose_name='Tip ID/Name')),
|
||||
('shown', models.BooleanField(default=False, help_text='Was this tip shown?', verbose_name='Shown')),
|
||||
('finished', models.BooleanField(default=False, help_text='Was this tip finished?', verbose_name='Finished')),
|
||||
('updated', models.DateTimeField(auto_now=True, help_text='The last time this tip result was updated', verbose_name='Updated')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tip_results', to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Tip Result',
|
||||
'verbose_name_plural': 'Tip Results',
|
||||
'unique_together': {('user', 'tip_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -29,6 +29,7 @@ import InvenTree.helpers
|
||||
import InvenTree.models
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.ready import isImportingData, isReadOnlyCommand
|
||||
from web.tips import KNOWN_IDS
|
||||
|
||||
from .ruleset import RULESET_CHOICES, get_ruleset_models
|
||||
|
||||
@@ -804,6 +805,65 @@ def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||
instance.profile.save()
|
||||
|
||||
|
||||
class TipResult(InvenTree.models.MetadataMixin):
|
||||
"""Model representing a tip result."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options."""
|
||||
|
||||
verbose_name = _('Tip Result')
|
||||
verbose_name_plural = _('Tip Results')
|
||||
unique_together = ('user', 'tip_id')
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tip_results',
|
||||
verbose_name=_('User'),
|
||||
)
|
||||
tip_id = models.CharField(max_length=255, verbose_name=_('Tip ID/Name'))
|
||||
shown = models.BooleanField(
|
||||
default=False, verbose_name=_('Shown'), help_text=_('Was this tip shown?')
|
||||
)
|
||||
finished = models.BooleanField(
|
||||
default=False, verbose_name=_('Finished'), help_text=_('Was this tip finished?')
|
||||
)
|
||||
|
||||
updated = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name=_('Updated'),
|
||||
help_text=_('The last time this tip result was updated'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the tip result."""
|
||||
if self.finished:
|
||||
return f'{self.user}: {self.tip_id} (Finished)'
|
||||
if self.shown:
|
||||
return f'{self.user}: {self.tip_id} (Shown)'
|
||||
return f'{self.user}: {self.tip_id}'
|
||||
|
||||
@classmethod
|
||||
def backfill(cls, user):
|
||||
"""Backfill missing state for all known IDs without an existing record."""
|
||||
known_ids = KNOWN_IDS
|
||||
if not known_ids:
|
||||
return
|
||||
|
||||
existing_ids = set(
|
||||
cls.objects.filter(user=user, tip_id__in=known_ids).values_list(
|
||||
'tip_id', flat=True
|
||||
)
|
||||
)
|
||||
|
||||
# bulk create
|
||||
missing_ids = set(known_ids) - existing_ids
|
||||
if missing_ids:
|
||||
cls.objects.bulk_create([
|
||||
cls(user=user, tip_id=tip_id) for tip_id in missing_ids
|
||||
])
|
||||
|
||||
|
||||
# Validate groups
|
||||
@receiver(post_save, sender=Group)
|
||||
def validate_primary_group_on_save(sender, instance, **kwargs):
|
||||
|
||||
@@ -216,6 +216,7 @@ def get_ruleset_ignore() -> list[str]:
|
||||
'common_selectionlist',
|
||||
'users_owner',
|
||||
'users_userprofile', # User profile is handled in the serializer - only own user can change
|
||||
'users_tipresult', # perms are handled in the serializer
|
||||
# Third-party tables
|
||||
'error_report_error',
|
||||
'exchange_rate',
|
||||
|
||||
@@ -16,7 +16,7 @@ from InvenTree.serializers import (
|
||||
OptionalField,
|
||||
)
|
||||
|
||||
from .models import ApiToken, Owner, RuleSet, UserProfile
|
||||
from .models import ApiToken, Owner, RuleSet, TipResult, UserProfile
|
||||
from .permissions import check_user_role, prefetch_rule_sets
|
||||
from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum
|
||||
|
||||
@@ -183,6 +183,17 @@ class UserProfileSerializer(BriefUserProfileSerializer):
|
||||
]
|
||||
|
||||
|
||||
class TipResultSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for the TipResult model."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for TipResultSerializer."""
|
||||
|
||||
model = TipResult
|
||||
fields = ['pk', 'tip_id', 'shown', 'finished', 'updated']
|
||||
read_only_fields = ['pk', 'tip_id', 'updated']
|
||||
|
||||
|
||||
class UserSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a User."""
|
||||
|
||||
@@ -336,9 +347,10 @@ class ExtendedUserSerializer(UserSerializer):
|
||||
'is_superuser',
|
||||
'is_active',
|
||||
'profile',
|
||||
'tips',
|
||||
]
|
||||
|
||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
|
||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups', 'tips']
|
||||
|
||||
# 'group_ids' is a write-only serializer field which does not exist on the model
|
||||
SKIP_CREATE_FIELDS = ['group_ids']
|
||||
@@ -366,6 +378,15 @@ class ExtendedUserSerializer(UserSerializer):
|
||||
|
||||
profile = BriefUserProfileSerializer(many=False, read_only=True)
|
||||
|
||||
tips = serializers.SerializerMethodField()
|
||||
|
||||
def get_tips(self, user: User) -> list[dict]:
|
||||
"""Return the user's known tip results, creating missing records first."""
|
||||
TipResult.backfill(user)
|
||||
return TipResultSerializer(
|
||||
user.tip_results.all(), many=True, context=self.context
|
||||
).data
|
||||
|
||||
def validate_is_superuser(self, value):
|
||||
"""Only a superuser account can adjust this value!"""
|
||||
request_user = self.context['request'].user
|
||||
@@ -430,6 +451,7 @@ class MeUserSerializer(FilterableSerializerMixin, ExtendedUserSerializer):
|
||||
*(f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids'),
|
||||
'roles',
|
||||
'permissions',
|
||||
'tips',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Central registration for frontend tips.
|
||||
|
||||
This is a pretty simple system for now, could be enhanced in the future with plugin registrations, more complex server side logic, and dynamic tips.
|
||||
"""
|
||||
|
||||
COMMON_PREFIX = 'org.inventree.i.tip'
|
||||
|
||||
available_tips: dict = {
|
||||
COMMON_PREFIX + '.ftug': {} # First-time user guide
|
||||
# COMMON_PREFIX + '.example': {}, # Example tip
|
||||
}
|
||||
|
||||
KNOWN_IDS: list = list(available_tips.keys())
|
||||
Reference in new issue
Block a user