diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 4d4b72abd294..146452e80611 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 459 +INVENTREE_API_VERSION = 460 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v460 -> 2026-02-26 : https://github.com/inventree/InvenTree/pull/10715 + - Adds GuideDefinition and GuideExecution models and API endpoints to provide tipps and guides within InvenTree's web frontend.v443 -> 2026-01-21 : https://github.com/inventree/InvenTree/pull/11177 + v459 -> 2026-02-23 : https://github.com/inventree/InvenTree/pull/11411 - Changed PurchaseOrderLine "auto_pricing" default value from true to false diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index efa4d355c668..85d80b5c8941 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -1,6 +1,7 @@ """Provides helper functions used throughout the InvenTree project.""" import datetime +import gc import hashlib import inspect import io @@ -1146,6 +1147,15 @@ def inheritors( return subcls +def instances_of(cls: type[Inheritors_T]) -> list[Inheritors_T]: + """Return instances of a class. + + Args: + cls: The class of which type instances should be searched + """ + return [k for k in gc.get_referrers(cls) if k.__class__ is cls] + + def pui_url(subpath: str) -> str: """Return the URL for a web subpath.""" if not subpath.startswith('/'): diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 4bd226a5749c..1544a5b1aa7b 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -275,6 +275,10 @@ class FilterableIntegerField(FilterableSerializerField, serializers.IntegerField """Custom IntegerField which allows filtering.""" +class FilterableJSONField(FilterableSerializerField, serializers.JSONField): + """Custom JSONField which allows filtering.""" + + class FilterableTagListField(FilterableSerializerField, TagListSerializerField): """Custom TagListSerializerField which allows filtering.""" diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 0d8831f36508..a91d78fa7226 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -301,7 +301,7 @@ 'machine.apps.MachineConfig', 'data_exporter.apps.DataExporterConfig', 'importer.apps.ImporterConfig', - 'web', + 'web.apps.WebConfig', 'generic', 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last # Core django modules diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index 282060634721..0cd2440f4c0d 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -27,6 +27,7 @@ import report.api import stock.api import users.api +import web.api from plugin.urls import get_plugin_urls from web.urls import cui_compatibility_urls from web.urls import urlpatterns as platform_urls @@ -80,6 +81,7 @@ ]), ), path('user/', include(users.api.user_urls)), + path('web/', include(web.api.web_urls)), # Plugin endpoints path('', include(plugin.api.plugin_api_urls)), # Common endpoints endpoint diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index ed70b79b4d1c..0dc209d61f14 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -193,6 +193,9 @@ def get_ruleset_ignore() -> list[str]: 'common_selectionlist', 'users_owner', 'users_userprofile', # User profile is handled in the serializer - only own user can change + # Web + 'web_guidedefinition', + 'web_guideexecution', # Third-party tables 'error_report_error', 'exchange_rate', diff --git a/src/backend/InvenTree/web/admin.py b/src/backend/InvenTree/web/admin.py new file mode 100644 index 000000000000..fe54ef7e8267 --- /dev/null +++ b/src/backend/InvenTree/web/admin.py @@ -0,0 +1,23 @@ +"""Admin classes for the 'web' app.""" + +from django.contrib import admin + +from web.models import GuideDefinition, GuideExecution + + +class GuideExecutionInline(admin.TabularInline): + """Inline for guide executions.""" + + model = GuideExecution + extra = 1 + + +@admin.register(GuideDefinition) +class GuideDefinitionAdmin(admin.ModelAdmin): + """Admin class for the GuideDefinition model.""" + + list_display = ('guide_type', 'name', 'slug') + list_filter = ('guide_type',) + inlines = [GuideExecutionInline] + fields = ('guide_type', 'name', 'slug', 'description', 'guide_data', 'metadata') + prepopulated_fields = {'slug': ('name',)} diff --git a/src/backend/InvenTree/web/api.py b/src/backend/InvenTree/web/api.py new file mode 100644 index 000000000000..8d3a4b0cb040 --- /dev/null +++ b/src/backend/InvenTree/web/api.py @@ -0,0 +1,122 @@ +"""DRF API definition for the 'web' app.""" + +from datetime import datetime + +from django.urls import include, path + +import django_filters.rest_framework.filters as rest_filters +import structlog + +import InvenTree.permissions +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration +from InvenTree.filters import SEARCH_ORDER_FILTER +from InvenTree.helpers import str2bool +from InvenTree.mixins import ( + ListAPI, + OutputOptionsMixin, + RetrieveAPI, + SerializerContextMixin, + UpdateAPI, +) +from web.models import GuideDefinition +from web.serializers import EmptySerializer, GuideDefinitionSerializer + +logger = structlog.get_logger('inventree') + + +class GuideDefinitionFilter: + """Filter class for GuideDefinition objects.""" + + all = rest_filters.BooleanFilter( + label='Show all definitions', method='filter_all', default=False + ) + + def filter_all(self, queryset, name, value): + """Filter to show all definitions.""" + if str2bool(value): + return queryset + return queryset.filter(executions__done=False) + + +class GuideDefinitionMixin(SerializerContextMixin): + """Mixin for GuideDefinition detail views.""" + + queryset = GuideDefinition.objects.all() + serializer_class = GuideDefinitionSerializer + permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope] + filter_backends = SEARCH_ORDER_FILTER + filterclass = GuideDefinitionFilter + + def get_queryset(self): + """Return queryset for this endpoint.""" + return super().get_queryset().prefetch_related('executions') + + +class GuideDefinitionDetailOptions(OutputConfiguration): + """Holds all available output options for Group views.""" + + OPTIONS = [ + InvenTreeOutputOption( + 'description', + description='Include description field (optional, may be large)', + ), + InvenTreeOutputOption( + 'guide_data', + description='Include data field (optional, may be large JSON object and is only meant for machines)', + ), + ] + + +class GuideDefinitionDetail(GuideDefinitionMixin, OutputOptionsMixin, RetrieveAPI): + """Detail for a particular guide definition.""" + + output_options = GuideDefinitionDetailOptions + + +class GuideDefinitionList(GuideDefinitionMixin, OutputOptionsMixin, ListAPI): + """List of guide definitions.""" + + output_options = GuideDefinitionDetailOptions + filter_backends = SEARCH_ORDER_FILTER + search_fields = ['name', 'slug', 'description'] + ordering_fields = ['guide_type', 'slug', 'name'] + + +class GuideDismissal(UpdateAPI): + """Dismissing a guide for the current user.""" + + queryset = GuideDefinition.objects.all() + serializer_class = EmptySerializer + permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] + lookup_field = 'slug' + + def get_serializer_context(self): + """Provide context for the serializer.""" + context = super().get_serializer_context() + context['user'] = self.request.user + return context + + def perform_update(self, serializer): + """Override to dismiss the guide for the user.""" + obj: GuideDefinition = serializer.instance + user = self.request.user + items = obj.executions.filter(user__pk=user.pk, done=True) + if len(items) == 0: + obj.executions.create(user=user, done=True, completed_at=datetime.now()) + logger.info('Guide dismissed', guide=obj.slug, user=user.username) + + +web_urls = [ + path( + 'guides/', + include([ + path( + '/dismiss/', + GuideDismissal.as_view(), + name='api-guide-dismiss', + ), + path('/', GuideDefinitionDetail.as_view(), name='api-guide-detail'), + path('', GuideDefinitionList.as_view(), name='api-guide-list'), + ]), + ) +] diff --git a/src/backend/InvenTree/web/apps.py b/src/backend/InvenTree/web/apps.py new file mode 100644 index 000000000000..5b2f9091ef23 --- /dev/null +++ b/src/backend/InvenTree/web/apps.py @@ -0,0 +1,23 @@ +"""App config for "web" app.""" + +from django.apps import AppConfig + +import structlog + +import InvenTree.ready + +logger = structlog.get_logger('inventree') + + +class WebConfig(AppConfig): + """AppConfig for web app.""" + + name = 'web' + + def ready(self): + """Initialize restart flag clearance on startup.""" + if not InvenTree.ready.canAppAccessDatabase(): # pragma: no cover + return + from web.models import collect_guides # pragma: no cover + + collect_guides(create=True) # Preload guide definitions # pragma: no cover diff --git a/src/backend/InvenTree/web/migrations/0001_initial.py b/src/backend/InvenTree/web/migrations/0001_initial.py new file mode 100644 index 000000000000..38e466b13dce --- /dev/null +++ b/src/backend/InvenTree/web/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.8 on 2025-11-24 11:39 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models +from web.models import GuideDefinition + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GuideDefinition', + 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')), + ('uid', models.CharField(default=uuid.uuid4, editable=False, help_text='Unique uuid4 identifier for this guide definition', max_length=255, verbose_name='Endpoint')), + ('name', models.CharField(help_text='Name of the guide', max_length=100, unique=True, verbose_name='Name')), + ('slug', models.SlugField(help_text='URL-friendly unique identifier for the guide', max_length=100, unique=True, verbose_name='Slug')), + ('description', models.TextField(blank=True, help_text='Optional description of the guide', verbose_name='Description')), + ('guide_type', models.CharField(choices=GuideDefinition.GuideType.choices, help_text='Type of the guide', max_length=20, verbose_name='Guide Type')), + ('guide_data', models.JSONField(blank=True, help_text='JSON data field for storing extra information', null=True, verbose_name='Data')), + ], + options={ + 'verbose_name': 'Guide Definition', + 'verbose_name_plural': 'Guide Definitions', + }, + ), + migrations.CreateModel( + name='GuideExecution', + 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')), + ('uid', models.CharField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this guide execution', max_length=255, verbose_name='UID')), + ('started_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp when the guide execution started', verbose_name='Started At')), + ('completed_at', models.DateTimeField(blank=True, help_text='Timestamp when the guide execution was completed', null=True, verbose_name='Completed At')), + ('progres_data', models.JSONField(blank=True, help_text='JSON field to track progress of the guide execution', null=True, verbose_name='Progress')), + ('done', models.BooleanField(default=False, help_text='Indicates whether the guide execution is completed', verbose_name='Done')), + ('guide', models.ForeignKey(help_text='The guide definition associated with this execution', on_delete=django.db.models.deletion.CASCADE, related_name='executions', to='web.guidedefinition', verbose_name='Guide')), + ('user', models.ForeignKey(help_text='The user who is executing the guide', on_delete=django.db.models.deletion.CASCADE, related_name='guide_executions', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Guide Execution', + 'verbose_name_plural': 'Guide Executions', + }, + ), + ] diff --git a/src/backend/InvenTree/web/migrations/__init__.py b/src/backend/InvenTree/web/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/InvenTree/web/models.py b/src/backend/InvenTree/web/models.py new file mode 100644 index 000000000000..199789f04c6b --- /dev/null +++ b/src/backend/InvenTree/web/models.py @@ -0,0 +1,182 @@ +"""Database model definitions for the 'web' app.""" + +import uuid +from typing import Optional + +from django.contrib.auth.models import User +from django.db import models +from django.db.utils import OperationalError +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + +import structlog + +import InvenTree.models +from InvenTree.helpers import instances_of + +from .types import GuideDefinitionData + +logger = structlog.get_logger('inventree') + + +class GuideDefinition(InvenTree.models.MetadataMixin): + """Model that represents a guide definition.""" + + class GuideType(models.TextChoices): + """Enumeration for guide types.""" + + Tipp = 'tipp', _('Tipp') + FirstUseTipp = 'firstuse', _('First Use Tipp') + + uid = models.CharField( + max_length=255, + verbose_name=_('Endpoint'), + help_text=_('Unique uuid4 identifier for this guide definition'), + default=uuid.uuid4, + editable=False, + ) + name = models.CharField( + max_length=100, + unique=True, + verbose_name=_('Name'), + help_text=_('Name of the guide'), + ) + slug = models.SlugField( + max_length=100, + unique=True, + verbose_name=_('Slug'), + help_text=_('URL-friendly unique identifier for the guide'), + ) + description = models.TextField( + blank=True, + verbose_name=_('Description'), + help_text=_('Optional description of the guide'), + ) + guide_type = models.CharField( + max_length=20, + choices=GuideType.choices, + verbose_name=_('Guide Type'), + help_text=_('Type of the guide'), + ) + guide_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('Data'), + help_text=_('JSON data field for storing extra information'), + ) + + def __str__(self): + """String representation of the guide.""" + return f'{self.name} ({self.guide_type})' + + def save(self, *args, **kwargs): + """Ensure required fields are set before saving.""" + if not self.guide_type: + raise ValueError('guide_type must be set before saving GuideDefinition') + if not self.slug: + self.slug = slugify(self.name) + + # Ensure that guide_type and slug are immutable once set + if self.pk: + old = GuideDefinition.objects.get(pk=self.pk) + if old.guide_type != self.guide_type: + raise ValueError('guide_type cannot be changed once set') + if old.slug != self.slug: + raise ValueError('slug cannot be changed once set') + + return super().save(*args, **kwargs) + + class Meta: + """Meta options for the GuideDefinition model.""" + + verbose_name = _('Guide Definition') + verbose_name_plural = _('Guide Definitions') + + +def collect_guides(create: bool = False) -> Optional[list[GuideDefinitionData]]: + """Collect all guide definitions (form types). + + Args: + create (bool): If True, create missing GuideDefinition entries in the database. + + Returns: + A list of GuideDefinitionData instances + """ + all_types = instances_of(GuideDefinitionData) + for guide in all_types: + try: + obj = GuideDefinition.objects.get(slug=guide.slug) + except GuideDefinition.DoesNotExist: + if not create: + continue # pragma: no cover + obj = GuideDefinition( + name=guide.name, + slug=guide.slug, + description=guide.description, + guide_type=guide.guide_type, + guide_data=guide.data, + ) + obj.save() + logger.info(f'Created guide definition: {obj.slug} - {obj.uid}') + except OperationalError: # pragma: no cover + return None # Database is not ready + guide.set_db(obj) + return all_types + + +class GuideExecution(InvenTree.models.MetadataMixin): + """Model that represents a user specific execution of a guide.""" + + uid = models.CharField( + max_length=255, + verbose_name=_('UID'), + help_text=_('Unique identifier for this guide execution'), + default=uuid.uuid4, + editable=False, + ) + guide = models.ForeignKey( + GuideDefinition, + on_delete=models.CASCADE, + related_name='executions', + verbose_name=_('Guide'), + help_text=_('The guide definition associated with this execution'), + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='guide_executions', + verbose_name=_('User'), + help_text=_('The user who is executing the guide'), + ) + started_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Started At'), + help_text=_('Timestamp when the guide execution started'), + ) + completed_at = models.DateTimeField( + blank=True, + null=True, + verbose_name=_('Completed At'), + help_text=_('Timestamp when the guide execution was completed'), + ) + progres_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('Progress'), + help_text=_('JSON field to track progress of the guide execution'), + ) + done = models.BooleanField( + default=False, + verbose_name=_('Done'), + help_text=_('Indicates whether the guide execution is completed'), + ) + + def __str__(self): + """String representation of the guide execution.""" + return f'{self.guide.name} for {self.user.username}' + + class Meta: + """Meta options for the GuideExecution model.""" + + verbose_name = _('Guide Execution') + verbose_name_plural = _('Guide Executions') diff --git a/src/backend/InvenTree/web/serializers.py b/src/backend/InvenTree/web/serializers.py new file mode 100644 index 000000000000..3e131869b2ae --- /dev/null +++ b/src/backend/InvenTree/web/serializers.py @@ -0,0 +1,74 @@ +"""DRF API serializers for the 'web' app.""" + +from rest_framework import serializers + +from InvenTree.serializers import ( + FilterableCharField, + FilterableJSONField, + FilterableSerializerMixin, + InvenTreeModelSerializer, + enable_filter, +) + +from .models import GuideDefinition, GuideExecution + + +class GuideDefinitionSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): + """A Guide Definition is used to describe a tipp or guide that might be available to be shown.""" + + class Meta: + """Metaclass defines serializer fields.""" + + model = GuideDefinition + fields = [ + 'uid', + 'slug', + 'guide_type', + 'name', + 'description', + 'guide_data', + 'is_applicable', + ] + ordering_fields = ['guide_type', 'slug', 'name'] + + description = enable_filter(FilterableCharField(allow_blank=True, required=False)) + guide_data = enable_filter(FilterableJSONField(required=False)) + is_applicable = serializers.SerializerMethodField() + + def get_is_applicable(self, instance: GuideDefinition) -> bool: + """Determine if this guide is applicable in the current context. + + For example, a 'First Use Tipp' might only be applicable if the user has never used the system before. + """ + import web.types as web_types + + type_def = None + if instance.guide_type == GuideDefinition.GuideType.FirstUseTipp.value: + type_def = web_types.FirstUseTipp + elif instance.guide_type == GuideDefinition.GuideType.Tipp.value: + type_def = web_types.Tipp + else: + raise NotImplementedError( + f'Guide type `{instance.guide_type}` not implemented' + ) # pragma: no cover + + # Cast definition and check applicability + type_instance = type_def(**instance.guide_data) + user = self.context['request'].user + # Ensure user is authenticated, checks do not make sense otherwise + if not user.is_authenticated: + return False # pragma: no cover + executions = GuideExecution.objects.filter( + user=user, guide__guide_type=instance.guide_type + ) + return type_instance.is_applicable(user, instance, executions) + + +class EmptySerializer(serializers.Serializer): + """An empty serializer, useful for endpoints that do not require any input or output.""" + + class Meta: + """Metaclass defines serializer fields.""" + + fields = [] + model = GuideDefinition diff --git a/src/backend/InvenTree/web/tests.py b/src/backend/InvenTree/web/tests.py index bed9ed43f670..c0f5e1899e64 100644 --- a/src/backend/InvenTree/web/tests.py +++ b/src/backend/InvenTree/web/tests.py @@ -5,8 +5,11 @@ from pathlib import Path from unittest import mock +from django.urls import reverse + from InvenTree.config import get_frontend_settings -from InvenTree.unit_test import InvenTreeTestCase +from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase +from web.models import GuideDefinition, GuideExecution, collect_guides from .templatetags import spa_helper @@ -86,3 +89,105 @@ def test_get_frontend_settings(self): rsp = get_frontend_settings(False) self.assertNotIn('show_server_selector', rsp) self.assertEqual(rsp['server_list'], ['aa', 'bb']) + + +class GuidesTest(InvenTreeTestCase): + """Tests for guide functionality.""" + + def test_collection(self): + """Test guide collection applicability.""" + self.assertEqual(GuideDefinition.objects.count(), 0) + + rsp = collect_guides(create=True) + self.assertEqual(len(rsp), NummerOfGuides) + self.assertEqual(GuideDefinition.objects.count(), NummerOfGuides) + + # Model base test + guide = GuideDefinition.objects.first() + self.assertEqual(guide.slug, 'ftue_admin_threat_model') + self.assertEqual( + str(guide), 'First Use Admin Welcome - Threat Model (firstuse)' + ) + + def test_model_fences(self): + """Test GuideExecution model string representation.""" + # No guide definition -> error + with self.assertRaises(ValueError): + GuideDefinition.objects.create(slug='test_slug', name='Test Guide') + + # Auto slug + guide = GuideDefinition.objects.create( + name='Test Guide 2', guide_type='test_type' + ) + self.assertEqual(guide.slug, 'test-guide-2') + + # Changing immutable fields + with self.assertRaises(ValueError): + guide.guide_type = 'new_type' + guide.save() + guide.refresh_from_db() + + with self.assertRaises(ValueError): + guide.slug = 'new-slug' + guide.save() + + # Check str method + guide.refresh_from_db() + self.assertEqual(str(guide), 'Test Guide 2 (test_type)') + + +NummerOfGuides = 2 + + +class WebAPITests(InvenTreeAPITestCase): + """Tests for web API endpoints.""" + + def test_guide_api(self): + """Tests for User API endpoints.""" + url = reverse('api-guide-list') + rsp = collect_guides(create=True) + self.assertEqual(len(rsp), NummerOfGuides) + + response = self.get(url, expected_code=200) + + # Check the correct number of results was returned + self.assertEqual(len(response.data), NummerOfGuides) + + # filter for all + response = self.get(f'{url}?all=true', expected_code=200) + self.assertEqual(len(response.data), NummerOfGuides) + + def test_guide_dismissal(self): + """Test guide dismissal endpoint.""" + rsp = collect_guides(create=True) + self.assertEqual(len(rsp), NummerOfGuides) + + response = self.get(reverse('api-guide-list'), expected_code=200) + self.assertEqual(len(response.data), NummerOfGuides) + self.assertTrue(response.data[0]['is_applicable']) + + # Wrong slug + self.patch( + reverse('api-guide-dismiss', kwargs={'slug': '123non'}), expected_code=404 + ) + + # Dismiss correct guide + self.patch( + reverse('api-guide-dismiss', kwargs={'slug': 'ftue_admin_threat_model'}), + expected_code=200, + ) + response = self.get(reverse('api-guide-list'), expected_code=200) + self.assertEqual(len(response.data), NummerOfGuides) + self.assertFalse(response.data[0]['is_applicable']) + self.assertEqual( + str(GuideExecution.objects.first()), + 'First Use Admin Welcome - Threat Model for testuser', + ) + + # Dismissing again should have no effect + self.patch( + reverse('api-guide-dismiss', kwargs={'slug': 'admin_center_1'}), + expected_code=200, + ) + response = self.get(reverse('api-guide-list'), expected_code=200) + self.assertEqual(len(response.data), NummerOfGuides) diff --git a/src/backend/InvenTree/web/types.py b/src/backend/InvenTree/web/types.py new file mode 100644 index 000000000000..3884ea3ac7d7 --- /dev/null +++ b/src/backend/InvenTree/web/types.py @@ -0,0 +1,165 @@ +"""Data structures for guides and tipps in InvenTree.""" + +import dataclasses +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional + +from django.db.models import QuerySet +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy as _ + +if TYPE_CHECKING: + from django.contrib.auth.models import User + + from web.models import GuideDefinition, GuideExecution + + +@dataclass +class GeneralInfo: + """General information structure for guides and tipps. + + Attributes: + title (str): The title of the guide or tipp. + detail_text (str): Detailed text content. + links (list[tuple[str, str]]): List of (label, URL) tuples for related links. + discoverable (bool): Whether this info is discoverable in the user interface / API for non admins. + permission_cls (Optional[object]): Permission class for access control. + """ + + title: str + detail_text: str + links: Optional[list[tuple[str, str]]] = None + discoverable: bool = True + permission_cls: Optional[object] = None + + def is_applicable( + self, + user: 'User', + instance: 'GuideDefinition', + executions: QuerySet['GuideExecution', 'GuideExecution'], + ) -> bool: + """Determine if this guide is applicable to the given user. + + This is a base method and should be overridden by subclasses. + + Args: + user (User): The user to check applicability for. + instance (GuideDefinition): The guide definition instance. + executions (QuerySet[GuideExecution]): QuerySet of guide executions for the user. + + Returns: + bool: True if applicable, False otherwise. + """ + return True # pragma: no cover + + +@dataclass +class Tipp(GeneralInfo): + """Simple tipp.""" + + def is_applicable(self, user, instance, executions) -> bool: + """Determine if this tipp is applicable to the given user.""" + # Tipps are always applicable if not a "done" execution is recorded + return not executions.filter(done=True).exists() + + +@dataclass +class FirstUseTipp(GeneralInfo): + """Tipp - shown only once.""" + + per_user: bool = True + """Only show once per user. If False, show once per installation.""" + show_to_admins: bool = False + """Whether to show this tipp to all admin users.""" + + def is_applicable(self, user, instance, executions) -> bool: + """Determine if this tipp is applicable to the given user.""" + from web.models import GuideExecution + + # Not a single positive "done" execution recorded + return not GuideExecution.objects.filter( + guide__guide_type=instance.guide_type, done=True + ).exists() + + +@dataclass +class GuideDefinitionData: + """Data structure for guide definition data. + + Initiate a object of this class to define a guide definition. This will be discovered during startup, registered and made concrete in the database. + + Attributes: + name (str): The name of the guide. + slug (str): The URL-friendly unique identifier for the guide. + description (str): Optional description of the guide. + setup (Union[Tipp, FirstUseTipp, Guide]): The functional data for the guide, which can be any of the defined types. + """ + + name: str + slug: str + setup: Tipp | FirstUseTipp + description: str = '' + + @property + def guide_type(self) -> str: + """Determine the guide type based on the setup instance.""" + if isinstance(self.setup, Tipp): + return 'tipp' + elif isinstance(self.setup, FirstUseTipp): + return 'firstuse' + else: + raise ValueError( + 'Invalid setup type for GuideDefinitionData' + ) # pragma: no cover + + @property + def data(self) -> dict[str, Any]: + """Return the data as a dictionary suitable for JSONField storage.""" + _data = dataclasses.asdict(self.setup) + # Transform proxy objects into json friendly data + for key, value in _data.items(): + if isinstance(value, Promise): + _data[key] = str(value) + # Iterate over links and ensure they are tuples + if 'links' in _data and _data['links'] is not None: + _data['links'] = [(str(label), url) for label, url in _data['links']] + return _data + + def set_db(self, obj): + """Set the linked database object.""" + self._db_obj = obj + + +# Real guides +guides = [ + GuideDefinitionData( + name='Admin Center Information Release Info', + slug='admin_center_1', + setup=Tipp( + title=_('Admin Center Information'), + detail_text=_( + 'The home panel (and the whole Admin Center) is a new feature starting with the new UI and was previously (before 1.0) not available.\n' + 'The admin center provides a centralized location for all administration functionality and is meant to replace all interaction with the (django) backend admin interface.\n' + 'Please open feature requests (after checking the tracker) for any existing backend admin functionality you are missing in this UI. The backend admin interface should be used carefully and seldom.' + ), + ), + ), + GuideDefinitionData( + name='First Use Admin Welcome - Threat Model', + slug='ftue_admin_threat_model', + setup=FirstUseTipp( + title=_('Welcome to InvenTree!'), + detail_text=_( + 'Welcome to InvenTree! As an administrator, it is important to understand the security implications of your installation.\n\n' + 'InvenTree has been designed with security in mind, but it is crucial to follow best practices to protect your data and users.\n\n' + 'Please take a moment to review the threat model and ensure that your installation is secure.' + ), + links=[ + ( + _('InvenTree Threat Model'), + 'https://docs.inventree.org/en/stable/concepts/threat_model/', + ) + ], + ), + ), +] diff --git a/src/frontend/lib/components/Tipp.tsx b/src/frontend/lib/components/Tipp.tsx new file mode 100644 index 000000000000..d0fbf3c89f52 --- /dev/null +++ b/src/frontend/lib/components/Tipp.tsx @@ -0,0 +1,53 @@ +import type { TippData } from '@lib/types/Core'; +import { Alert, Button, Group, Stack, Text } from '@mantine/core'; +import { IconExternalLink } from '@tabler/icons-react'; +import { type JSX, useMemo, useState } from 'react'; +import { useGuideState } from '../../src/states/GuideState'; + +export function Tipp({ id }: Readonly<{ id: string }>): JSX.Element { + const { getGuideBySlug, closeGuide } = useGuideState(); + const [val, setVal] = useState(0); // Force re-render on close + const tip = useMemo(() => getGuideBySlug(id), [id, val]); + + if (!tip) { + return <>; + } + const tip_data: TippData = tip.guide_data; + if (tip.is_applicable === false) { + return <>; + } + + return ( + { + await closeGuide(id); + setVal(val + 1); + }} + > + + {tip_data.detail_text} + {tip_data.links && tip_data.links.length > 0 && ( + + {tip_data.links.map((link, index) => ( + + ))} + + )} + + + ); +} diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index e7adf3f0b8d0..b5e2fc28874a 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -65,6 +65,8 @@ export enum ApiEndpoints { icons = 'icons/', selectionlist_list = 'selection/', selectionlist_detail = 'selection/:id/', + guides_list = 'web/guides/', + guide_dismiss = 'web/guides/:id/dismiss/', // Barcode API endpoints barcode = 'barcode/', diff --git a/src/frontend/lib/types/Core.tsx b/src/frontend/lib/types/Core.tsx index edfc60c64b59..863ef5ba3fc9 100644 --- a/src/frontend/lib/types/Core.tsx +++ b/src/frontend/lib/types/Core.tsx @@ -11,3 +11,10 @@ export interface UserTheme { } export type PathParams = Record; + +export type TippData = { + title: string; + color: string; + detail_text: string; + links?: [string, string][]; +}; diff --git a/src/frontend/src/components/settings/QuickAction.tsx b/src/frontend/src/components/settings/QuickAction.tsx index 40ea4b71321b..ad31d1492bdc 100644 --- a/src/frontend/src/components/settings/QuickAction.tsx +++ b/src/frontend/src/components/settings/QuickAction.tsx @@ -29,7 +29,7 @@ interface ActionItem { function ActionGrid({ items }: { items: ActionItem[] }) { const slides = items.map((image) => ( - + diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx index 8a0d202166e2..3c60ab14df33 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/HomePanel.tsx @@ -1,7 +1,8 @@ +import { Tipp } from '@lib/components/Tipp'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { Accordion, Alert, SimpleGrid, Stack, Text } from '@mantine/core'; -import { type JSX, useMemo, useState } from 'react'; +import { Accordion, SimpleGrid, Stack } from '@mantine/core'; +import { type JSX, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { StylishText } from '../../../../components/items/StylishText'; import { @@ -21,7 +22,6 @@ function rankAlert(alert: ExtendedAlertInfo): number { } export default function HomePanel(): JSX.Element { - const [dismissed, setDismissed] = useState(false); const [server] = useServerApiState(useShallow((state) => [state.server])); const globalSettings = useGlobalSettingsState(); @@ -41,39 +41,8 @@ export default function HomePanel(): JSX.Element { return ( - {dismissed ? null : ( - setDismissed(true)} - > - - - - The home panel (and the whole Admin Center) is a new feature - starting with the new UI and was previously (before 1.0) not - available. - - - - - The admin center provides a centralized location for all - administration functionality and is meant to replace all - interaction with the (django) backend admin interface. - - - - - Please open feature requests (after checking the tracker) for - any existing backend admin functionality you are missing in this - UI. The backend admin interface should be used carefully and - seldom. - - - - - )} + + ; + fetchGuides: () => Promise; + getGuideBySlug: (slug: string) => any | undefined; + closeGuide: (slug: string) => void; +}; + +export const useGuideState = create()((set, get) => ({ + hasLoaded: false, + guidesMap: {}, + fetchGuides: async () => { + if (get().hasLoaded) return; + const data = await api + .get(`${apiUrl(ApiEndpoints.guides_list)}?guide_data=true`) + .catch((_error) => { + console.error('Could not fetch guides'); + }) + .then((response) => response); + if (!data) return; + + const guidesDictionary = Object.fromEntries( + data?.data.map((itm: any) => [itm.slug, itm]) + ); + set({ + hasLoaded: true, + guidesMap: guidesDictionary + }); + }, + getGuideBySlug: (slug: string) => { + get().fetchGuides(); + return get().guidesMap[slug]; + }, + closeGuide: async (slug) => { + const guides = get().guidesMap; + await api + .put(apiUrl(ApiEndpoints.guide_dismiss, slug)) + .then(() => {}) + .catch((err) => { + console.error(`Could not dismiss guide ${slug} with error: ${err}`); + }); + + delete guides[slug]; + set({ guidesMap: { ...guides } }); + } +})); diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 4e9a1cfe338a..fa8bf021beea 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -1,6 +1,7 @@ import type { PluginProps } from '@lib/types/Plugins'; import { removeTraceId, setApiDefaults, setTraceId } from '../App'; import { useGlobalStatusState } from './GlobalStatusState'; +import { useGuideState } from './GuideState'; import { useIconState } from './IconState'; import { useServerApiState } from './ServerApiState'; import { useGlobalSettingsState, useUserSettingsState } from './SettingsStates'; @@ -59,7 +60,8 @@ export async function fetchGlobalStates() { useUserSettingsState.getState().fetchSettings(), useGlobalSettingsState.getState().fetchSettings(), useGlobalStatusState.getState().fetchStatus(), - useIconState.getState().fetchIcons() + useIconState.getState().fetchIcons(), + useGuideState.getState().fetchGuides() ]); removeTraceId(traceId); }