mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
15ab911da6
commit
9f56ee1023
@ -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)
|
||||||
|
@ -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 = [
|
||||||
|
@ -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
|
||||||
|
@ -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."""
|
||||||
|
@ -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'
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user