From 9f56ee1023ce25d7e18b9a641a749f5ed8e53953 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Jun 2023 05:13:53 +0200 Subject: [PATCH] [FR] Add Feature flags (#4982) * make currency choices independend * Remove check for field, just try to get rid of it * Add IF EXISTS to avoid error (works in postgres) * Look for operational error, not programming error * Use variants, depending on errors caused * [FR] Add Feature flags Fixes #4965 * Add option to define custom flags * Revert "make currency choices independend" This reverts commit ab84a7ff830bfdf8df2235ec11f5a83e05533a10. * try fixing mysql * more safeguards * fix executioner call * a fck * use migrations. syntax * and another round for mysql * revert print change * use UTC for datetime * Update part.migrations.0112 - Add custom migration class which handles errors * Add unit test for migration - Ensure that the new fields are added to the model * Update reference to PR * fix ruleset for missing_models * fix ruleset for flags_flagstate * add API endpoints for flags * add tests for new API endpoints * fix tests * fix merge * fix tests --------- Co-authored-by: martin Co-authored-by: Oliver Walters --- InvenTree/InvenTree/settings.py | 21 +++++++++++++++++++ InvenTree/common/api.py | 29 ++++++++++++++++++++++++++ InvenTree/common/serializers.py | 18 ++++++++++++++++ InvenTree/common/tests.py | 37 +++++++++++++++++++++++++++++++++ InvenTree/config_template.yaml | 8 +++++++ InvenTree/users/models.py | 1 + requirements.in | 1 + requirements.txt | 3 +++ 8 files changed, 118 insertions(+) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 7ea7eb06af..fc30387d6a 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -234,6 +234,7 @@ INSTALLED_APPS = [ 'formtools', # Form wizard tools 'dbbackup', # Backups - django-dbbackup 'taggit', # Tagging + 'flags', # Flagging - django-flags 'allauth', # Base app for SSO 'allauth.account', # Extend user with accounts @@ -944,3 +945,23 @@ if DEBUG: logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") + +# Flags +FLAGS = { + 'EXPERIMENTAL': [ + {'condition': 'boolean', 'value': DEBUG}, + {'condition': 'parameter', 'value': 'experimental='}, + ], # Should experimental features be turned on? + 'NEXT_GEN': [ + {'condition': 'parameter', 'value': 'ngen='}, + ], # Should next-gen features be turned on? +} + +# Get custom flags from environment/yaml +CUSTOM_FLAGS = get_setting('INVENTREE_FLAGS', 'flags', None, typecast=dict) +if CUSTOM_FLAGS: + if not isinstance(CUSTOM_FLAGS, dict): + logger.error(f"Invalid custom flags, must be valid dict: {CUSTOM_FLAGS}") + else: + logger.info(f"Custom flags: {CUSTOM_FLAGS}") + FLAGS.update(CUSTOM_FLAGS) diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 6f67af2dd7..1de77c4245 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -2,6 +2,7 @@ import json +from django.conf import settings from django.http.response import HttpResponse from django.urls import include, path, re_path from django.utils.decorators import method_decorator @@ -480,6 +481,29 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI): permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] +class FlagList(ListAPI): + """List view for feature flags.""" + + queryset = settings.FLAGS + serializer_class = common.serializers.FlagSerializer + permission_classes = [permissions.AllowAny, ] + + +class FlagDetail(RetrieveAPI): + """Detail view for an individual feature flag.""" + + serializer_class = common.serializers.FlagSerializer + permission_classes = [permissions.AllowAny, ] + + def get_object(self): + """Attempt to find a config object with the provided key.""" + key = self.kwargs['key'] + value = settings.FLAGS.get(key, None) + if not value: + raise NotFound() + return {key: value} + + settings_api_urls = [ # User settings re_path(r'^user/', include([ @@ -552,6 +576,11 @@ common_api_urls = [ re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), ])), + # Flags + path('flags/', include([ + path('/', FlagDetail.as_view(), name='api-flag-detail'), + re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'), + ])), ] admin_api_urls = [ diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index c74dc76fd4..8dc911f506 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,7 +1,9 @@ """JSON serializers for common components.""" + from django.urls import reverse +from flags.state import flag_state from rest_framework import serializers from common.models import (InvenTreeSetting, InvenTreeUserSetting, @@ -269,3 +271,19 @@ class ProjectCodeSerializer(InvenTreeModelSerializer): 'code', 'description' ] + + +class FlagSerializer(serializers.Serializer): + """Serializer for feature flags.""" + + def to_representation(self, instance): + """Return the configuration data as a dictionary.""" + request = self.context.get('request') + if not isinstance(instance, str): + instance = list(instance.keys())[0] + data = {'key': instance, 'state': flag_state(instance, request=request)} + + if request and request.user.is_superuser: + data['conditions'] = self.instance[instance] + + return data diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 6e33544f22..f854b63514 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -875,6 +875,43 @@ class CommonTest(InvenTreeAPITestCase): self.user.is_superuser = False self.user.save() + def test_flag_api(self): + """Test flag URLs.""" + # Not superuser + response = self.get(reverse('api-flag-list'), expected_code=200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL') + + # Turn into superuser + self.user.is_superuser = True + self.user.save() + + # Successful checks + response = self.get(reverse('api-flag-list'), expected_code=200) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL') + self.assertTrue(response.data[0]['conditions']) + + response = self.get(reverse('api-flag-detail', kwargs={'key': 'EXPERIMENTAL'}), expected_code=200) + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data['key'], 'EXPERIMENTAL') + self.assertTrue(response.data['conditions']) + + # Try without param -> false + response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), expected_code=200) + self.assertFalse(response.data['state']) + + # Try with param -> true + response = self.get(reverse('api-flag-detail', kwargs={'key': 'NEXT_GEN'}), {'ngen': ''}, expected_code=200) + self.assertTrue(response.data['state']) + + # Try non existent flag + response = self.get(reverse('api-flag-detail', kwargs={'key': 'NON_EXISTENT'}), expected_code=404) + + # Turn into normal user again + self.user.is_superuser = False + self.user.save() + class ColorThemeTest(TestCase): """Tests for ColorTheme.""" diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 284e672693..7795bb694f 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -228,3 +228,11 @@ remote_login_header: HTTP_REMOTE_USER # splash: splash_screen.jpg # hide_admin_link: true # hide_password_reset: true + +# Custom flags +# InvenTree uses django-flags; read more in their docs at https://cfpb.github.io/django-flags/conditions/ +# Use environment variable INVENTREE_FLAGS or the settings below +# flags: +# MY_FLAG: +# - condition: 'parameter' +# value: 'my_flag_param1' diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index a16e261c35..df5efc8d08 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -81,6 +81,7 @@ class RuleSet(models.Model): 'common_newsfeedentry', 'taggit_tag', 'taggit_taggeditem', + 'flags_flagstate', ], 'part_category': [ 'part_partcategory', diff --git a/requirements.in b/requirements.in index cd5b876d87..a1a6525479 100644 --- a/requirements.in +++ b/requirements.in @@ -10,6 +10,7 @@ django-crispy-forms<2.0 # Form helpers # FIXED 2023-02-18 due to django-dbbackup # Backup / restore of database and media files django-error-report # Error report viewer for the admin interface django-filter # Extended filtering options +django-flags # Feature flags django-formtools # Form wizard tools django-ical # iCal export for calendar views django-import-export==2.5.0 # Data import / export for admin interface diff --git a/requirements.txt b/requirements.txt index e7eebeac0d..c1d35125d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,7 @@ django==3.2.19 # django-dbbackup # django-error-report # django-filter + # django-flags # django-formtools # django-ical # django-import-export @@ -92,6 +93,8 @@ django-error-report==0.2.0 # via -r requirements.in django-filter==23.2 # via -r requirements.in +django-flags==5.0.12 + # via -r requirements.in django-formtools==2.4.1 # via -r requirements.in django-ical==1.9.1