Skip to content

Commit c576d9c

Browse files
authored
Merge pull request #5590 from raft-tech/5535-feature-flag-model
Feature Flag Model and Admin
2 parents 921d3c8 + b785d2c commit c576d9c

File tree

6 files changed

+379
-3
lines changed

6 files changed

+379
-3
lines changed

tdrs-backend/tdpservice/core/admin.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
"""Admin class for LogEntry objects."""
1+
"""Admin classes for core app models."""
22
from django.contrib import admin
33
from django.contrib.admin.models import LogEntry
4+
from django.forms import ModelForm
45
from django.urls import reverse
56
from django.utils.html import escape
67
from django.utils.safestring import mark_safe
8+
from django_json_widget.widgets import JSONEditorWidget
79

10+
from tdpservice.core.models import FeatureFlag
811
from tdpservice.core.utils import ReadOnlyAdminMixin
912

1013
# LogEntry needs to be de-registered first before registering a custom Admin Model below.
@@ -45,3 +48,50 @@ def object_link(self, obj):
4548

4649
object_link.admin_order_field = "object_repr"
4750
object_link.short_description = "object"
51+
52+
53+
class FeatureFlagAdminForm(ModelForm):
54+
"""Custom form for FeatureFlag admin with JSON editor widget."""
55+
56+
class Meta:
57+
"""Metadata."""
58+
59+
model = FeatureFlag
60+
fields = '__all__'
61+
widgets = {
62+
'config': JSONEditorWidget(options={
63+
'mode': 'code',
64+
'modes': ['code', 'tree'],
65+
'search': True
66+
})
67+
}
68+
69+
70+
@admin.register(FeatureFlag)
71+
class FeatureFlagAdmin(admin.ModelAdmin):
72+
"""Admin interface for FeatureFlag model."""
73+
74+
form = FeatureFlagAdminForm
75+
76+
list_display = ['feature_name', 'enabled', 'updated_at']
77+
list_filter = ['enabled', 'created_at', 'updated_at']
78+
search_fields = ['feature_name', 'description']
79+
readonly_fields = ['created_at', 'updated_at']
80+
81+
fieldsets = (
82+
('Feature Identity', {
83+
'fields': ('feature_name', 'description')
84+
}),
85+
('Configuration', {
86+
'fields': ('enabled', 'config'),
87+
'description': 'Toggle the feature on/off and configure feature-specific settings'
88+
}),
89+
('Metadata', {
90+
'fields': ('created_at', 'updated_at'),
91+
'classes': ('collapse',)
92+
}),
93+
)
94+
95+
def has_delete_permission(self, request, obj=None):
96+
"""Only allow superusers to delete feature flags."""
97+
return request.user.is_superuser
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.2.10 on 2026-01-16 18:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0003_load_uswds_admin_theme'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='FeatureFlag',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('feature_name', models.CharField(db_index=True, max_length=100, unique=True)),
18+
('enabled', models.BooleanField(default=False)),
19+
('config', models.JSONField(blank=True, default=dict)),
20+
('description', models.TextField(blank=True)),
21+
('created_at', models.DateTimeField(auto_now_add=True)),
22+
('updated_at', models.DateTimeField(auto_now=True)),
23+
],
24+
options={
25+
'verbose_name': 'Feature Flag',
26+
'verbose_name_plural': 'Feature Flags',
27+
'ordering': ['feature_name'],
28+
},
29+
),
30+
]

tdrs-backend/tdpservice/core/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@
44
from django.contrib.contenttypes.models import ContentType
55
from django.db import models
66

