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:
Matthias Mair authored and GitHub committed 2026-10-15 19:46:06 +11:00
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
+7 -1
View File
@@ -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)
+16 -1
View File
@@ -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')},
},
),
]
+60
View File
@@ -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):
+1
View File
@@ -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',
+24 -2
View File
@@ -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 = [
+13
View File
@@ -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())