[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 ab84a7ff83.

* 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 <martin@iggland.com>
Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair 2023-06-12 05:13:53 +02:00 committed by GitHub
parent 15ab911da6
commit 9f56ee1023
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 118 additions and 0 deletions

View File

@ -234,6 +234,7 @@ INSTALLED_APPS = [
'formtools', # Form wizard tools 'formtools', # Form wizard tools
'dbbackup', # Backups - django-dbbackup 'dbbackup', # Backups - django-dbbackup
'taggit', # Tagging 'taggit', # Tagging
'flags', # Flagging - django-flags
'allauth', # Base app for SSO 'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts 'allauth.account', # Extend user with accounts
@ -944,3 +945,23 @@ if DEBUG:
logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'")
logger.info(f"STATIC_ROOT: '{STATIC_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)

View File

@ -2,6 +2,7 @@
import json import json
from django.conf import settings
from django.http.response import HttpResponse from django.http.response import HttpResponse
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -480,6 +481,29 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] 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 = [ settings_api_urls = [
# User settings # User settings
re_path(r'^user/', include([ re_path(r'^user/', include([
@ -552,6 +576,11 @@ common_api_urls = [
re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'), re_path(r'^.*$', NewsFeedEntryList.as_view(), name='api-news-list'),
])), ])),
# Flags
path('flags/', include([
path('<str:key>/', FlagDetail.as_view(), name='api-flag-detail'),
re_path(r'^.*$', FlagList.as_view(), name='api-flag-list'),
])),
] ]
admin_api_urls = [ admin_api_urls = [

View File

@ -1,7 +1,9 @@
"""JSON serializers for common components.""" """JSON serializers for common components."""
from django.urls import reverse from django.urls import reverse
from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
from common.models import (InvenTreeSetting, InvenTreeUserSetting, from common.models import (InvenTreeSetting, InvenTreeUserSetting,
@ -269,3 +271,19 @@ class ProjectCodeSerializer(InvenTreeModelSerializer):
'code', 'code',
'description' '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

View File

@ -875,6 +875,43 @@ class CommonTest(InvenTreeAPITestCase):
self.user.is_superuser = False self.user.is_superuser = False
self.user.save() 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): class ColorThemeTest(TestCase):
"""Tests for ColorTheme.""" """Tests for ColorTheme."""

View File

@ -228,3 +228,11 @@ remote_login_header: HTTP_REMOTE_USER
# splash: splash_screen.jpg # splash: splash_screen.jpg
# hide_admin_link: true # hide_admin_link: true
# hide_password_reset: 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'

View File

@ -81,6 +81,7 @@ class RuleSet(models.Model):
'common_newsfeedentry', 'common_newsfeedentry',
'taggit_tag', 'taggit_tag',
'taggit_taggeditem', 'taggit_taggeditem',
'flags_flagstate',
], ],
'part_category': [ 'part_category': [
'part_partcategory', 'part_partcategory',

View File

@ -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-dbbackup # Backup / restore of database and media files
django-error-report # Error report viewer for the admin interface django-error-report # Error report viewer for the admin interface
django-filter # Extended filtering options django-filter # Extended filtering options
django-flags # Feature flags
django-formtools # Form wizard tools django-formtools # Form wizard tools
django-ical # iCal export for calendar views django-ical # iCal export for calendar views
django-import-export==2.5.0 # Data import / export for admin interface django-import-export==2.5.0 # Data import / export for admin interface

View File

@ -53,6 +53,7 @@ django==3.2.19
# django-dbbackup # django-dbbackup
# django-error-report # django-error-report
# django-filter # django-filter
# django-flags
# django-formtools # django-formtools
# django-ical # django-ical
# django-import-export # django-import-export
@ -92,6 +93,8 @@ django-error-report==0.2.0
# via -r requirements.in # via -r requirements.in
django-filter==23.2 django-filter==23.2
# via -r requirements.in # via -r requirements.in
django-flags==5.0.12
# via -r requirements.in
django-formtools==2.4.1 django-formtools==2.4.1
# via -r requirements.in # via -r requirements.in
django-ical==1.9.1 django-ical==1.9.1