Skip to content

Commit 1cc32d9

Browse files
authored
Merge branch 'develop' into bug-ssn-valid-logic
2 parents 0080a8f + 5778aff commit 1cc32d9

File tree

22 files changed

+683
-65
lines changed

22 files changed

+683
-65
lines changed

Taskfile.yml

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
version: '3'
1+
version: "3"
22

33
tasks:
4-
54
gitcfg:
65
desc: Configure git
76
cmds:
@@ -61,7 +60,6 @@ tasks:
6160
# 3) Apply all migrations to initialize the new database
6261
- docker compose -f docker-compose.yml exec web sh -c "python manage.py migrate"
6362

64-
6563
backend-restart:
6664
desc: Restart backend web server
6765
dir: tdrs-backend
@@ -84,7 +82,7 @@ tasks:
8482
desc: Execute a manage.py command in the backend container."
8583
dir: tdrs-backend
8684
vars:
87-
CMD: '{{.CMD}}'
85+
CMD: "{{.CMD}}"
8886
cmds:
8987
- docker compose -f docker-compose.yml exec web sh -c "python manage.py {{.CMD}}"
9088

@@ -296,7 +294,7 @@ tasks:
296294
cmds:
297295
- export CYPRESS_TOKEN=local-cypress-token
298296
- docker-compose exec web python manage.py delete_cypress_users -usernames new-cypress@teamraft.com cypress-admin@teamraft.com
299-
- docker-compose exec web python manage.py loaddata cypress/users cypress/data_files cypress/regions
297+
- docker-compose exec web python manage.py loaddata cypress/users cypress/data_files cypress/regions cypress/profile_editing_regions cypress/profile_editing_users
300298

301299
frontend-e2e-local:
302300
desc: Run Cypress E2E tests locally (Cypress on host, app in docker)
@@ -309,10 +307,10 @@ tasks:
309307
desc: Run k6 performance tests
310308
dir: performance-tests
311309
vars:
312-
SCRIPT: '{{.SCRIPT}}'
310+
SCRIPT: "{{.SCRIPT}}"
313311
CYPRESS_TOKEN: '{{.CYPRESS_TOKEN | default "local-cypress-token"}}'
314312
BASE_URL: '{{.BASE_URL | default "http://localhost:3000"}}'
315-
SCENARIO: '{{.SCENARIO}}'
313+
SCENARIO: "{{.SCENARIO}}"
316314
cmds:
317315
- k6 run -e BASE_URL={{.BASE_URL}} -e CYPRESS_TOKEN={{.CYPRESS_TOKEN}} -e SCENARIO={{.SCENARIO}} {{.SCRIPT}}
318316

tdrs-backend/tdpservice/common/test/__init__.py

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Test the common utils."""
2+
3+
import pytest
4+
5+
from tdpservice.common.util import get_cloudgov_broker_db_numbers
6+
7+
8+
@pytest.mark.parametrize(
9+
"env,expected",
10+
[
11+
# dev envs
12+
("raft", {"celery": "0", "caches": {"stts": "1", "feature-flags": "2"}}),
13+
("qasp", {"celery": "3", "caches": {"stts": "4", "feature-flags": "5"}}),
14+
("a11y", {"celery": "6", "caches": {"stts": "7", "feature-flags": "8"}}),
15+
# staging
16+
("develop", {"celery": "0", "caches": {"stts": "1", "feature-flags": "2"}}),
17+
("staging", {"celery": "3", "caches": {"stts": "4", "feature-flags": "5"}}),
18+
# prod
19+
("prod", {"celery": "0", "caches": {"stts": "1", "feature-flags": "2"}}),
20+
],
21+
)
22+
@pytest.mark.django_db
23+
def test_get_cloudgov_broker_db_numbers(env, expected):
24+
"""Test redis broker db number generation for deployed envs."""
25+
result = get_cloudgov_broker_db_numbers(env)
26+
assert result == expected
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Utilities for the application."""
2+
3+
4+
def get_cloudgov_broker_db_numbers(cloudgov_name):
5+
"""
6+
Get the appropriate redis broker db numbers for an environment.
7+
8+
Returns an object of {"celery": str, "caches": {"cache_name": str}}
9+
"""
10+
spaces = {
11+
"dev": ["raft", "qasp", "a11y"],
12+
"staging": ["develop", "staging"],
13+
"prod": ["prod"],
14+
}
15+
cache_options = ["stts", "feature-flags"]
16+
17+
broker_nums = {}
18+
19+
for space, envs in spaces.items():
20+
incr = 0
21+
22+
for env in envs:
23+
celery = str(incr)
24+
caches = {}
25+
for c in cache_options:
26+
incr += 1
27+
caches[c] = str(incr)
28+
29+
broker_nums[env] = {"celery": celery, "caches": caches}
30+
incr += 1
31+
32+
return broker_nums[cloudgov_name]