7+
8+
class FeatureFlag(models.Model):
9+
"""Model for storing feature flags that can be toggled on/off via Django admin."""
10+
11+
class Meta:
12+
"""Metadata."""
13+
14+
ordering = ['feature_name']
15+
verbose_name = 'Feature Flag'
16+
verbose_name_plural = 'Feature Flags'
17+
18+
feature_name = models.CharField(max_length=100, unique=True, db_index=True)
19+
enabled = models.BooleanField(default=False)
20+
config = models.JSONField(null=False, blank=True, default=dict)
21+
description = models.TextField(blank=True)
22+
created_at = models.DateTimeField(auto_now_add=True)
23+
updated_at = models.DateTimeField(auto_now=True)
24+
25+
def __str__(self) -> str:
26+
"""Return string representation of the feature flag."""
27+
status = "enabled" if self.enabled else "disabled"
28+
return f"{self.feature_name} ({status})"
29+
30+
731
"""Global permissions
832
933
Allows for the creation of permissions that are

tdrs-backend/tdpservice/core/test/test_admin.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Core Admin class tests."""
22
from django.contrib.admin.models import ADDITION, LogEntry
3+
from django.contrib.admin.sites import AdminSite
34
from django.contrib.contenttypes.models import ContentType
4-
from django.test import TestCase, Client
5+
from django.test import TestCase, Client, RequestFactory
56
from django.urls import reverse
67
from tdpservice.users.models import UserChangeRequest, UserChangeRequestStatus
78

89
import pytest
910

11+
from tdpservice.core.admin import FeatureFlagAdmin
12+
from tdpservice.core.models import FeatureFlag
1013
from tdpservice.users.models import User
1114

1215

