diff --git a/care/emr/api/viewsets/facility.py b/care/emr/api/viewsets/facility.py index c4d53aff5c..1558294f6c 100644 --- a/care/emr/api/viewsets/facility.py +++ b/care/emr/api/viewsets/facility.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.db.models import Q from django.utils.decorators import method_decorator from django_filters import CharFilter, FilterSet, NumberFilter @@ -11,11 +12,22 @@ from rest_framework.parsers import MultiPartParser from rest_framework.response import Response -from care.emr.api.viewsets.base import EMRModelReadOnlyViewSet, EMRModelViewSet +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRDestroyMixin, + EMRListMixin, + EMRModelReadOnlyViewSet, + EMRModelViewSet, + EMRRetrieveMixin, +) from care.emr.models import Organization, SchedulableResource +from care.emr.models.facility import FacilityFlag from care.emr.models.organization import FacilityOrganizationUser, OrganizationUser from care.emr.resources.facility.spec import ( FacilityCreateSpec, + FacilityFlagCreateSpec, + FacilityFlagReadSpec, FacilityInvoiceExpressionSpec, FacilityMinimalReadSpec, FacilityMonetaryCodeSpec, @@ -31,6 +43,7 @@ cover_image_validator, custom_image_extension_validator, ) +from care.utils.registries.feature_flag import FlagNotFoundError, FlagRegistry, FlagType from care.utils.shortcuts import get_object_or_404 @@ -218,3 +231,54 @@ class AllFacilityViewSet(EMRModelReadOnlyViewSet): def get_queryset(self): return Facility.objects.filter(is_public=True).select_related() + + +class FacilityFlagFilter(filters.FilterSet): + flag = filters.CharFilter(field_name="flag", lookup_expr="exact") + facility = filters.UUIDFilter(field_name="facility__external_id") + + +class FacilityFlagViewSet( + EMRDestroyMixin, EMRCreateMixin, EMRRetrieveMixin, EMRListMixin, EMRBaseViewSet +): + database_model = FacilityFlag + pydantic_model = FacilityFlagCreateSpec + pydantic_read_model = FacilityFlagReadSpec + filter_backends = [filters.DjangoFilterBackend] + filterset_class = FacilityFlagFilter + + def permissions_controller(self, request): + return request.user.is_superuser + + def perform_create(self, instance): + with transaction.atomic(): + FlagRegistry.register(FlagType.FACILITY.value, instance.flag) + super().perform_create(instance) + + def perform_destroy(self, instance): + with transaction.atomic(): + super().perform_destroy(instance) + FacilityFlag.invalidate_cache(instance.facility, instance.flag) + transaction.on_commit( + lambda: self._safe_unregister_flag_if_unused(instance.flag, instance.id) + ) + + def _safe_unregister_flag_if_unused(self, flag_name: str, deleted_instance_id: int): + still_used = ( + FacilityFlag.objects.filter(flag=flag_name, deleted=False) + .exclude(id=deleted_instance_id) + .exists() + ) + + if not still_used: + FlagRegistry.unregister(FlagType.FACILITY.value, flag_name) + + @action(detail=False, methods=["get"], url_path="available-flags") + def list_available_flags(self, request): + try: + flags = FlagRegistry.get_all_flags(FlagType.FACILITY.value) + return Response({"available_flags": list(flags)}) + except FlagNotFoundError: + return Response( + {"message": "No registered flag type 'facility' found."}, status=400 + ) diff --git a/care/emr/api/viewsets/user.py b/care/emr/api/viewsets/user.py index 0e2d2cd6e2..b3f8c5d338 100644 --- a/care/emr/api/viewsets/user.py +++ b/care/emr/api/viewsets/user.py @@ -10,13 +10,23 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from care.emr.api.viewsets.base import EMRModelViewSet +from care.emr.api.viewsets.base import ( + EMRBaseViewSet, + EMRCreateMixin, + EMRDestroyMixin, + EMRListMixin, + EMRModelViewSet, + EMRRetrieveMixin, +) from care.emr.models import Organization from care.emr.models.organization import OrganizationUser +from care.emr.models.user import UserFlag from care.emr.resources.common.mail_type import MailTypeChoices from care.emr.resources.user.spec import ( CurrentUserRetrieveSpec, UserCreateSpec, + UserFlagCreateSpec, + UserFlagReadSpec, UserRetrieveSpec, UserSpec, UserTypeRoleMapping, @@ -31,6 +41,7 @@ cover_image_validator, custom_image_extension_validator, ) +from care.utils.registries.feature_flag import FlagNotFoundError, FlagRegistry, FlagType class UserImageUploadSerializer(serializers.ModelSerializer): @@ -192,3 +203,54 @@ def pnconfig(self, request, *args, **kwargs): setattr(user, field, request.data[field]) user.save() return Response({}) + + +class UserFlagFilter(filters.FilterSet): + flag = filters.CharFilter(field_name="flag", lookup_expr="exact") + user = filters.UUIDFilter(field_name="user__external_id") + + +class UserFlagViewSet( + EMRDestroyMixin, EMRCreateMixin, EMRRetrieveMixin, EMRListMixin, EMRBaseViewSet +): + database_model = UserFlag + pydantic_model = UserFlagCreateSpec + pydantic_read_model = UserFlagReadSpec + filter_backends = [filters.DjangoFilterBackend] + filterset_class = UserFlagFilter + + def permissions_controller(self, request): + return request.user.is_superuser + + def perform_create(self, instance): + with transaction.atomic(): + FlagRegistry.register(FlagType.USER.value, instance.flag) + super().perform_create(instance) + + def perform_destroy(self, instance): + with transaction.atomic(): + super().perform_destroy(instance) + UserFlag.invalidate_cache(instance.user, instance.flag) + transaction.on_commit( + lambda: self._safe_unregister_flag_if_unused(instance.flag, instance.id) + ) + + def _safe_unregister_flag_if_unused(self, flag_name: str, deleted_instance_id: int): + still_used = ( + UserFlag.objects.filter(flag=flag_name) + .exclude(id=deleted_instance_id) + .exists() + ) + + if not still_used: + FlagRegistry.unregister(FlagType.USER.value, flag_name) + + @action(detail=False, methods=["get"], url_path="available-flags") + def list_available_flags(self, request): + try: + flags = FlagRegistry.get_all_flags(FlagType.USER.value) + return Response({"available_flags": list(flags)}) + except FlagNotFoundError: + return Response( + {"message": "No registered flag type 'user' found."}, status=400 + ) diff --git a/care/emr/migrations/0030_facilityflag_userflag.py b/care/emr/migrations/0030_facilityflag_userflag.py new file mode 100644 index 0000000000..6922f494f8 --- /dev/null +++ b/care/emr/migrations/0030_facilityflag_userflag.py @@ -0,0 +1,58 @@ +# Generated by Django 5.1.4 on 2025-05-15 15:33 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emr', '0029_encounter_discharge_summary_advice'), + ('facility', '0477_delete_facilityflag'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + 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)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('flag', models.CharField(max_length=1024)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='facility.facility')), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Facility Flag', + 'constraints': [models.UniqueConstraint(condition=models.Q(('deleted', False)), fields=('facility', 'flag'), name='emr_unique_facility_flag')], + }, + ), + 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)), + ('history', models.JSONField(default=dict)), + ('meta', models.JSONField(default=dict)), + ('flag', models.CharField(max_length=1024)), + ('created_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_created_by', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL)), + ('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='emr_unique_user_flag')], + }, + ), + ] diff --git a/care/emr/models/base.py b/care/emr/models/base.py index c93d90e2ca..42ebb0f187 100644 --- a/care/emr/models/base.py +++ b/care/emr/models/base.py @@ -1,8 +1,10 @@ import uuid +from django.core.cache import cache from django.db import models from care.utils.models.base import BaseModel +from care.utils.registries.feature_flag import FlagName, FlagRegistry class EMRBaseModel(BaseModel): @@ -29,6 +31,73 @@ class Meta: abstract = True +FLAGS_CACHE_TTL = 60 * 60 * 24 # 1 Day + + +class BaseFlag(EMRBaseModel): + flag = models.CharField(max_length=1024) + + cache_key_template = "" + all_flags_cache_key_template = "" + flag_type = None + entity_field_name = "" + + class Meta: + abstract = True + + 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) + + @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) + + @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, + ) + + @classmethod + def invalidate_cache(cls, entity_id: int, flag_name: str): + cache.delete( + cls.cache_key_template.format(entity_id=entity_id, flag_name=flag_name) + ) + cache.delete(cls.all_flags_cache_key_template.format(entity_id=entity_id)) + + class SlugBaseModel(EMRBaseModel): FACILITY_SCOPED = True diff --git a/care/facility/models/facility_flag.py b/care/emr/models/facility.py similarity index 77% rename from care/facility/models/facility_flag.py rename to care/emr/models/facility.py index e5cf3b4441..d515974054 100644 --- a/care/facility/models/facility_flag.py +++ b/care/emr/models/facility.py @@ -1,12 +1,8 @@ from django.db import models -from care.utils.models.base import BaseFlag +from care.emr.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( @@ -15,7 +11,7 @@ class FacilityFlag(BaseFlag): 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 + flag_type = FlagType.FACILITY.value entity_field_name = "facility" def __str__(self) -> str: @@ -27,7 +23,7 @@ class Meta: models.UniqueConstraint( fields=["facility", "flag"], condition=models.Q(deleted=False), - name="unique_facility_flag", + name="emr_unique_facility_flag", ) ] diff --git a/care/emr/models/file_upload.py b/care/emr/models/file_upload.py index afa394d7e5..d99181aaf9 100644 --- a/care/emr/models/file_upload.py +++ b/care/emr/models/file_upload.py @@ -5,7 +5,6 @@ from care.emr.models import EMRBaseModel from care.emr.utils.file_manager import S3FilesManager -from care.users.models import User from care.utils.csp.config import BucketType from care.utils.models.validators import parse_file_extension @@ -23,7 +22,7 @@ class FileUpload(EMRBaseModel): archive_reason = models.TextField(blank=True) archived_datetime = models.DateTimeField(blank=True, null=True) archived_by = models.ForeignKey( - User, + "users.User", on_delete=models.PROTECT, null=True, blank=True, diff --git a/care/emr/models/patient.py b/care/emr/models/patient.py index 0cd8c6230b..4aa49d116d 100644 --- a/care/emr/models/patient.py +++ b/care/emr/models/patient.py @@ -9,7 +9,6 @@ from care.emr.models import EMRBaseModel from care.emr.resources.base import model_from_cache -from care.users.models import User from care.utils.models.validators import mobile_or_landline_number_validator @@ -148,7 +147,7 @@ class PatientUser(EMRBaseModel): Add a user that can access the patient """ - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey("users.User", on_delete=models.CASCADE) patient = models.ForeignKey(Patient, on_delete=models.CASCADE) role = models.ForeignKey("security.RoleModel", on_delete=models.PROTECT) diff --git a/care/emr/models/scheduling/booking.py b/care/emr/models/scheduling/booking.py index f65aae0ee8..32be8ed6ec 100644 --- a/care/emr/models/scheduling/booking.py +++ b/care/emr/models/scheduling/booking.py @@ -3,7 +3,6 @@ from care.emr.models import EMRBaseModel from care.emr.models.scheduling.schedule import Availability, SchedulableResource -from care.users.models import User class TokenSlot(EMRBaseModel): @@ -30,7 +29,9 @@ class TokenBooking(EMRBaseModel): blank=False, ) booked_on = models.DateTimeField(auto_now_add=True) - booked_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + booked_by = models.ForeignKey( + "users.User", on_delete=models.CASCADE, null=True, blank=True + ) status = models.CharField() note = models.TextField(null=True, blank=True) tags = ArrayField(models.IntegerField(), default=list) diff --git a/care/emr/models/user.py b/care/emr/models/user.py new file mode 100644 index 0000000000..22b5ea82ed --- /dev/null +++ b/care/emr/models/user.py @@ -0,0 +1,36 @@ +from django.db import models + +from care.emr.models.base import BaseFlag +from care.utils.registries.feature_flag import FlagName, FlagType + + +class UserFlag(BaseFlag): + user = models.ForeignKey( + "users.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.value + 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="emr_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/emr/resources/facility/spec.py b/care/emr/resources/facility/spec.py index 6035f4b98d..e9e4b94d50 100644 --- a/care/emr/resources/facility/spec.py +++ b/care/emr/resources/facility/spec.py @@ -2,10 +2,12 @@ from django.conf import settings from django.db.models.functions import Lower, Trim +from django.shortcuts import get_object_or_404 from pydantic import UUID4, BaseModel, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo from care.emr.models import Organization +from care.emr.models.facility import FacilityFlag from care.emr.models.patient import PatientIdentifierConfigCache from care.emr.resources.base import EMRResource, cacheable from care.emr.resources.common.coding import Coding @@ -21,6 +23,7 @@ REVERSE_REVERSE_FACILITY_TYPES, Facility, ) +from care.utils.registries.feature_flag import FlagName, FlagNotFoundError @cacheable(use_base_manager=True) @@ -152,6 +155,44 @@ def perform_extra_serialization(cls, mapping, obj): mapping["instance_informational_codes"] = settings.INFORMATIONAL_MONETARY_CODES +class FacilityFlagBaseSpec(EMRResource): + __model__ = FacilityFlag + __exclude__ = ["facility"] + + id: UUID4 | None = None + + +class FacilityFlagCreateSpec(FacilityFlagBaseSpec): + flag: FlagName + facility: UUID4 + + @model_validator(mode="after") + def validate_flag(self): + try: + if FacilityFlag.check_facility_has_flag( + get_object_or_404(Facility, external_id=self.facility).id, self.flag + ): + raise ValueError("Facility already has this flag") + except FlagNotFoundError: + pass + return self + + def perform_extra_deserialization(self, is_update, obj): + if not is_update: + obj.facility = get_object_or_404(Facility, external_id=self.facility) + + +class FacilityFlagReadSpec(FacilityFlagBaseSpec): + facility: dict = {} + flag: str + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["facility"] = FacilityReadSpec.serialize(obj.facility).to_json() + mapping["flag"] = obj.flag + mapping["id"] = obj.external_id + + class FacilityMonetaryCodeSpec(EMRResource): __model__ = Facility __exclude__ = [] diff --git a/care/emr/resources/user/spec.py b/care/emr/resources/user/spec.py index 687cf19a9f..8b53625aaa 100644 --- a/care/emr/resources/user/spec.py +++ b/care/emr/resources/user/spec.py @@ -2,12 +2,12 @@ from enum import Enum from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError from django.core.validators import validate_email -from pydantic import UUID4, BaseModel, Field, field_validator +from pydantic import UUID4, BaseModel, Field, field_validator, model_validator from care.emr.models import Organization from care.emr.models.organization import FacilityOrganizationUser, OrganizationUser +from care.emr.models.user import UserFlag from care.emr.resources.base import EMRResource, cacheable, model_from_cache from care.emr.resources.patient.spec import GenderChoices from care.facility.models.facility import Facility @@ -20,6 +20,7 @@ VOLUNTEER_ROLE, ) from care.users.models import User +from care.utils.registries.feature_flag import FlagName, FlagNotFoundError from care.utils.shortcuts import get_object_or_404 @@ -101,7 +102,7 @@ def validate_user_email(cls, email): raise ValueError("Email already exists") try: validate_email(email) - except ValidationError as e: + except ValueError as e: raise ValueError("Invalid Email") from e return email @@ -226,6 +227,44 @@ def perform_extra_serialization(cls, mapping, obj: User): mapping["profile_picture_url"] = obj.read_profile_picture_url() +class UserFlagBaseSpec(EMRResource): + __model__ = UserFlag + __exclude__ = ["user"] + + id: UUID4 | None = None + + +class UserFlagCreateSpec(UserFlagBaseSpec): + flag: FlagName + user: UUID4 + + @model_validator(mode="after") + def validate_flag(self): + try: + if UserFlag.check_user_has_flag( + get_object_or_404(User, external_id=self.user).id, self.flag + ): + raise ValueError("User already has this flag") + except FlagNotFoundError: + pass + return self + + def perform_extra_deserialization(self, is_update, obj): + if not is_update: + obj.user = get_object_or_404(User, external_id=self.user) + + +class UserFlagReadSpec(UserFlagBaseSpec): + user: dict = {} + flag: str + + @classmethod + def perform_extra_serialization(cls, mapping, obj): + mapping["user"] = UserRetrieveSpec.serialize(obj.user).to_json() + mapping["flag"] = obj.flag + mapping["id"] = obj.external_id + + class ResetPasswordCheckRequest(BaseModel): token: str diff --git a/care/facility/migrations/0477_delete_facilityflag.py b/care/facility/migrations/0477_delete_facilityflag.py new file mode 100644 index 0000000000..b3f92e3e5c --- /dev/null +++ b/care/facility/migrations/0477_delete_facilityflag.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.4 on 2025-05-15 15:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('facility', '0476_facility_default_internal_organization_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='FacilityFlag', + ), + ] diff --git a/care/facility/models/__init__.py b/care/facility/models/__init__.py index 5bae44b627..1d0be7de97 100644 --- a/care/facility/models/__init__.py +++ b/care/facility/models/__init__.py @@ -9,7 +9,6 @@ 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 de5f489f59..44a36a837b 100644 --- a/care/facility/models/facility.py +++ b/care/facility/models/facility.py @@ -10,9 +10,9 @@ from simple_history.models import HistoricalRecords from care.emr.models import FacilityOrganization +from care.emr.models.facility import FacilityFlag from care.emr.models.organization import FacilityOrganizationUser 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, diff --git a/care/users/admin.py b/care/users/admin.py index 3f0a7f9f5c..1871c3c88f 100644 --- a/care/users/admin.py +++ b/care/users/admin.py @@ -4,13 +4,13 @@ from django.contrib.auth import get_user_model from djqscsv import render_to_csv_response +from care.emr.models.user import UserFlag from care.users.forms import UserChangeForm, UserCreationForm from care.users.models import ( District, LocalBody, Skill, State, - UserFlag, UserSkill, Ward, ) diff --git a/care/users/migrations/0024_delete_userflag.py b/care/users/migrations/0024_delete_userflag.py new file mode 100644 index 0000000000..390793278c --- /dev/null +++ b/care/users/migrations/0024_delete_userflag.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.4 on 2025-05-15 15:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0023_user_prefix_user_suffix'), + ] + + operations = [ + migrations.DeleteModel( + name='UserFlag', + ), + ] diff --git a/care/users/models.py b/care/users/models.py index 1bae53d230..3225ec3a4b 100644 --- a/care/users/models.py +++ b/care/users/models.py @@ -10,13 +10,13 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from care.utils.models.base import BaseFlag, BaseModel +from care.emr.models.user import UserFlag +from care.utils.models.base import 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}" @@ -458,33 +458,3 @@ class PlugConfig(models.Model): def __str__(self): return self.slug - - -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/utils/models/base.py b/care/utils/models/base.py index 62da9f4f50..27b5128916 100644 --- a/care/utils/models/base.py +++ b/care/utils/models/base.py @@ -1,10 +1,7 @@ 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): @@ -30,63 +27,3 @@ 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 - - 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) - - @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) - - @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/config/api_router.py b/config/api_router.py index 674e3c9424..11aca2441d 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -24,6 +24,7 @@ from care.emr.api.viewsets.encounter import EncounterViewSet from care.emr.api.viewsets.facility import ( AllFacilityViewSet, + FacilityFlagViewSet, FacilitySchedulableUsersViewSet, FacilityUsersViewSet, FacilityViewSet, @@ -99,7 +100,7 @@ from care.emr.api.viewsets.specimen_definition import SpecimenDefinitionViewSet from care.emr.api.viewsets.tag_config import TagConfigViewSet from care.emr.api.viewsets.totp import TOTPViewSet -from care.emr.api.viewsets.user import UserViewSet +from care.emr.api.viewsets.user import UserFlagViewSet, UserViewSet from care.emr.api.viewsets.valueset import ValueSetViewSet from care.security.api.viewsets.permissions import PermissionViewSet from care.security.api.viewsets.roles import RoleViewSet @@ -486,6 +487,9 @@ basename="note", ) +router.register(r"facility_flags", FacilityFlagViewSet, basename="facility-flags") +router.register(r"user_flags", UserFlagViewSet, basename="user-flags") + app_name = "api" urlpatterns = [ path("", include(router.urls)),