tdrs-backend/tdpservice/core/models.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from django.contrib.auth.models import Permission
44
from django.contrib.contenttypes.models import ContentType
5+
from django.core.cache import caches
56
from django.db import models
7+
from django.db.models.signals import post_delete, post_migrate, post_save
8+
from django.dispatch import receiver
69

710

811
class FeatureFlag(models.Model):
@@ -11,9 +14,9 @@ class FeatureFlag(models.Model):
1114
class Meta:
1215
"""Metadata."""
1316

14-
ordering = ['feature_name']
15-
verbose_name = 'Feature Flag'
16-
verbose_name_plural = 'Feature Flags'
17+
ordering = ["feature_name"]
18+
verbose_name = "Feature Flag"
19+
verbose_name_plural = "Feature Flags"
1720

1821
feature_name = models.CharField(max_length=100, unique=True, db_index=True)
1922
enabled = models.BooleanField(default=False)
@@ -28,6 +31,18 @@ def __str__(self) -> str:
2831
return f"{self.feature_name} ({status})"
2932

3033

34+
@receiver([post_delete, post_migrate, post_save], sender=FeatureFlag)
35+
def clear_feature_flag_cache(sender, instance, **kwargs):
36+
"""Invalidate the cache after any changes to feature flags.
37+
38+
This depends on the cache being separated by feature, so the entire cache can be deleted.
39+
There are too many options for headers/cookies to determine the key programatically,
40+
so we segment the different featuers into separate caches to be able to invalidate efficiently
41+
"""
42+
cache = caches["feature-flags"]
43+
cache.clear()
44+
45+
3146
"""Global permissions
3247
3348
Allows for the creation of permissions that are
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Serialize core model data."""
2+
3+
from rest_framework import serializers
4+
5+
from tdpservice.core.models import FeatureFlag
6+
7+
8+
class FeatureFlagSerializer(serializers.ModelSerializer):
9+
"""FeatureFlag serializer."""
10+
11+
class Meta:
12+
"""Metadata."""
13+
14+
model = FeatureFlag
15+
fields = [
16+
"feature_name",
17+
"enabled",
18+
"config",
19+
"description",
20+
]

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

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
"""Core API tests."""
2+
23
import uuid
4+
from unittest.mock import MagicMock, patch
35

46
from django.contrib.admin.models import LogEntry
57
from django.contrib.contenttypes.models import ContentType
8+
from django.core.cache import caches
9+
from django.test import TestCase, override_settings
10+
from django.urls import reverse
611

712
import pytest
813
from rest_framework import status
14+
from rest_framework.test import APIClient
915

16+
from tdpservice.conftest import UserFactory
17+
from tdpservice.core.models import FeatureFlag
18+
from tdpservice.core.views import FeatureFlagViewset
1019
from tdpservice.data_files.models import DataFile
1120

1221