@@ -82,3 +85,176 @@ def test_user_change_request_approve(self):
8285
# Approve the change request
8386
response = client.post(f'/admin/users/userchangerequest/{change_request.id}/approve/')
8487
self.assertEqual(response.status_code, 302)
88+
89+
90+
class TestFeatureFlagAdmin(TestCase):
91+
"""Test the FeatureFlagAdmin interface."""
92+
93+
def setUp(self):
94+
"""Create users for testing."""
95+
self.superuser = User.objects.create_superuser(
96+
username='superadmin',
97+
password='superpassword',
98+
first_name='Super',
99+
last_name='Admin',
100+
email='superadmin@example.com'
101+
)
102+
self.superuser.is_active = True
103+
self.superuser.save()
104+
105+
self.staff_user = User.objects.create_user(
106+
username='staffuser',
107+
password='staffpassword',
108+
first_name='Staff',
109+
last_name='User',
110+
email='staff@example.com'
111+
)
112+
self.staff_user.is_staff = True
113+
self.staff_user.is_active = True
114+
self.staff_user.save()
115+
116+
self.site = AdminSite()
117+
self.admin = FeatureFlagAdmin(FeatureFlag, self.site)
118+
self.factory = RequestFactory()
119+
120+
return super().setUp()
121+
122+
def test_feature_flag_list_view(self):
123+
"""Test that feature flags are visible in admin list view."""
124+
FeatureFlag.objects.create(
125+
feature_name="test_feature",
126+
enabled=True,
127+
description="A test feature"
128+
)
129+
130+
client = Client()
131+
client.login(username='superadmin', password='superpassword')
132+
response = client.get(reverse('admin:core_featureflag_changelist'))
133+
134+
self.assertEqual(response.status_code, 200)
135+
self.assertContains(response, 'test_feature')
136+
137+
def test_feature_flag_add_view(self):
138+
"""Test that feature flags can be added via admin."""
139+
client = Client()
140+
client.login(username='superadmin', password='superpassword')
141+
response = client.get(reverse('admin:core_featureflag_add'))
142+
143+
self.assertEqual(response.status_code, 200)
144+
self.assertContains(response, 'Feature Identity')
145+
self.assertContains(response, 'Configuration')
146+
147+
def test_feature_flag_change_view(self):
148+
"""Test that feature flags can be edited via admin."""
149+
flag = FeatureFlag.objects.create(
150+
feature_name="edit_feature",
151+
enabled=False
152+
)
153+
154+
client = Client()
155+
client.login(username='superadmin', password='superpassword')
156+
response = client.get(
157+
reverse('admin:core_featureflag_change', args=[flag.id])
158+
)
159+
160+
self.assertEqual(response.status_code, 200)
161+
self.assertContains(response, 'edit_feature')
162+
163+
def test_superuser_can_delete(self):
164+
"""Test that superusers can delete feature flags."""
165+
flag = FeatureFlag.objects.create(feature_name="deletable_feature")
166+
167+
request = self.factory.get('/')
168+
request.user = self.superuser
169+
170+
self.assertTrue(self.admin.has_delete_permission(request, flag))
171+
172+
def test_non_superuser_cannot_delete(self):
173+
"""Test that non-superusers cannot delete feature flags."""
174+
flag = FeatureFlag.objects.create(feature_name="protected_feature")
175+
176+
request = self.factory.get('/')
177+
request.user = self.staff_user
178+
179+
self.assertFalse(self.admin.has_delete_permission(request, flag))
180+
181+
def test_list_display_fields(self):
182+
"""Test that list_display contains expected fields."""
183+
self.assertIn('feature_name', self.admin.list_display)
184+
self.assertIn('enabled', self.admin.list_display)
185+
self.assertIn('updated_at', self.admin.list_display)
186+
187+
def test_list_filter_fields(self):
188+
"""Test that list_filter contains expected fields."""
189+
self.assertIn('enabled', self.admin.list_filter)
190+
self.assertIn('created_at', self.admin.list_filter)
191+
self.assertIn('updated_at', self.admin.list_filter)
192+
193+
def test_search_fields(self):
194+
"""Test that search_fields contains expected fields."""
195+
self.assertIn('feature_name', self.admin.search_fields)
196+
self.assertIn('description', self.admin.search_fields)
197+
198+
def test_readonly_fields(self):
199+
"""Test that timestamps are read-only."""
200+
self.assertIn('created_at', self.admin.readonly_fields)
201+
self.assertIn('updated_at', self.admin.readonly_fields)
202+
203+
def test_feature_flag_create_via_admin(self):
204+
"""Test creating a feature flag through the admin interface."""
205+
client = Client()
206+
client.login(username='superadmin', password='superpassword')
207+
208+
response = client.post(
209+
reverse('admin:core_featureflag_add'),
210+
{
211+
'feature_name': 'datafiles_pia_submission',
212+
'enabled': True,
213+
'config': '{"max_file_size": 1000}',
214+
'description': 'Enable PIA datafile submission'
215+
}
216+
)
217+
218+
# Should redirect after successful creation
219+
self.assertEqual(response.status_code, 302)
220+
self.assertTrue(
221+
FeatureFlag.objects.filter(feature_name='datafiles_pia_submission').exists()
222+
)
223+
224+
def test_feature_flag_search(self):
225+
"""Test searching for feature flags."""
226+
FeatureFlag.objects.create(
227+
feature_name="searchable_feature",
228+
description="This feature can be searched"
229+
)
230+
FeatureFlag.objects.create(
231+
feature_name="another_feature",
232+
description="Different description"
233+
)
234+
235+
client = Client()
236+
client.login(username='superadmin', password='superpassword')
237+
response = client.get(
238+
reverse('admin:core_featureflag_changelist'),
239+
{'q': 'searchable'}
240+
)
241+
242+
self.assertEqual(response.status_code, 200)
243+
self.assertContains(response, 'searchable_feature')
244+
self.assertNotContains(response, 'another_feature')
245+
246+
def test_feature_flag_filter_by_enabled(self):
247+
"""Test filtering feature flags by enabled status."""
248+
FeatureFlag.objects.create(feature_name="enabled_flag", enabled=True)
249+
FeatureFlag.objects.create(feature_name="disabled_flag", enabled=False)
250+
251+
client = Client()
252+
client.login(username='superadmin', password='superpassword')
253+
response = client.get(
254+
reverse('admin:core_featureflag_changelist'),
255+
{'enabled__exact': '1'}
256+
)
257+
258+
self.assertEqual(response.status_code, 200)
259+
self.assertContains(response, 'enabled_flag')
260+
self.assertNotContains(response, 'disabled_flag')

0 commit comments

Comments
 (0)