From 2bb73576f4103af0ae55a3b37f1e9b7c22b6102f Mon Sep 17 00:00:00 2001 From: Vignesh Hari <14056798+vigneshhari@users.noreply.github.com> Date: Thu, 19 Sep 2024 19:56:42 +0530 Subject: [PATCH] Add feature flags (#2429) * Added Facility Flag Implementation * added caching and user flags * Fix class name * update migrations * fixes * format * add tests * fix tests * Fix variable naming * abstract feature flag model * cleanup * fix migrations * fix failing tests * Add merge migration * Clean migrations * Fix linting * fix lint --------- Co-authored-by: Aakash Singh --- care/facility/admin.py | 18 +++++ care/facility/api/serializers/facility.py | 6 ++ ...yflag_facilityflag_unique_facility_flag.py | 62 +++++++++++++++++ care/facility/models/__init__.py | 1 + care/facility/models/facility.py | 4 ++ care/facility/models/facility_flag.py | 40 +++++++++++ care/facility/tests/test_facility_flags.py | 55 +++++++++++++++ care/users/admin.py | 27 +++++++- care/users/api/serializers/user.py | 6 ++ care/users/migrations/0017_userflag.py | 62 +++++++++++++++++ care/users/models.py | 40 ++++++++++- care/users/tests/test_api.py | 1 + care/users/tests/test_user_flags.py | 44 ++++++++++++ care/utils/models/base.py | 63 +++++++++++++++++ care/utils/registries/__init__.py | 0 care/utils/registries/feature_flag.py | 68 +++++++++++++++++++ care/utils/tests/test_feature_flags.py | 68 +++++++++++++++++++ config/urls.py | 2 +- 18 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 care/facility/migrations/0458_facilityflag_facilityflag_unique_facility_flag.py create mode 100644 care/facility/models/facility_flag.py create mode 100644 care/facility/tests/test_facility_flags.py create mode 100644 care/users/migrations/0017_userflag.py create mode 100644 care/users/tests/test_user_flags.py create mode 100644 care/utils/registries/__init__.py create mode 100644 care/utils/registries/feature_flag.py create mode 100644 care/utils/tests/test_feature_flags.py diff --git a/care/facility/admin.py b/care/facility/admin.py index e48c3a6c00..7e06f1442b 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib import admin from django.contrib.admin import SimpleListFilter from djangoql.admin import DjangoQLSearchMixin @@ -12,12 +13,14 @@ PatientConsultation, ) from care.facility.models.patient_sample import PatientSample +from care.utils.registries.feature_flag import FlagRegistry, FlagType from .models import ( Building, Disease, Facility, FacilityCapacity, + FacilityFlag, FacilityInventoryItem, FacilityInventoryItemTag, FacilityInventoryUnit, @@ -188,6 +191,19 @@ class FacilityUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin, ExportCsvMixin): actions = ["export_as_csv"] +class FacilityFlagAdmin(admin.ModelAdmin): + class FacilityFeatureFlagForm(forms.ModelForm): + flag = forms.ChoiceField( + choices=lambda: FlagRegistry.get_all_flags_as_choices(FlagType.FACILITY) + ) + + class Meta: + fields = "__all__" + model = FacilityFlag + + form = FacilityFeatureFlagForm + + admin.site.register(Facility, FacilityAdmin) admin.site.register(FacilityStaff, FacilityStaffAdmin) admin.site.register(FacilityCapacity, FacilityCapacityAdmin) @@ -217,3 +233,5 @@ class FacilityUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin, ExportCsvMixin): admin.site.register(PatientConsent) admin.site.register(FileUpload) admin.site.register(PatientConsultation) + +admin.site.register(FacilityFlag, FacilityFlagAdmin) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index dba13cd5b1..99c00bbe69 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -105,6 +105,11 @@ class FacilitySerializer(FacilityBasicInfoSerializer): ) bed_count = serializers.SerializerMethodField() + facility_flags = serializers.SerializerMethodField() + + def get_facility_flags(self, facility): + return facility.get_facility_flags() + class Meta: model = Facility fields = [ @@ -140,6 +145,7 @@ class Meta: "read_cover_image_url", "patient_count", "bed_count", + "facility_flags", ] read_only_fields = ("modified_date", "created_date") diff --git a/care/facility/migrations/0458_facilityflag_facilityflag_unique_facility_flag.py b/care/facility/migrations/0458_facilityflag_facilityflag_unique_facility_flag.py new file mode 100644 index 0000000000..d47a1f7c14 --- /dev/null +++ b/care/facility/migrations/0458_facilityflag_facilityflag_unique_facility_flag.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.10 on 2024-09-19 12:58 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("facility", "0457_patientmetainfo_domestic_healthcare_support_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="FacilityFlag", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("flag", models.CharField(max_length=1024)), + ( + "facility", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="facility.facility", + ), + ), + ], + options={ + "verbose_name": "Facility Flag", + }, + ), + migrations.AddConstraint( + model_name="facilityflag", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("facility", "flag"), + name="unique_facility_flag", + ), + ), + ] diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index 8993152ef2..df41476768 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -8,6 +8,7 @@ from .encounter_symptom import * # noqa from .events import * # noqa from .facility import * # noqa +from .facility_flag import * # noqa from .icd11_diagnosis import * # noqa from .inventory import * # noqa from .patient import * # noqa diff --git a/care/facility/models/facility.py b/care/facility/models/facility.py index 8b231d5a50..ec2f79a35c 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -6,6 +6,7 @@ from simple_history.models import HistoricalRecords from care.facility.models import FacilityBaseModel, reverse_choices +from care.facility.models.facility_flag import FacilityFlag from care.facility.models.mixins.permissions.facility import ( FacilityPermissionMixin, FacilityRelatedPermissionMixin, @@ -274,6 +275,9 @@ def get_features_display(self): return [] return [FacilityFeature(f).label for f in self.features] + def get_facility_flags(self): + return FacilityFlag.get_all_flags(self.id) + CSV_MAPPING = { "name": "Facility Name", "facility_type": "Facility Type", diff --git a/care/facility/models/facility_flag.py b/care/facility/models/facility_flag.py new file mode 100644 index 0000000000..e5cf3b4441 --- /dev/null +++ b/care/facility/models/facility_flag.py @@ -0,0 +1,40 @@ +from django.db import models + +from care.utils.models.base import BaseFlag +from care.utils.registries.feature_flag import FlagName, FlagType + +FACILITY_FLAG_CACHE_KEY = "facility_flag_cache:{facility_id}:{flag_name}" +FACILITY_ALL_FLAGS_CACHE_KEY = "facility_all_flags_cache:{facility_id}" +FACILITY_FLAG_CACHE_TTL = 60 * 60 * 24 # 1 Day + + +class FacilityFlag(BaseFlag): + facility = models.ForeignKey( + "facility.Facility", on_delete=models.CASCADE, null=False, blank=False + ) + + cache_key_template = "facility_flag_cache:{entity_id}:{flag_name}" + all_flags_cache_key_template = "facility_all_flags_cache:{entity_id}" + flag_type = FlagType.FACILITY + entity_field_name = "facility" + + def __str__(self) -> str: + return f"Facility Flag: {self.facility.name} - {self.flag}" + + class Meta: + verbose_name = "Facility Flag" + constraints = [ + models.UniqueConstraint( + fields=["facility", "flag"], + condition=models.Q(deleted=False), + name="unique_facility_flag", + ) + ] + + @classmethod + def check_facility_has_flag(cls, facility_id: int, flag_name: FlagName) -> bool: + return cls.check_entity_has_flag(facility_id, flag_name) + + @classmethod + def get_all_flags(cls, facility_id: int) -> tuple[FlagName]: + return super().get_all_flags(facility_id) diff --git a/care/facility/tests/test_facility_flags.py b/care/facility/tests/test_facility_flags.py new file mode 100644 index 0000000000..5141459ec7 --- /dev/null +++ b/care/facility/tests/test_facility_flags.py @@ -0,0 +1,55 @@ +from django.db import IntegrityError +from rest_framework.test import APITestCase + +from care.facility.models.facility_flag import FacilityFlag +from care.utils.registries.feature_flag import FlagRegistry, FlagType +from care.utils.tests.test_utils import TestUtils + + +class FacilityFlagsTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + FlagRegistry.register(FlagType.FACILITY, "TEST_FLAG") + FlagRegistry.register(FlagType.FACILITY, "TEST_FLAG_2") + cls.district = cls.create_district(cls.create_state()) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + + def setUp(self) -> None: + self.facility = self.create_facility( + self.super_user, self.district, self.local_body + ) + + def test_facility_flags(self): + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG") + self.assertTrue( + FacilityFlag.check_facility_has_flag(self.facility.id, "TEST_FLAG") + ) + + def test_facility_flags_negative(self): + self.assertFalse( + FacilityFlag.check_facility_has_flag(self.facility.id, "TEST_FLAG") + ) + + def test_create_duplicate_flag(self): + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG") + with self.assertRaises(IntegrityError): + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG") + + def test_get_all_flags(self): + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG") + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG_2") + self.assertEqual( + FacilityFlag.get_all_flags(self.facility.id), ("TEST_FLAG", "TEST_FLAG_2") + ) + + def test_get_user_flags_api(self): + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG") + FacilityFlag.objects.create(facility=self.facility, flag="TEST_FLAG_2") + user = self.create_user("user", self.district, home_facility=self.facility) + self.client.force_authenticate(user=user) + response = self.client.get(f"/api/v1/facility/{self.facility.external_id}/") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json()["facility_flags"], ["TEST_FLAG", "TEST_FLAG_2"] + ) diff --git a/care/users/admin.py b/care/users/admin.py index 32b64980dd..5aebd9f966 100644 --- a/care/users/admin.py +++ b/care/users/admin.py @@ -1,10 +1,20 @@ +from django import forms from django.contrib import admin from django.contrib.auth import admin as auth_admin from django.contrib.auth import get_user_model from djqscsv import render_to_csv_response from care.users.forms import UserChangeForm, UserCreationForm -from care.users.models import District, LocalBody, Skill, State, UserSkill, Ward +from care.users.models import ( + District, + LocalBody, + Skill, + State, + UserFlag, + UserSkill, + Ward, +) +from care.utils.registries.feature_flag import FlagRegistry, FlagType User = get_user_model() @@ -72,6 +82,19 @@ class WardAdmin(admin.ModelAdmin): autocomplete_fields = ["local_body"] -admin.site.register(Skill) +@admin.register(UserFlag) +class UserFlagAdmin(admin.ModelAdmin): + class UserFlagForm(forms.ModelForm): + flag = forms.ChoiceField( + choices=lambda: FlagRegistry.get_all_flags_as_choices(FlagType.USER) + ) + + class Meta: + fields = "__all__" + model = UserFlag + form = UserFlagForm + + +admin.site.register(Skill) admin.site.register(UserSkill) diff --git a/care/users/api/serializers/user.py b/care/users/api/serializers/user.py index 9e267ac98e..8040ed71dc 100644 --- a/care/users/api/serializers/user.py +++ b/care/users/api/serializers/user.py @@ -284,6 +284,11 @@ class UserSerializer(SignUpSerializer): date_of_birth = serializers.DateField(required=True) + user_flags = serializers.SerializerMethodField() + + def get_user_flags(self, user) -> tuple[str]: + return user.get_all_flags() + class Meta: model = User fields = ( @@ -316,6 +321,7 @@ class Meta: "pf_endpoint", "pf_p256dh", "pf_auth", + "user_flags", ) read_only_fields = ( "is_superuser", diff --git a/care/users/migrations/0017_userflag.py b/care/users/migrations/0017_userflag.py new file mode 100644 index 0000000000..a862ab9d08 --- /dev/null +++ b/care/users/migrations/0017_userflag.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1.1 on 2024-09-19 12:22 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0016_upgrade_user_skills"), + ] + + operations = [ + migrations.CreateModel( + name="UserFlag", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "external_id", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ( + "created_date", + models.DateTimeField(auto_now_add=True, db_index=True, null=True), + ), + ( + "modified_date", + models.DateTimeField(auto_now=True, db_index=True, null=True), + ), + ("deleted", models.BooleanField(db_index=True, default=False)), + ("flag", models.CharField(max_length=1024)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "User Flag", + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("deleted", False)), + fields=("user", "flag"), + name="unique_user_flag", + ) + ], + }, + ), + ] diff --git a/care/users/models.py b/care/users/models.py index 5ea7e17e6e..1ef1280f54 100644 --- a/care/users/models.py +++ b/care/users/models.py @@ -8,12 +8,17 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from care.utils.models.base import BaseModel +from care.utils.models.base import BaseFlag, BaseModel from care.utils.models.validators import ( UsernameValidator, mobile_or_landline_number_validator, mobile_validator, ) +from care.utils.registries.feature_flag import FlagName, FlagType + +USER_FLAG_CACHE_KEY = "user_flag_cache:{user_id}:{flag_name}" +USER_ALL_FLAGS_CACHE_KEY = "user_all_flags_cache:{user_id}" +USER_FLAG_CACHE_TTL = 60 * 60 * 24 # 1 Day def reverse_choices(choices): @@ -368,6 +373,9 @@ def delete(self, *args, **kwargs): def get_absolute_url(self): return reverse("users:detail", kwargs={"username": self.username}) + def get_all_flags(self): + return UserFlag.get_all_flags(self.id) + def save(self, *args, **kwargs) -> None: """ While saving, if the local body is not null, then district will be local body's district @@ -391,3 +399,33 @@ class UserFacilityAllocation(models.Model): ) start_date = models.DateTimeField(default=now) end_date = models.DateTimeField(null=True, blank=True) + + +class UserFlag(BaseFlag): + user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False) + + cache_key_template = "user_flag_cache:{entity_id}:{flag_name}" + all_flags_cache_key_template = "user_all_flags_cache:{entity_id}" + flag_type = FlagType.USER + entity_field_name = "user" + + def __str__(self): + return f"User Flag: {self.user.get_full_name()} - {self.flag}" + + class Meta: + verbose_name = "User Flag" + constraints = [ + models.UniqueConstraint( + fields=["user", "flag"], + condition=models.Q(deleted=False), + name="unique_user_flag", + ) + ] + + @classmethod + def check_user_has_flag(cls, user_id: int, flag_name: FlagName) -> bool: + return cls.check_entity_has_flag(user_id, flag_name) + + @classmethod + def get_all_flags(cls, user_id: int) -> tuple[FlagName]: + return super().get_all_flags(user_id) diff --git a/care/users/tests/test_api.py b/care/users/tests/test_api.py index ef58f25e7c..28b87af4bf 100644 --- a/care/users/tests/test_api.py +++ b/care/users/tests/test_api.py @@ -46,6 +46,7 @@ def get_detail_representation(self, obj=None) -> dict: "doctor_qualification": obj.doctor_qualification, "weekly_working_hours": obj.weekly_working_hours, "video_connect_link": obj.video_connect_link, + "user_flags": [], **self.get_local_body_district_state_representation(obj), } diff --git a/care/users/tests/test_user_flags.py b/care/users/tests/test_user_flags.py new file mode 100644 index 0000000000..88f2450049 --- /dev/null +++ b/care/users/tests/test_user_flags.py @@ -0,0 +1,44 @@ +from django.db import IntegrityError +from rest_framework.test import APITestCase + +from care.users.models import UserFlag +from care.utils.registries.feature_flag import FlagRegistry, FlagType +from care.utils.tests.test_utils import TestUtils + + +class UserFlagsTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + FlagRegistry.register(FlagType.USER, "TEST_FLAG_2") + cls.district = cls.create_district(cls.create_state()) + + def setUp(self) -> None: + self.user = self.create_user("user", self.district) + super().setUp() + + def test_user_flags(self): + UserFlag.objects.create(user=self.user, flag="TEST_FLAG") + self.assertTrue(UserFlag.check_user_has_flag(self.user.id, "TEST_FLAG")) + + def test_user_flags_negative(self): + self.assertFalse(UserFlag.check_user_has_flag(self.user.id, "TEST_FLAG")) + + def test_create_duplicate_flag(self): + UserFlag.objects.create(user=self.user, flag="TEST_FLAG") + with self.assertRaises(IntegrityError): + UserFlag.objects.create(user=self.user, flag="TEST_FLAG") + + def test_get_all_flags(self): + UserFlag.objects.create(user=self.user, flag="TEST_FLAG") + UserFlag.objects.create(user=self.user, flag="TEST_FLAG_2") + self.assertEqual( + UserFlag.get_all_flags(self.user.id), ("TEST_FLAG", "TEST_FLAG_2") + ) + + def test_get_user_flags_api(self): + UserFlag.objects.create(user=self.user, flag="TEST_FLAG") + UserFlag.objects.create(user=self.user, flag="TEST_FLAG_2") + response = self.client.get("/api/v1/users/getcurrentuser/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["user_flags"], ["TEST_FLAG", "TEST_FLAG_2"]) diff --git a/care/utils/models/base.py b/care/utils/models/base.py index 6e61afb567..f38fb5a501 100644 --- a/care/utils/models/base.py +++ b/care/utils/models/base.py @@ -1,7 +1,10 @@ from uuid import uuid4 +from django.core.cache import cache from django.db import models +from care.utils.registries.feature_flag import FlagName, FlagRegistry + class BaseManager(models.Manager): def get_queryset(self): @@ -33,3 +36,63 @@ class Meta: def delete(self, *args): self.deleted = True self.save(update_fields=["deleted"]) + + +FLAGS_CACHE_TTL = 60 * 60 * 24 # 1 Day + + +class BaseFlag(BaseModel): + flag = models.CharField(max_length=1024) + + cache_key_template = "" + all_flags_cache_key_template = "" + flag_type = None + entity_field_name = "" + + class Meta: + abstract = True + + @property + def entity(self): + return getattr(self, self.entity_field_name) + + @property + def entity_id(self): + return getattr(self, f"{self.entity_field_name}_id") + + @classmethod + def validate_flag(cls, flag_name: FlagName): + FlagRegistry.validate_flag_name(cls.flag_type, flag_name) + + def save(self, *args, **kwargs): + self.validate_flag(self.flag) + cache.delete( + self.cache_key_template.format( + entity_id=self.entity_id, flag_name=self.flag + ) + ) + cache.delete(self.all_flags_cache_key_template.format(entity_id=self.entity_id)) + return super().save(*args, **kwargs) + + @classmethod + def check_entity_has_flag(cls, entity_id: int, flag_name: FlagName) -> bool: + cls.validate_flag(flag_name) + return cache.get_or_set( + cls.cache_key_template.format(entity_id=entity_id, flag_name=flag_name), + default=lambda: cls.objects.filter( + **{f"{cls.entity_field_name}_id": entity_id, "flag": flag_name} + ).exists(), + timeout=FLAGS_CACHE_TTL, + ) + + @classmethod + def get_all_flags(cls, entity_id: int) -> tuple[FlagName]: + return cache.get_or_set( + cls.all_flags_cache_key_template.format(entity_id=entity_id), + default=lambda: tuple( + cls.objects.filter( + **{f"{cls.entity_field_name}_id": entity_id} + ).values_list("flag", flat=True) + ), + timeout=FLAGS_CACHE_TTL, + ) diff --git a/care/utils/registries/__init__.py b/care/utils/registries/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/registries/feature_flag.py b/care/utils/registries/feature_flag.py new file mode 100644 index 0000000000..dba42888e5 --- /dev/null +++ b/care/utils/registries/feature_flag.py @@ -0,0 +1,68 @@ +import enum +import logging +from typing import TypeAlias + +from django.core.exceptions import ValidationError + +logger = logging.getLogger(__name__) + + +class FlagNotFoundException(ValidationError): + pass + + +class FlagType(enum.Enum): + USER = "USER" + FACILITY = "FACILITY" + + +# TODO: convert to type in python 3.12 +FlagName = str +FlagTypeRegistry: TypeAlias = dict[FlagType, dict[FlagName, bool]] + + +class FlagRegistry: + _flags: FlagTypeRegistry = {} + + @classmethod + def register(cls, flag_type: FlagType, flag_name: FlagName) -> None: + if flag_type not in cls._flags: + cls._flags[flag_type] = {} + cls._flags[flag_type][flag_name] = True + + @classmethod + def unregister(cls, flag_type, flag_name) -> None: + try: + del cls._flags[flag_type][flag_name] + except KeyError as e: + logger.warning(f"Flag {flag_name} not found in {flag_type}: {e}") + + @classmethod + def register_wrapper(cls, flag_type, flag_name) -> None: + def inner_wrapper(wrapped_class): + cls.register(cls, flag_type, flag_name) + return wrapped_class + + return inner_wrapper + + @classmethod + def validate_flag_type(cls, flag_type: FlagType) -> None: + if flag_type not in cls._flags: + raise FlagNotFoundException("Invalid Flag Type") + + @classmethod + def validate_flag_name(cls, flag_type: FlagType, flag_name): + cls.validate_flag_type(flag_type) + if flag_name not in cls._flags[flag_type]: + raise FlagNotFoundException("Flag not registered") + + @classmethod + def get_all_flags(cls, flag_type: FlagType) -> list[FlagName]: + cls.validate_flag_type(flag_type) + return list(cls._flags[flag_type].keys()) + + @classmethod + def get_all_flags_as_choices( + cls, flag_type: FlagType + ) -> list[tuple[FlagName, FlagName]]: + return ((x, x) for x in cls._flags.get(flag_type, {}).keys()) diff --git a/care/utils/tests/test_feature_flags.py b/care/utils/tests/test_feature_flags.py new file mode 100644 index 0000000000..13324f20f6 --- /dev/null +++ b/care/utils/tests/test_feature_flags.py @@ -0,0 +1,68 @@ +from django.test import TestCase + +from care.utils.registries.feature_flag import ( + FlagNotFoundException, + FlagRegistry, + FlagType, +) + + +class FeatureFlagTestCase(TestCase): + def setUp(self): + FlagRegistry._flags = {} + super().setUp() + + def test_register_flag(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + self.assertTrue(FlagRegistry._flags[FlagType.USER]["TEST_FLAG"]) + FlagRegistry.register(FlagType.USER, "TEST_FLAG_2") + self.assertTrue(FlagRegistry._flags[FlagType.USER]["TEST_FLAG_2"]) + + def test_unregister_flag(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + self.assertTrue(FlagRegistry._flags[FlagType.USER]["TEST_FLAG"]) + FlagRegistry.unregister(FlagType.USER, "TEST_FLAG") + self.assertFalse(FlagRegistry._flags[FlagType.USER].get("TEST_FLAG")) + + def test_unregister_flag_not_found(self): + FlagRegistry.unregister(FlagType.USER, "TEST_FLAG") + self.assertEqual(FlagRegistry._flags, {}) + + def test_validate_flag_type(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + self.assertIsNone(FlagRegistry.validate_flag_type(FlagType.USER)) + + def test_validate_flag_type_invalid(self): + with self.assertRaises(FlagNotFoundException): + FlagRegistry.validate_flag_type( + FlagType.USER + ) # FlagType.USER is not registered + + def test_validate_flag_name(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + self.assertIsNone(FlagRegistry.validate_flag_name(FlagType.USER, "TEST_FLAG")) + + def test_validate_flag_name_invalid(self): + with self.assertRaises(FlagNotFoundException) as ectx: + FlagRegistry.validate_flag_name(FlagType.USER, "TEST_OTHER_FLAG") + self.assertEqual(ectx.exception.message, "Invalid Flag Type") + + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + with self.assertRaises(FlagNotFoundException) as ectx: + FlagRegistry.validate_flag_name(FlagType.USER, "TEST_OTHER_FLAG") + self.assertEqual(ectx.exception.message, "Flag not registered") + + def test_get_all_flags(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + FlagRegistry.register(FlagType.USER, "TEST_FLAG_2") + self.assertEqual( + FlagRegistry.get_all_flags(FlagType.USER), ["TEST_FLAG", "TEST_FLAG_2"] + ) + + def test_get_all_flags_as_choices(self): + FlagRegistry.register(FlagType.USER, "TEST_FLAG") + FlagRegistry.register(FlagType.USER, "TEST_FLAG_2") + self.assertEqual( + list(FlagRegistry.get_all_flags_as_choices(FlagType.USER)), + [("TEST_FLAG", "TEST_FLAG"), ("TEST_FLAG_2", "TEST_FLAG_2")], + ) diff --git a/config/urls.py b/config/urls.py index 4d112c686a..923c8413ce 100644 --- a/config/urls.py +++ b/config/urls.py @@ -40,7 +40,7 @@ path("ping/", ping, name="ping"), path("app_version/", app_version, name="app_version"), # Django Admin, use {% url 'admin:index' %} - path(settings.ADMIN_URL, admin.site.urls), + path(f"{settings.ADMIN_URL}/", admin.site.urls), # Rest API path("api/v1/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path(