@@ -78,3 +87,119 @@ def test_log_entry_creation(api_client, data_file_instance):
7887
content_type_id=ContentType.objects.get_for_model(DataFile).pk,
7988
object_id=data_file_instance.pk,
8089
).exists()
90+
91+
92+
@override_settings(
93+
CACHES={
94+
"feature-flags": {
95+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
96+
"LOCATION": "unique-test-cache-location", # Unique location to avoid conflicts
97+
"KEY_PREFIX": "test",
98+
},
99+
}
100+
)
101+
class TestFeatureFlagViewset(TestCase):
102+
"""Tests for the FeatureFlagViewset class."""
103+
104+
api_client = APIClient()
105+
106+
def setUp(self):
107+
"""Run before all tests in TestCase."""
108+
super().setUp()
109+
cache = caches["feature-flags"]
110+
cache.clear()
111+
112+
user = UserFactory.create()
113+
self.api_client.login(username=user.username, password="test_password")
114+
115+
def test_existing_list_cache_avoids_lookup(self):
116+
"""Test that no lookup is performed if flags exist in the cache."""
117+
mock_queryset = MagicMock()
118+
with patch.object(
119+
FeatureFlagViewset, "get_queryset", return_value=mock_queryset
120+
) as mock_method:
121+
# request and check the cache was cold
122+
response = self.api_client.get(reverse("feature-flag-list"))
123+
assert response.status_code == status.HTTP_200_OK
124+
assert mock_method.called
125+
126+
mock_method.reset_mock()
127+
128+
# the cache should be warm now, request again
129+
response = self.api_client.get(reverse("feature-flag-list"))
130+
assert response.status_code == status.HTTP_200_OK
131+
assert not mock_method.called
132+
133+
def test_no_list_cache_forces_lookup(self):
134+
"""Test that a lookup is performed if there are no flags in the cache."""
135+
mock_queryset = MagicMock()
136+
with patch.object(
137+
FeatureFlagViewset, "get_queryset", return_value=mock_queryset
138+
) as mock_method:
139+
# request and check the cache was cold
140+
response = self.api_client.get(reverse("feature-flag-list"))
141+
assert response.status_code == status.HTTP_200_OK
142+
assert mock_method.called
143+
144+
def test_saving_flag_invalidates_cache(self):
145+
"""Test saving a feature flag invalidates existing cache."""
146+
mock_queryset = MagicMock()
147+
with patch.object(
148+
FeatureFlagViewset, "get_queryset", return_value=mock_queryset
149+
) as mock_method:
150+
# request and check the cache was cold
151+
response = self.api_client.get(reverse("feature-flag-list"))
152+
assert response.status_code == status.HTTP_200_OK
153+
assert mock_method.called
154+
155+
mock_method.reset_mock()
156+
157+
# the cache should be warm now, request again
158+
response = self.api_client.get(reverse("feature-flag-list"))
159+
assert response.status_code == status.HTTP_200_OK
160+
assert not mock_method.called
161+
162+
mock_method.reset_mock()
163+
164+
# create a new feature flag
165+
FeatureFlag.objects.create(feature_name="unit-test")
166+
167+
# check that the cache was invalidated
168+
response = self.api_client.get(reverse("feature-flag-list"))
169+
assert response.status_code == status.HTTP_200_OK
170+
assert mock_method.called
171+
172+
def test_existing_single_cache_avoids_lookup(self):
173+
"""Test that no lookup is performed if flags exist in the cache."""
174+
FeatureFlag.objects.create(feature_name="test1")
175+
with patch.object(
176+
FeatureFlagViewset, "get_queryset", return_value=FeatureFlag.objects.all()
177+
) as mock_method:
178+
# request and check the cache was cold
179+
response = self.api_client.get(
180+
reverse("feature-flag-detail", args=("test1",))
181+
)
182+
assert response.status_code == status.HTTP_200_OK
183+
assert mock_method.called
184+
185+
mock_method.reset_mock()
186+
187+
# the cache should be warm now, request again
188+
response = self.api_client.get(
189+
reverse("feature-flag-detail", args=("test1",))
190+
)
191+
assert response.status_code == status.HTTP_200_OK
192+
assert not mock_method.called
193+
194+
def test_no_single_cache_forces_lookup(self):
195+
"""Test that a lookup is performed if there are no flags in the cache."""
196+
FeatureFlag.objects.create(feature_name="test2")
197+
with patch.object(
198+
FeatureFlagViewset, "get_queryset", return_value=FeatureFlag.objects.all()
199+
) as mock_method:
200+
# request and check the cache was cold
201+
response = self.api_client.get(
202+
reverse("feature-flag-detail", args=("test2",))
203+
)
204+
assert response.status_code == status.HTTP_200_OK
205+
assert mock_method.called

tdrs-backend/tdpservice/core/views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
"""Define core, generic views of the app."""
2+
23
import logging
34

5+
from django.conf import settings
46
from django.contrib.admin.models import ADDITION, LogEntry
57
from django.contrib.contenttypes.models import ContentType
8+
from django.utils.decorators import method_decorator
9+
from django.views.decorators.cache import cache_page
610

11+
from rest_framework import viewsets
712
from rest_framework.decorators import api_view, permission_classes
813
from rest_framework.permissions import IsAuthenticated
914
from rest_framework.response import Response
1015

16+
from tdpservice.core.models import FeatureFlag
17+
from tdpservice.core.serializers import FeatureFlagSerializer
1118
from tdpservice.data_files.models import DataFile
1219

1320
logger = logging.getLogger()
@@ -56,3 +63,39 @@ def write_logs(request):
5663
)
5764

5865
return Response("Success")
66+
67+
68+
class FeatureFlagViewset(viewsets.ReadOnlyModelViewSet):
69+
"""List and Get endpoints for FeatureFlag."""
70+
71+
pagination_class = None
72+
permission_classes = [IsAuthenticated]
73+
queryset = FeatureFlag.objects.all()
74+
serializer_class = FeatureFlagSerializer
75+
lookup_field = "feature_name"
76+
77+
@method_decorator(
78+
[
79+
cache_page(
80+
settings.DEFAULT_CACHE_TIMEOUT,
81+
cache="feature-flags",
82+
key_prefix="list",
83+
),
84+
]
85+
)
86+
def list(self, request):
87+
"""Get the feature flag list from the cache if available, else fetch the queryset."""
88+
return super().list(request)
89+
90+
@method_decorator(
91+
[
92+
cache_page(
93+
settings.DEFAULT_CACHE_TIMEOUT,
94+
cache="feature-flags",
95+
key_prefix="value",
96+
),
97+
]
98+
) # should these be individually cached? would be cached anyway with above impl
99+
def retrieve(self, request, *args, **kwargs):
100+
"""Get the feature flag from cache if available, fallback to db."""
101+
return super().retrieve(request, *args, **kwargs)

0 commit comments

Comments
 (0)