From 7e89e93252d9941cf130b54fc9b095c8345874a0 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Oct 2026 11:44:58 +0200 Subject: [PATCH] feat(backend): mark setting when changed via API (#12982) * mark setting when changed via API might be helpful for https://github.com/inventree/InvenTree/issues/12767 * add migration * fix migration * use atomic instead * move atomic * move the transaction safety to be migration specific * reduce diff * make write safe * remove migration_safe_query again * use a simpler approach in the squashed migration * try using a manager * add update / updated_by to api * update generic settings api to handle updated_by field --- src/backend/InvenTree/common/api.py | 13 +++++- ..._squashed_0039_emailthread_emailmessage.py | 26 +++++------ ...uched_inventreesetting_updated_and_more.py | 46 +++++++++++++++++++ src/backend/InvenTree/common/models.py | 18 +++++++- src/backend/InvenTree/common/serializers.py | 9 ++++ src/backend/InvenTree/machine/api.py | 3 +- ...touched_machinesetting_updated_and_more.py | 31 +++++++++++++ ..._touched_pluginsetting_updated_and_more.py | 46 +++++++++++++++++++ 8 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0053_inventreesetting_touched_inventreesetting_updated_and_more.py create mode 100644 src/backend/InvenTree/machine/migrations/0003_machinesetting_touched_machinesetting_updated_and_more.py create mode 100644 src/backend/InvenTree/plugin/migrations/0012_pluginsetting_touched_pluginsetting_updated_and_more.py diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 2922905718..05827fdc9e 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -269,7 +269,16 @@ class GlobalSettingsList(SettingsList): return super().list(request, *args, **kwargs) -class GlobalSettingsDetail(RetrieveUpdateAPI): +class TouchMixin: + """Mark a setting as touched when it is modified by the user.""" + + def perform_update(self, serializer): + """Mark a setting as touched when it is modified by the user.""" + serializer.instance.touched = True + super().perform_update(serializer) + + +class GlobalSettingsDetail(TouchMixin, RetrieveUpdateAPI): """Detail view for an individual "global setting" object. - User must have 'staff' status to view / edit @@ -328,7 +337,7 @@ class UserSettingsList(SettingsList): return queryset -class UserSettingsDetail(RetrieveUpdateAPI): +class UserSettingsDetail(TouchMixin, RetrieveUpdateAPI): """Detail view for an individual "user setting" object. - User can only view / edit settings their own settings objects diff --git a/src/backend/InvenTree/common/migrations/0008_remove_inventreesetting_description_squashed_0039_emailthread_emailmessage.py b/src/backend/InvenTree/common/migrations/0008_remove_inventreesetting_description_squashed_0039_emailthread_emailmessage.py index 0ddef8b015..5dc2d7e1f8 100644 --- a/src/backend/InvenTree/common/migrations/0008_remove_inventreesetting_description_squashed_0039_emailthread_emailmessage.py +++ b/src/backend/InvenTree/common/migrations/0008_remove_inventreesetting_description_squashed_0039_emailthread_emailmessage.py @@ -9,25 +9,25 @@ import django.db.models.deletion import taggit.managers import uuid from django.conf import settings -from django.db import migrations, models +from django.db import migrations, models, transaction from moneyed import CURRENCIES def set_default_currency(apps, schema_editor): """Migrate the currency setting from config.yml to db.""" - from common.currency import currency_codes - from common.models import InvenTreeSetting + InvenTreeSetting = apps.get_model('common', 'InvenTreeSetting') + key = 'INVENTREE_DEFAULT_CURRENCY' + base_currency = InvenTree.config.get_setting('INVENTREE_BASE_CURRENCY', 'base_currency', 'USD') - base_currency = InvenTree.config.get_setting( - 'INVENTREE_BASE_CURRENCY', 'base_currency', 'USD' - ) - - if base_currency not in currency_codes(): - base_currency = currency_codes()[0] if currency_codes() else 'USD' - - InvenTreeSetting.set_setting( - 'INVENTREE_DEFAULT_CURRENCY', base_currency, None, create=True - ) + if base_currency not in CURRENCIES: + base_currency = 'USD' + with transaction.atomic(): + setting = InvenTreeSetting.objects.filter(key=key).first() + if setting: + setting.value = base_currency + setting.save() + else: + InvenTreeSetting(key=key, value=base_currency).save() def set_currencies(apps, schema_editor): diff --git a/src/backend/InvenTree/common/migrations/0053_inventreesetting_touched_inventreesetting_updated_and_more.py b/src/backend/InvenTree/common/migrations/0053_inventreesetting_touched_inventreesetting_updated_and_more.py new file mode 100644 index 0000000000..7cf7aa1531 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0053_inventreesetting_touched_inventreesetting_updated_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.17 on 2026-10-12 22:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0052_remove_notesimage_model_id_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='inventreesetting', + name='touched', + field=models.BooleanField(default=False, help_text='Indicates whether the setting has been modified by the user'), + ), + migrations.AddField( + model_name='inventreesetting', + name='updated', + field=models.DateTimeField(blank=True, default=None, help_text='Timestamp of last update', null=True, verbose_name='Updated'), + ), + migrations.AddField( + model_name='inventreesetting', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='User who last updated this object', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated', to=settings.AUTH_USER_MODEL, verbose_name='Update By'), + ), + migrations.AddField( + model_name='inventreeusersetting', + name='touched', + field=models.BooleanField(default=False, help_text='Indicates whether the setting has been modified by the user'), + ), + migrations.AddField( + model_name='inventreeusersetting', + name='updated', + field=models.DateTimeField(blank=True, default=None, help_text='Timestamp of last update', null=True, verbose_name='Updated'), + ), + migrations.AddField( + model_name='inventreeusersetting', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='User who last updated this object', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated', to=settings.AUTH_USER_MODEL, verbose_name='Update By'), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index d4b48cdaeb..9784361e5f 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -205,7 +205,15 @@ class ProjectCode(InvenTree.models.InvenTreeMetadataModel): ) -class BaseInvenTreeSetting(models.Model): +class SettingsManager(models.Manager): + """Ensure that the 'touched' field is deferred in all queries.""" + + def get_queryset(self): + """Defer the write-only 'touched' field.""" + return super().get_queryset().defer('touched', 'updated_by', 'updated') + + +class BaseInvenTreeSetting(UpdatedUserMixin, models.Model): """An base InvenTreeSetting object is a key:value pair used for storing single values (e.g. one-off settings values). Attributes: @@ -217,6 +225,8 @@ class BaseInvenTreeSetting(models.Model): CHECK_SETTING_KEY = False + objects = SettingsManager() + extra_unique_fields: list[str] = [] class Meta: @@ -614,7 +624,6 @@ class BaseInvenTreeSetting(models.Model): access_global_cache = False # At this point, we need to query the database - filters = { 'key__iexact': key, # Optionally filter by other keys @@ -806,6 +815,11 @@ class BaseInvenTreeSetting(models.Model): max_length=2000, blank=True, unique=False, help_text=_('Settings value') ) + touched = models.BooleanField( + default=False, + help_text=_('Indicates whether the setting has been modified by the user'), + ) + @property def name(self): """Return name for setting.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 34ddbe9ff5..4d198dc311 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -152,6 +152,15 @@ class SettingsSerializer(InvenTreeModelSerializer): }) return ret + def save(self, **kwargs): + """Save the setting, adding the updated_by field.""" + request = self.context.get('request', None) + if request is not None and hasattr(request, 'user'): + kwargs['updated_by'] = request.user + elif 'updated_by' in kwargs: + kwargs.pop('updated_by') + return super().save(**kwargs) + class GlobalSettingsSerializer(SettingsSerializer): """Serializer for the InvenTreeSetting model.""" diff --git a/src/backend/InvenTree/machine/api.py b/src/backend/InvenTree/machine/api.py index f99d8552ab..48c8d6bf6d 100644 --- a/src/backend/InvenTree/machine/api.py +++ b/src/backend/InvenTree/machine/api.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView import InvenTree.permissions import machine.serializers as MachineSerializers +from common.api import TouchMixin from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI from machine import registry @@ -108,7 +109,7 @@ class MachineSettingList(APIView): return Response(results) -class MachineSettingDetail(RetrieveUpdateAPI): +class MachineSettingDetail(TouchMixin, RetrieveUpdateAPI): """Detail endpoint for a machine-specific setting. - GET: Get machine setting detail diff --git a/src/backend/InvenTree/machine/migrations/0003_machinesetting_touched_machinesetting_updated_and_more.py b/src/backend/InvenTree/machine/migrations/0003_machinesetting_touched_machinesetting_updated_and_more.py new file mode 100644 index 0000000000..05527f72f4 --- /dev/null +++ b/src/backend/InvenTree/machine/migrations/0003_machinesetting_touched_machinesetting_updated_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-10-12 22:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('machine', '0002_alter_machineconfig_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='machinesetting', + name='touched', + field=models.BooleanField(default=False, help_text='Indicates whether the setting has been modified by the user'), + ), + migrations.AddField( + model_name='machinesetting', + name='updated', + field=models.DateTimeField(blank=True, default=None, help_text='Timestamp of last update', null=True, verbose_name='Updated'), + ), + migrations.AddField( + model_name='machinesetting', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='User who last updated this object', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated', to=settings.AUTH_USER_MODEL, verbose_name='Update By'), + ), + ] diff --git a/src/backend/InvenTree/plugin/migrations/0012_pluginsetting_touched_pluginsetting_updated_and_more.py b/src/backend/InvenTree/plugin/migrations/0012_pluginsetting_touched_pluginsetting_updated_and_more.py new file mode 100644 index 0000000000..238c56dfe8 --- /dev/null +++ b/src/backend/InvenTree/plugin/migrations/0012_pluginsetting_touched_pluginsetting_updated_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.17 on 2026-10-12 22:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugin', '0011_delete_notificationusersetting'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='pluginsetting', + name='touched', + field=models.BooleanField(default=False, help_text='Indicates whether the setting has been modified by the user'), + ), + migrations.AddField( + model_name='pluginsetting', + name='updated', + field=models.DateTimeField(blank=True, default=None, help_text='Timestamp of last update', null=True, verbose_name='Updated'), + ), + migrations.AddField( + model_name='pluginsetting', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='User who last updated this object', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated', to=settings.AUTH_USER_MODEL, verbose_name='Update By'), + ), + migrations.AddField( + model_name='pluginusersetting', + name='touched', + field=models.BooleanField(default=False, help_text='Indicates whether the setting has been modified by the user'), + ), + migrations.AddField( + model_name='pluginusersetting', + name='updated', + field=models.DateTimeField(blank=True, default=None, help_text='Timestamp of last update', null=True, verbose_name='Updated'), + ), + migrations.AddField( + model_name='pluginusersetting', + name='updated_by', + field=models.ForeignKey(blank=True, help_text='User who last updated this object', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated', to=settings.AUTH_USER_MODEL, verbose_name='Update By'), + ), + ]