diff --git a/care/facility/admin.py b/care/facility/admin.py index e29ea0902c..14a7f2a63e 100644 --- a/care/facility/admin.py +++ b/care/facility/admin.py @@ -1,6 +1,6 @@ +from django import forms from django.contrib import admin from django.contrib.admin import SimpleListFilter -from django import forms from djangoql.admin import DjangoQLSearchMixin from djqscsv import render_to_csv_response @@ -13,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, @@ -34,9 +36,8 @@ PatientInvestigationGroup, PatientRegistration, Room, - StaffRoomAllocation, FacilityFlag, + StaffRoomAllocation, ) -from .registries.feature_flag import FlagRegistry, FlagType class BuildingAdmin(admin.ModelAdmin): @@ -190,16 +191,11 @@ class FacilityUserAdmin(DjangoQLSearchMixin, admin.ModelAdmin, ExportCsvMixin): actions = ["export_as_csv"] -def get_facility_flags(): - try: - return ((x,x) for x in FlagRegistry.get_all_flags(FlagType.FACILITY)) - except: - return () - class FacilityFlagAdmin(admin.ModelAdmin): - class AddHorribleTableEntryForm(forms.ModelForm): - flag = forms.ChoiceField(choices=get_facility_flags) + flag = forms.ChoiceField( + choices=lambda: FlagRegistry.get_all_flags_as_choices(FlagType.FACILITY) + ) class Meta: fields = "__all__" @@ -237,4 +233,5 @@ class Meta: 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..17ade57db4 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 = [ diff --git a/care/facility/migrations/0455_facilityflag.py b/care/facility/migrations/0455_facilityflag_facilityflag_unique_facility_flag.py similarity index 80% rename from care/facility/migrations/0455_facilityflag.py rename to care/facility/migrations/0455_facilityflag_facilityflag_unique_facility_flag.py index 9fd40d73b3..3a3ad0b368 100644 --- a/care/facility/migrations/0455_facilityflag.py +++ b/care/facility/migrations/0455_facilityflag_facilityflag_unique_facility_flag.py @@ -1,9 +1,10 @@ -# Generated by Django 4.2.10 on 2024-09-06 12:20 +# Generated by Django 4.2.15 on 2024-09-10 14:07 -from django.db import migrations, models -import django.db.models.deletion import uuid +import django.db.models.deletion +from django.db import migrations, models + class Migration(migrations.Migration): @@ -47,7 +48,15 @@ class Migration(migrations.Migration): ), ], options={ - "abstract": False, + "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/facility.py b/care/facility/models/facility.py index 53bcac81ea..805b624b8e 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -8,6 +8,7 @@ from simple_history.models import HistoricalRecords from care.facility.models import FacilityBaseModel, reverse_choices +from care.facility.models.feature_flag import FacilityFlag from care.facility.models.mixins.permissions.facility import ( FacilityPermissionMixin, FacilityRelatedPermissionMixin, @@ -282,6 +283,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/feature_flag.py b/care/facility/models/feature_flag.py index 3f1253a37d..3a9afef9d0 100644 --- a/care/facility/models/feature_flag.py +++ b/care/facility/models/feature_flag.py @@ -1,34 +1,79 @@ -from care.facility.registries.feature_flag import FlagRegistry, FlagType +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.db import models + from care.utils.models.base import BaseModel +from care.utils.registries.feature_flag import FlagName, FlagRegistry, FlagType -from django.db import models +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(BaseModel): facility = models.ForeignKey( - "Facility", on_delete=models.CASCADE, null=False, blank=False + "facility.Facility", on_delete=models.CASCADE, null=False, blank=False ) flag = models.CharField(max_length=1024) - class Meta: - verbose_name = "Facility Flag" - - def __str__(self): - return f"{self.facility.name} - {self.flag}" - @classmethod - def validate_flag(cls, flag_name): + def validate_flag(cls, flag_name: FlagName): FlagRegistry.validate_flag_name(FlagType.FACILITY, flag_name) - def save( - self, *args, **kwargs - ): + def save(self, *args, **kwargs): self.validate_flag(self.flag) - # TODO : Add Unique Together + + if ( + not self.deleted + and self.__class__.objects.filter( + facility_id=self.facility_id, flag=self.flag + ).exists() + ): + raise ValidationError("Flag Already Exists") + + cache.delete( + FACILITY_FLAG_CACHE_KEY.format( + facility_id=self.facility_id, flag_name=self.flag + ) + ) + cache.delete(FACILITY_ALL_FLAGS_CACHE_KEY.format(facility_id=self.facility_id)) + return super().save(*args, **kwargs) @classmethod - def check_facility_has_flag(cls , facility , flag_name): + def check_facility_has_flag(cls, facility_id: int, flag_name: FlagName) -> bool: cls.validate_flag(flag_name) - # TODO : Add Caching , Invalidate on save actions - return cls.objects.filter(facility=facility, flag=flag_name).exists() + return cache.get_or_set( + FACILITY_FLAG_CACHE_KEY.format( + facility_id=facility_id, flag_name=flag_name + ), + default=lambda: cls.objects.filter( + facility_id=facility_id, flag=flag_name + ).exists(), + timeout=FACILITY_FLAG_CACHE_TTL, + ) + + @classmethod + def get_all_flags(cls, facility_id: int) -> tuple[FlagName]: + return cache.get_or_set( + FACILITY_ALL_FLAGS_CACHE_KEY.format(facility_id=facility_id), + default=lambda: tuple( + cls.objects.filter(facility_id=facility_id).values_list( + "flag", flat=True + ) + ), + timeout=FACILITY_FLAG_CACHE_TTL, + ) + + 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", + ) + ] diff --git a/care/facility/registries/feature_flag.py b/care/facility/registries/feature_flag.py deleted file mode 100644 index e53cb03bb5..0000000000 --- a/care/facility/registries/feature_flag.py +++ /dev/null @@ -1,49 +0,0 @@ -import enum -from django.core.exceptions import ValidationError - - -class FlagNotFoundException(ValidationError): - pass - - -class FlagType(enum.Enum): - USER = "USER" - FACILITY = "FACILITY" - - -class FlagRegistry: - _flags = {} - - @classmethod - def register(cls, flag_type, flag_name): - if flag_type not in FlagType: - return - if flag_type not in cls._flags: - cls._flags[flag_type] = {} - cls._flags[flag_type][flag_name] = True # Using a dict to avoid duplication ( sets are also fine ) - - @classmethod - def register_wrapper(cls, flag_type, flag_name): - 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): - if flag_type not in FlagType or flag_type not in cls._flags: - raise FlagNotFoundException("Invalid Flag Type") - - @classmethod - def get_all_flags(cls, flag_type: FlagType): - cls.validate_flag_type(flag_type) - return list(cls._flags[flag_type].keys()) - - @classmethod - def validate_flag_name(cls, flag_type, flag_name): - cls.validate_flag_type(flag_type) - if flag_name not in cls._flags[flag_type]: - raise FlagNotFoundException("Flag not registered") - -FlagRegistry.register(FlagType.FACILITY, "Test Flag") # To be Removed diff --git a/care/users/admin.py b/care/users/admin.py index 32b64980dd..6164b1f57d 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 AddHorribleTableEntryForm(forms.ModelForm): + flag = forms.ChoiceField( + choices=lambda: FlagRegistry.get_all_flags_as_choices(FlagType.USER) + ) + + class Meta: + fields = "__all__" + model = UserFlag + form = AddHorribleTableEntryForm + + +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_userflag_unique_user_flag.py b/care/users/migrations/0017_userflag_userflag_unique_user_flag.py new file mode 100644 index 0000000000..c264dfc260 --- /dev/null +++ b/care/users/migrations/0017_userflag_userflag_unique_user_flag.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.15 on 2024-09-10 14:07 + +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", + }, + ), + migrations.AddConstraint( + model_name="userflag", + constraint=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..6534c79cb5 100644 --- a/care/users/models.py +++ b/care/users/models.py @@ -2,6 +2,8 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager +from django.core.cache import cache +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse @@ -14,6 +16,11 @@ mobile_or_landline_number_validator, mobile_validator, ) +from care.utils.registries.feature_flag import FlagName, FlagRegistry, 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 +375,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 +401,64 @@ class UserFacilityAllocation(models.Model): ) start_date = models.DateTimeField(default=now) end_date = models.DateTimeField(null=True, blank=True) + + +class UserFlag(BaseModel): + user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False) + flag = models.CharField(max_length=1024) + + @classmethod + def validate_flag(cls, flag_name) -> None: + FlagRegistry.validate_flag_name(FlagType.USER, flag_name) + + def save(self, *args, **kwargs) -> None: + self.validate_flag(self.flag) + + if ( + not self.deleted + and self.__class__.objects.filter( + user_id=self.user_id, flag=self.flag + ).exists() + ): + raise ValidationError("Flag Already Exists") + + cache.delete( + USER_FLAG_CACHE_KEY.format(user_id=self.user_id, flag_name=self.flag) + ) + cache.delete(USER_ALL_FLAGS_CACHE_KEY.format(user_id=self.user_id)) + + return super().save(*args, **kwargs) + + @classmethod + def check_user_has_flag(cls, user_id: int, flag_name: FlagName) -> bool: + cls.validate_flag(flag_name) + return cache.get_or_set( + USER_FLAG_CACHE_KEY.format(user_id=user_id, flag_name=flag_name), + default=lambda: cls.objects.filter( + user_id=user_id, flag=flag_name + ).exists(), + timeout=USER_FLAG_CACHE_TTL, + ) + + @classmethod + def get_all_flags(cls, user_id: int) -> tuple[FlagName]: + return cache.get_or_set( + USER_ALL_FLAGS_CACHE_KEY.format(user_id=user_id), + default=lambda: tuple( + cls.objects.filter(user_id=user_id).values_list("flag", flat=True) + ), + timeout=USER_FLAG_CACHE_TTL, + ) + + 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", + ) + ] diff --git a/care/facility/registries/__init__.py b/care/utils/registries/__init__.py similarity index 100% rename from care/facility/registries/__init__.py rename to care/utils/registries/__init__.py diff --git a/care/utils/registries/feature_flag.py b/care/utils/registries/feature_flag.py new file mode 100644 index 0000000000..c672c5c6b1 --- /dev/null +++ b/care/utils/registries/feature_flag.py @@ -0,0 +1,73 @@ +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 FlagType: + return + 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 FlagType or flag_type not in cls._flags: + raise FlagNotFoundException("Invalid Flag Type") + + @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()) + + @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") + + +FlagRegistry.register(FlagType.USER, "USER_TEST_FLAG") 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(