From 67c63d93b78b257fb861799d68193583e982067d Mon Sep 17 00:00:00 2001 From: Vignesh Hari <14056798+vigneshhari@users.noreply.github.com> Date: Wed, 30 Oct 2024 16:04:20 +0530 Subject: [PATCH 1/8] New RBAC Implementation (#2515) RBAC Implementation --- care/security/__init__.py | 0 care/security/apps.py | 11 +++ care/security/authorization/__init__.py | 0 care/security/authorization/base.py | 88 +++++++++++++++++++ care/security/authorization/facility.py | 22 +++++ care/security/management/__init__.py | 0 care/security/management/commands/__init__.py | 0 .../commands/sync_permissions_roles.py | 65 ++++++++++++++ care/security/migrations/0001_initial.py | 88 +++++++++++++++++++ care/security/migrations/__init__.py | 0 care/security/models/__init__.py | 3 + care/security/models/permission.py | 18 ++++ .../security/models/permission_association.py | 22 +++++ care/security/models/role.py | 45 ++++++++++ care/security/permissions/__init__.py | 0 care/security/permissions/base.py | 83 +++++++++++++++++ care/security/permissions/facility.py | 19 ++++ care/security/roles/__init__.py | 0 care/security/roles/role.py | 69 +++++++++++++++ config/settings/base.py | 1 + 20 files changed, 534 insertions(+) create mode 100644 care/security/__init__.py create mode 100644 care/security/apps.py create mode 100644 care/security/authorization/__init__.py create mode 100644 care/security/authorization/base.py create mode 100644 care/security/authorization/facility.py create mode 100644 care/security/management/__init__.py create mode 100644 care/security/management/commands/__init__.py create mode 100644 care/security/management/commands/sync_permissions_roles.py create mode 100644 care/security/migrations/0001_initial.py create mode 100644 care/security/migrations/__init__.py create mode 100644 care/security/models/__init__.py create mode 100644 care/security/models/permission.py create mode 100644 care/security/models/permission_association.py create mode 100644 care/security/models/role.py create mode 100644 care/security/permissions/__init__.py create mode 100644 care/security/permissions/base.py create mode 100644 care/security/permissions/facility.py create mode 100644 care/security/roles/__init__.py create mode 100644 care/security/roles/role.py diff --git a/care/security/__init__.py b/care/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/apps.py b/care/security/apps.py new file mode 100644 index 0000000000..9f29b9bd7a --- /dev/null +++ b/care/security/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class SecurityConfig(AppConfig): + name = "care.security" + verbose_name = _("Security Management") + + def ready(self): + # import care.security.signals # noqa F401 + pass diff --git a/care/security/authorization/__init__.py b/care/security/authorization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/authorization/base.py b/care/security/authorization/base.py new file mode 100644 index 0000000000..36db9c022e --- /dev/null +++ b/care/security/authorization/base.py @@ -0,0 +1,88 @@ +from care.security.permissions.base import PermissionController + + +class PermissionDeniedError(Exception): + pass + + +class AuthorizationHandler: + """ + This is the base class for Authorization Handlers + Authorization handler must define a list of actions that can be performed and define the methods that + actually perform the authorization action. + + All Authz methods would be of the signature ( user, obj , **kwargs ) + obj refers to the obj which the user is seeking permission to. obj can also be a string or any datatype as long + as the logic can handle the type. + + Queries are actions that return a queryset as the response. + """ + + actions = [] + queries = [] + + def check_permission(self, user, obj): + if not PermissionController.has_permission(user, obj): + raise PermissionDeniedError + + return PermissionController.has_permission(user, obj) + + +class AuthorizationController: + """ + This class abstracts all security related operations in care + This includes Checking if A has access to resource X, + Filtering query-sets for list based operations and so on. + Security Controller implicitly caches all cachable operations and expects it to be invalidated. + + SecurityController maintains a list of override Classes, When present, + The override classes are invoked first and then the predefined classes. + The overridden classes can choose to call the next function in the hierarchy if needed. + """ + + override_authz_controllers: list[ + AuthorizationHandler + ] = [] # The order is important + # Override Security Controllers will be defined from plugs + internal_authz_controllers: list[AuthorizationHandler] = [] + + cache = {} + + @classmethod + def build_cache(cls): + for controller in ( + cls.internal_authz_controllers + cls.override_authz_controllers + ): + for action in controller.actions: + if "actions" not in cls.cache: + cls.cache["actions"] = {} + cls.cache["actions"][action] = [ + *cls.cache["actions"].get(action, []), + controller, + ] + + @classmethod + def get_action_controllers(cls, action): + return cls.cache["actions"].get(action, []) + + @classmethod + def check_action_permission(cls, action, user, obj): + """ + TODO: Add Caching and capability to remove cache at both user and obj level + """ + if not cls.cache: + cls.build_cache() + controllers = cls.get_action_controllers(action) + for controller in controllers: + permission_fn = getattr(controller, action) + result, _continue = permission_fn(user, obj) + if not _continue: + return result + if not result: + return result + return True + + @classmethod + def register_internal_controller(cls, controller: AuthorizationHandler): + # TODO : Do some deduplication Logic + cls.internal_authz_controllers.append(controller) diff --git a/care/security/authorization/facility.py b/care/security/authorization/facility.py new file mode 100644 index 0000000000..11de91f2b7 --- /dev/null +++ b/care/security/authorization/facility.py @@ -0,0 +1,22 @@ +from care.abdm.utils.api_call import Facility +from care.facility.models import FacilityUser +from care.security.authorization.base import ( + AuthorizationHandler, + PermissionDeniedError, +) + + +class FacilityAccess(AuthorizationHandler): + actions = ["can_read_facility"] + queries = ["allowed_facilities"] + + def can_read_facility(self, user, facility_id): + self.check_permission(user, facility_id) + # Since the old method relied on a facility-user relationship, check that + # This can be removed when the migrations have been completed + if not FacilityUser.objects.filter(facility_id=facility_id, user=user).exists(): + raise PermissionDeniedError + return True, True + + def allowed_facilities(self, user): + return Facility.objects.filter(users__id__exact=user.id) diff --git a/care/security/management/__init__.py b/care/security/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/management/commands/__init__.py b/care/security/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py new file mode 100644 index 0000000000..5d5808e7e7 --- /dev/null +++ b/care/security/management/commands/sync_permissions_roles.py @@ -0,0 +1,65 @@ +from django.core.management import BaseCommand +from django.db import transaction + +from care.security.models import PermissionModel, RoleModel, RolePermission +from care.security.permissions.base import PermissionController +from care.security.roles.role import RoleController +from care.utils.lock import Lock + + +class Command(BaseCommand): + """ + This command syncs roles, permissions and role-permission mapping to the database. + This command should be run after all deployments and plug changes. + This command is idempotent, multiple instances running the same command is automatically blocked with redis. + """ + + help = "Syncs permissions and roles to database" + + def handle(self, *args, **options): + permissions = PermissionController.get_permissions() + roles = RoleController.get_roles() + with transaction.atomic(), Lock("sync_permissions_roles", 900): + # Create, update permissions and delete old permissions + PermissionModel.objects.all().update(temp_deleted=True) + for permission, metadata in permissions.items(): + permission_obj = PermissionModel.objects.filter(slug=permission).first() + if not permission_obj: + permission_obj = PermissionModel(slug=permission) + permission_obj.name = metadata.name + permission_obj.description = metadata.description + permission_obj.context = metadata.context.value + permission_obj.temp_deleted = False + permission_obj.save() + PermissionModel.objects.filter(temp_deleted=True).delete() + # Create, update roles and delete old roles + RoleModel.objects.all().update(temp_deleted=True) + for role in roles: + role_obj = RoleModel.objects.filter( + name=role.name, context=role.context.value + ).first() + if not role_obj: + role_obj = RoleModel(name=role.name, context=role.context.value) + role_obj.description = role.description + role_obj.is_system = True + role_obj.temp_deleted = False + role_obj.save() + RoleModel.objects.filter(temp_deleted=True).delete() + # Sync permissions to role + RolePermission.objects.all().update(temp_deleted=True) + role_cache = {} + for permission, metadata in permissions.items(): + permission_obj = PermissionModel.objects.filter(slug=permission).first() + for role in metadata.roles: + if role.name not in role_cache: + role_cache[role.name] = RoleModel.objects.get(name=role.name) + obj = RolePermission.objects.filter( + role=role_cache[role.name], permission=permission_obj + ).first() + if not obj: + obj = RolePermission( + role=role_cache[role.name], permission=permission_obj + ) + obj.temp_deleted = False + obj.save() + RolePermission.objects.filter(temp_deleted=True).delete() diff --git a/care/security/migrations/0001_initial.py b/care/security/migrations/0001_initial.py new file mode 100644 index 0000000000..37363e6084 --- /dev/null +++ b/care/security/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.2 on 2024-10-30 10:00 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PermissionModel', + 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)), + ('slug', models.CharField(db_index=True, max_length=1024, unique=True)), + ('name', models.CharField(max_length=1024)), + ('description', models.TextField(default='')), + ('context', models.CharField(max_length=1024)), + ('temp_deleted', models.BooleanField(default=False)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RoleModel', + 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)), + ('name', models.CharField(max_length=1024)), + ('description', models.TextField(default='')), + ('context', models.CharField(max_length=1024)), + ('is_system', models.BooleanField(default=False)), + ('temp_deleted', models.BooleanField(default=False)), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('name', 'context'), name='unique_order')], + }, + ), + migrations.CreateModel( + name='RoleAssociation', + 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)), + ('context', models.CharField(max_length=1024)), + ('context_id', models.BigIntegerField()), + ('expiry', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.rolemodel')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RolePermission', + 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)), + ('temp_deleted', models.BooleanField(default=False)), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.permissionmodel')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.rolemodel')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/care/security/migrations/__init__.py b/care/security/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/models/__init__.py b/care/security/models/__init__.py new file mode 100644 index 0000000000..edaf462c2c --- /dev/null +++ b/care/security/models/__init__.py @@ -0,0 +1,3 @@ +from .permission import * # noqa F403 +from .permission_association import * # noqa F403 +from .role import * # noqa F403 diff --git a/care/security/models/permission.py b/care/security/models/permission.py new file mode 100644 index 0000000000..0faed59c6d --- /dev/null +++ b/care/security/models/permission.py @@ -0,0 +1,18 @@ +from django.db import models + +from care.utils.models.base import BaseModel + + +class PermissionModel(BaseModel): + """ + This model represents a permission in the security system. + A permission allows a certain action to be performed by the user for a given context. + """ + + slug = models.CharField(max_length=1024, unique=True, db_index=True) + name = models.CharField(max_length=1024) + description = models.TextField(default="") + context = models.CharField( + max_length=1024 + ) # We can add choices here as well if needed + temp_deleted = models.BooleanField(default=False) diff --git a/care/security/models/permission_association.py b/care/security/models/permission_association.py new file mode 100644 index 0000000000..b8e85fd3b8 --- /dev/null +++ b/care/security/models/permission_association.py @@ -0,0 +1,22 @@ +from django.db import models + +from care.security.models.role import RoleModel +from care.users.models import User +from care.utils.models.base import BaseModel + + +class RoleAssociation(BaseModel): + """ + This model connects roles to users via contexts + Expiry can be used to expire the role allocation after a certain period + """ + + user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False) + context = models.CharField(max_length=1024) + context_id = models.BigIntegerField() # Store integer id of the context here + role = models.ForeignKey( + RoleModel, on_delete=models.CASCADE, null=False, blank=False + ) + expiry = models.DateTimeField(null=True, blank=True) + + # TODO : Index user, context and context_id diff --git a/care/security/models/role.py b/care/security/models/role.py new file mode 100644 index 0000000000..471aa44d35 --- /dev/null +++ b/care/security/models/role.py @@ -0,0 +1,45 @@ +from django.db import models +from django.db.models import UniqueConstraint + +from care.security.models.permission import PermissionModel +from care.utils.models.base import BaseModel + + +class RoleModel(BaseModel): + """ + This model represents a role in the security system. + A role comprises multiple permissions on the same type. + A role can only be made for a single context. eg, A role can be FacilityAdmin with Facility related permission items + Another role is to be created for other contexts, eg. Asset Admin should only contain Asset related permission items + Roles can be created on the fly, System roles cannot be deleted, but user created roles can be deleted by users + with the permission to delete roles + """ + + name = models.CharField(max_length=1024) + description = models.TextField(default="") + context = models.CharField( + max_length=1024 + ) # We can add choices here as well if needed + is_system = models.BooleanField( + default=False + ) # Denotes if role was created on the fly + temp_deleted = models.BooleanField(default=False) + + class Meta: + constraints = [ + UniqueConstraint(name="unique_order", fields=["name", "context"]) + ] + + +class RolePermission(BaseModel): + """ + Connects a role to a list of permissions + """ + + role = models.ForeignKey( + RoleModel, on_delete=models.CASCADE, null=False, blank=False + ) + permission = models.ForeignKey( + PermissionModel, on_delete=models.CASCADE, null=False, blank=False + ) + temp_deleted = models.BooleanField(default=False) diff --git a/care/security/permissions/__init__.py b/care/security/permissions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/permissions/base.py b/care/security/permissions/base.py new file mode 100644 index 0000000000..00028e6b39 --- /dev/null +++ b/care/security/permissions/base.py @@ -0,0 +1,83 @@ +import enum +from dataclasses import dataclass + +from care.security.models import RoleAssociation, RolePermission + + +class PermissionContext(enum.Enum): + GENERIC = "GENERIC" + FACILITY = "FACILITY" + ASSET = "ASSET" + LOCATION = "LOCATION" + + +@dataclass +class Permission: + """ + This class abstracts a permission + """ + + name: str + description: str + context: PermissionContext + roles: list + + +class PermissionHandler: + pass + + +from care.security.permissions.facility import FacilityPermissions # noqa: E402 + + +class PermissionController: + """ + This class defines all permissions used within care. + This class is used to abstract all operations related to permissions + """ + + override_permission_handlers = [] + # Override Permission Controllers will be defined from plugs + internal_permission_handlers = [FacilityPermissions] + + cache = {} + + @classmethod + def build_cache(cls): + """ + Iterate through the entire permission library and create a list of permissions and associated Metadata + """ + for handler in ( + cls.internal_permission_handlers + cls.override_permission_handlers + ): + for permission in handler: + cls.cache[permission.name] = permission.value + + @classmethod + def has_permission(cls, user, permission, context, context_id): + # TODO : Cache permissions and invalidate when they change + # TODO : Fetch the user role from the previous role management implementation as well. + # Need to maintain some sort of mapping from previous generation to new generation of roles + from care.security.roles.role import RoleController + + mapped_role = RoleController.map_old_role_to_new(user.role) + permission_roles = RolePermission.objects.filter( + permission__slug=permission, permission__context=context + ).values("role_id") + if RoleAssociation.objects.filter( + context_id=context_id, context=context, role__in=permission_roles, user=user + ).exists(): + return True + # Check for old cases + return RolePermission.objects.filter( + permission__slug=permission, + permission__context=context, + role__name=mapped_role.name, + role__context=mapped_role.context.value, + ).exists() + + @classmethod + def get_permissions(cls): + if not cls.cache: + cls.build_cache() + return cls.cache diff --git a/care/security/permissions/facility.py b/care/security/permissions/facility.py new file mode 100644 index 0000000000..ee1cafbd0e --- /dev/null +++ b/care/security/permissions/facility.py @@ -0,0 +1,19 @@ +import enum + +from care.security.permissions.base import Permission, PermissionContext +from care.security.roles.role import DOCTOR_ROLE, STAFF_ROLE + + +class FacilityPermissions(enum.Enum): + can_read_facility = Permission( + "Can Read on Facility", + "Something Here", + PermissionContext.FACILITY, + [STAFF_ROLE, DOCTOR_ROLE], + ) + can_update_facility = Permission( + "Can Update on Facility", + "Something Here", + PermissionContext.FACILITY, + [STAFF_ROLE], + ) diff --git a/care/security/roles/__init__.py b/care/security/roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/roles/role.py b/care/security/roles/role.py new file mode 100644 index 0000000000..dac2d8455e --- /dev/null +++ b/care/security/roles/role.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass + +from care.security.permissions.base import PermissionContext + + +@dataclass +class Role: + """ + This class can be inherited for role classes that are created by default + """ + + name: str + description: str + context: PermissionContext + + +DOCTOR_ROLE = Role( + name="Doctor", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description +STAFF_ROLE = Role( + name="Staff", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description +ADMIN_ROLE = Role( + name="Facility Admin", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description + + +class RoleController: + override_roles = [] + # Override Permission Controllers will be defined from plugs + internal_roles = [DOCTOR_ROLE, STAFF_ROLE] + + @classmethod + def get_roles(cls): + return cls.internal_roles + cls.override_roles + + @classmethod + def map_old_role_to_new(cls, old_role): + mapping = { + "Transportation": STAFF_ROLE, + "Pharmacist": STAFF_ROLE, + "Volunteer": STAFF_ROLE, + "StaffReadOnly": STAFF_ROLE, + "Staff": STAFF_ROLE, + "NurseReadOnly": STAFF_ROLE, + "Nurse": STAFF_ROLE, + "Doctor": DOCTOR_ROLE, + "Reserved": DOCTOR_ROLE, + "WardAdmin": STAFF_ROLE, + "LocalBodyAdmin": ADMIN_ROLE, + "DistrictLabAdmin": ADMIN_ROLE, + "DistrictReadOnlyAdmin": ADMIN_ROLE, + "DistrictAdmin": ADMIN_ROLE, + "StateLabAdmin": ADMIN_ROLE, + "StateReadOnlyAdmin": ADMIN_ROLE, + "StateAdmin": ADMIN_ROLE, + } + return mapping[old_role] + + @classmethod + def register_role(cls, role: Role): + # TODO : Do some deduplication Logic + cls.override_roles.append(role) diff --git a/config/settings/base.py b/config/settings/base.py index df20ebb0cf..65e58d235d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -124,6 +124,7 @@ "healthy_django", ] LOCAL_APPS = [ + "care.security", "care.facility", "care.users", "care.audit_log", From 6143667af11818d8d45efce20073bfb64899a4f9 Mon Sep 17 00:00:00 2001 From: John Lu <87673068+JohnLu2004@users.noreply.github.com> Date: Sat, 2 Nov 2024 02:14:20 -0400 Subject: [PATCH 2/8] Flushed care_static_data prefixed redis keys (#2572) Flushed care_static_data prefixed redis keys (#2572) --------- Co-authored-by: JohnLu2004 Co-authored-by: Aakash Singh --- care/facility/management/commands/load_redis_index.py | 11 +++++++++++ care/facility/tasks/redis_index.py | 11 +++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/care/facility/management/commands/load_redis_index.py b/care/facility/management/commands/load_redis_index.py index 736f482836..e24175a8bf 100644 --- a/care/facility/management/commands/load_redis_index.py +++ b/care/facility/management/commands/load_redis_index.py @@ -17,6 +17,17 @@ class Command(BaseCommand): help = "Loads static data to redis" def handle(self, *args, **options): + try: + deleted_count = cache.delete_pattern("care_static_data*", itersize=25_000) + self.stdout.write( + f"Deleted {deleted_count} keys with prefix 'care_static_data'" + ) + except Exception as e: + self.stdout.write( + f"Failed to delete keys with prefix 'care_static_data': {e}" + ) + return + if cache.get("redis_index_loading"): self.stdout.write("Redis Index already loading, skipping") return diff --git a/care/facility/tasks/redis_index.py b/care/facility/tasks/redis_index.py index 306fc1352c..901c1be22d 100644 --- a/care/facility/tasks/redis_index.py +++ b/care/facility/tasks/redis_index.py @@ -15,6 +15,13 @@ @shared_task def load_redis_index(): + try: + deleted_count = cache.delete_pattern("care_static_data*", itersize=25_000) + logger.info("Deleted %s keys with prefix 'care_static_data'", deleted_count) + except Exception as e: + logger.error("Failed to delete keys with prefix 'care_static_data': %s", e) + return + if cache.get("redis_index_loading"): logger.info("Redis Index already loading, skipping") return @@ -37,9 +44,9 @@ def load_redis_index(): if load_static_data: load_static_data() except ModuleNotFoundError: - logger.info("Module %s not found", module_path) + logger.debug("Module %s not found", module_path) except Exception as e: - logger.info("Error loading static data for %s: %s", plug.name, e) + logger.error("Error loading static data for %s: %s", plug.name, e) cache.delete("redis_index_loading") logger.info("Redis Index Loaded") From 39c7aa41abcac031ad4d2c7d22cf6d0428455dc0 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sat, 2 Nov 2024 17:37:49 +0530 Subject: [PATCH 3/8] Fix bug in response --- care/users/api/viewsets/plug_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/care/users/api/viewsets/plug_config.py b/care/users/api/viewsets/plug_config.py index 40cae848a8..ddfe801353 100644 --- a/care/users/api/viewsets/plug_config.py +++ b/care/users/api/viewsets/plug_config.py @@ -24,7 +24,7 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(self.queryset, many=True) response = serializer.data cache.set(self.cache_key, response) - return Response({"configs": [response]}) + return Response({"configs": response}) def perform_create(self, serializer): cache.delete(self.cache_key) From 8d5e701eacbb5bc4b2a0e91cbc17b6f348a209b2 Mon Sep 17 00:00:00 2001 From: arinak1017 Date: Mon, 4 Nov 2024 05:31:04 -0500 Subject: [PATCH 4/8] Added a settings variable to allow configurable time periods for notification deletion (#2547) Added NOTIFICATION_RETENTION_DAYS settings variable to allow configurable time period with default of 30 days --- care/facility/tasks/cleanup.py | 7 +++++-- .../facility/tests/test_delete_older_notifications_task.py | 7 +++++-- config/settings/base.py | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/care/facility/tasks/cleanup.py b/care/facility/tasks/cleanup.py index 3f913142cc..4fca52eddc 100644 --- a/care/facility/tasks/cleanup.py +++ b/care/facility/tasks/cleanup.py @@ -1,6 +1,7 @@ from datetime import timedelta from celery import shared_task +from django.conf import settings from django.utils import timezone from care.facility.models.notification import Notification @@ -8,5 +9,7 @@ @shared_task def delete_old_notifications(): - ninety_days_ago = timezone.now() - timedelta(days=90) - Notification.objects.filter(created_date__lte=ninety_days_ago).delete() + retention_days = settings.NOTIFICATION_RETENTION_DAYS + + threshold_date = timezone.now() - timedelta(days=retention_days) + Notification.objects.filter(created_date__lte=threshold_date).delete() diff --git a/care/facility/tests/test_delete_older_notifications_task.py b/care/facility/tests/test_delete_older_notifications_task.py index 9f860770b2..70ded42e71 100644 --- a/care/facility/tests/test_delete_older_notifications_task.py +++ b/care/facility/tests/test_delete_older_notifications_task.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.conf import settings from django.test import TestCase from django.utils import timezone from freezegun import freeze_time @@ -10,8 +11,10 @@ class DeleteOldNotificationsTest(TestCase): def test_delete_old_notifications(self): - # notifications created 90 days ago - with freeze_time(timezone.now() - timedelta(days=90)): + retention_days = settings.NOTIFICATION_RETENTION_DAYS + + # notifications created at the threshold of retention + with freeze_time(timezone.now() - timedelta(days=retention_days)): notification1 = Notification.objects.create() notification2 = Notification.objects.create() diff --git a/config/settings/base.py b/config/settings/base.py index 65e58d235d..6983433f86 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -530,6 +530,7 @@ "VAPID_PRIVATE_KEY", default="7mf3OFreFsgFF4jd8A71ZGdVaj8kpJdOto4cFbfAS-s" ) SEND_SMS_NOTIFICATION = False +NOTIFICATION_RETENTION_DAYS = env.int("NOTIFICATION_RETENTION_DAYS", default=30) # Cloud and Buckets # ------------------------------------------------------------------------------ From 40d94c5ea064b49aae49f8a359110acb3b655d53 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 5 Nov 2024 10:45:47 +0530 Subject: [PATCH 5/8] Fix Bug in plugin config --- care/users/api/viewsets/plug_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/care/users/api/viewsets/plug_config.py b/care/users/api/viewsets/plug_config.py index ddfe801353..faeb475dcc 100644 --- a/care/users/api/viewsets/plug_config.py +++ b/care/users/api/viewsets/plug_config.py @@ -15,7 +15,6 @@ class PlugConfigViewset( serializer_class = PLugConfigSerializer queryset = PlugConfig.objects.all().order_by("slug") cache_key = "care_plug_viewset_list" - authentication_classes = [] def list(self, request, *args, **kwargs): # Cache data and return From 341de70b330f6f7e5505e6ca1992a4fc07afb8b7 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 5 Nov 2024 13:27:14 +0530 Subject: [PATCH 6/8] Fix Bug in plugin config --- care/users/api/serializers/plug_config.py | 2 +- care/users/api/viewsets/plug_config.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/care/users/api/serializers/plug_config.py b/care/users/api/serializers/plug_config.py index 6ac59adb21..c308b72d9e 100644 --- a/care/users/api/serializers/plug_config.py +++ b/care/users/api/serializers/plug_config.py @@ -3,7 +3,7 @@ from care.users.models import PlugConfig -class PLugConfigSerializer(serializers.ModelSerializer): +class PlugConfigSerializer(serializers.ModelSerializer): class Meta: model = PlugConfig exclude = ("id",) diff --git a/care/users/api/viewsets/plug_config.py b/care/users/api/viewsets/plug_config.py index faeb475dcc..f7ca4786c9 100644 --- a/care/users/api/viewsets/plug_config.py +++ b/care/users/api/viewsets/plug_config.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet -from care.users.api.serializers.plug_config import PLugConfigSerializer +from care.users.api.serializers.plug_config import PlugConfigSerializer from care.users.models import PlugConfig @@ -12,7 +12,7 @@ class PlugConfigViewset( GenericViewSet, ): lookup_field = "slug" - serializer_class = PLugConfigSerializer + serializer_class = PlugConfigSerializer queryset = PlugConfig.objects.all().order_by("slug") cache_key = "care_plug_viewset_list" @@ -20,7 +20,7 @@ def list(self, request, *args, **kwargs): # Cache data and return response = cache.get(self.cache_key) if not response: - serializer = self.get_serializer(self.queryset, many=True) + serializer = self.get_serializer(self.get_queryset(), many=True) response = serializer.data cache.set(self.cache_key, response) return Response({"configs": response}) From 5cba13c625269c06bdff2a740783414629cc6508 Mon Sep 17 00:00:00 2001 From: Jacob John Jeevan <40040905+Jacobjeevan@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:39:54 +0530 Subject: [PATCH 7/8] Added profile pic url to UserBaseMinimumSerializer (#2581) * adding profile pic url to UserBaseMinSerializer * formatting fix * test fix --- care/facility/tests/test_patient_api.py | 1 + care/users/api/serializers/user.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 01bff0b813..6facfdd3ad 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -86,6 +86,7 @@ class ExpectedCreatedByObjectKeys(Enum): LAST_NAME = "last_name" USER_TYPE = "user_type" LAST_LOGIN = "last_login" + READ_PROFILE_PICTURE_URL = "read_profile_picture_url" class PatientNotesTestCase(TestUtils, APITestCase): diff --git a/care/users/api/serializers/user.py b/care/users/api/serializers/user.py index 36141cecdd..c33a004bdf 100644 --- a/care/users/api/serializers/user.py +++ b/care/users/api/serializers/user.py @@ -375,6 +375,7 @@ def validate(self, attrs): class UserBaseMinimumSerializer(serializers.ModelSerializer): user_type = ChoiceField(choices=User.TYPE_CHOICES, read_only=True) + read_profile_picture_url = serializers.URLField(read_only=True) class Meta: model = User @@ -386,6 +387,7 @@ class Meta: "last_name", "user_type", "last_login", + "read_profile_picture_url", ) From d6d069e7a9eb56c7fc8314010ef8cb7de689a773 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Wed, 6 Nov 2024 11:42:40 +0530 Subject: [PATCH 8/8] Update queryset references in views to avoid cached results (#2582) * update queryset references in views to avoid cached results * fix --- care/facility/api/viewsets/mixins/access.py | 2 +- care/facility/api/viewsets/patient_consultation.py | 13 ++++++------- care/facility/api/viewsets/patient_external_test.py | 2 +- care/facility/api/viewsets/patient_otp_data.py | 2 +- care/facility/api/viewsets/shifting.py | 7 ++++--- care/users/api/viewsets/users.py | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/care/facility/api/viewsets/mixins/access.py b/care/facility/api/viewsets/mixins/access.py index 314c36c360..2cdcc92c3a 100644 --- a/care/facility/api/viewsets/mixins/access.py +++ b/care/facility/api/viewsets/mixins/access.py @@ -5,7 +5,7 @@ class UserAccessMixin: def get_queryset(self): - queryset = self.queryset + queryset = super().get_queryset() model = self.queryset.__dict__["model"] if not self.request.user.is_superuser: diff --git a/care/facility/api/viewsets/patient_consultation.py b/care/facility/api/viewsets/patient_consultation.py index 8bd7145df9..1065e53538 100644 --- a/care/facility/api/viewsets/patient_consultation.py +++ b/care/facility/api/viewsets/patient_consultation.py @@ -82,8 +82,9 @@ def get_permissions(self): return super().get_permissions() def get_queryset(self): + queryset = super().get_queryset() if self.serializer_class == PatientConsultationSerializer: - self.queryset = self.queryset.prefetch_related( + queryset = queryset.prefetch_related( "assigned_to", Prefetch( "assigned_to__skills", @@ -95,13 +96,11 @@ def get_queryset(self): "current_bed__assets__current_location", ) if self.request.user.is_superuser: - return self.queryset + return queryset if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"]: - return self.queryset.filter( - patient__facility__state=self.request.user.state - ) + return queryset.filter(patient__facility__state=self.request.user.state) if self.request.user.user_type >= User.TYPE_VALUE_MAP["DistrictLabAdmin"]: - return self.queryset.filter( + return queryset.filter( patient__facility__district=self.request.user.district ) allowed_facilities = get_accessible_facilities(self.request.user) @@ -111,7 +110,7 @@ def get_queryset(self): ) # A user should be able to see all consultations part of their home facility applied_filters |= Q(facility=self.request.user.home_facility) - return self.queryset.filter(applied_filters) + return queryset.filter(applied_filters) @transaction.non_atomic_requests def create(self, request, *args, **kwargs) -> Response: diff --git a/care/facility/api/viewsets/patient_external_test.py b/care/facility/api/viewsets/patient_external_test.py index e1d86cc311..92d00bfcda 100644 --- a/care/facility/api/viewsets/patient_external_test.py +++ b/care/facility/api/viewsets/patient_external_test.py @@ -95,7 +95,7 @@ class PatientExternalTestViewSet( parser_classes = (MultiPartParser, FormParser, JSONParser) def get_queryset(self): - queryset = self.queryset + queryset = super().get_queryset() if not self.request.user.is_superuser: if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateLabAdmin"]: queryset = queryset.filter(district__state=self.request.user.state) diff --git a/care/facility/api/viewsets/patient_otp_data.py b/care/facility/api/viewsets/patient_otp_data.py index b0f5e6d234..0e2d5015c2 100644 --- a/care/facility/api/viewsets/patient_otp_data.py +++ b/care/facility/api/viewsets/patient_otp_data.py @@ -18,7 +18,7 @@ class OTPPatientDataViewSet(RetrieveModelMixin, ListModelMixin, GenericViewSet): def get_queryset(self): is_otp_login = getattr(self.request.user, "is_alternative_login", False) - queryset = self.queryset + queryset = super().get_queryset() if is_otp_login: queryset = queryset.filter(phone_number=self.request.user.phone_number) else: diff --git a/care/facility/api/viewsets/shifting.py b/care/facility/api/viewsets/shifting.py index 500da9fafb..b8209a833f 100644 --- a/care/facility/api/viewsets/shifting.py +++ b/care/facility/api/viewsets/shifting.py @@ -109,8 +109,9 @@ class ShiftingViewSet( filterset_class = ShiftingFilterSet def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() if self.action == "list": - self.queryset = self.queryset.select_related( + queryset = queryset.select_related( "origin_facility", "shifting_approving_facility", "assigned_facility", @@ -118,7 +119,7 @@ def get_queryset(self) -> QuerySet: ) else: - self.queryset = self.queryset.select_related( + queryset = queryset.select_related( "origin_facility", "origin_facility__ward", "origin_facility__local_body", @@ -148,7 +149,7 @@ def get_queryset(self) -> QuerySet: "created_by", "last_edited_by", ) - return self.queryset + return queryset def get_serializer_class(self): serializer_class = self.serializer_class diff --git a/care/users/api/viewsets/users.py b/care/users/api/viewsets/users.py index 3f064d9d3a..a70241c855 100644 --- a/care/users/api/viewsets/users.py +++ b/care/users/api/viewsets/users.py @@ -125,7 +125,7 @@ class UserViewSet( def get_queryset(self): if self.request.user.is_superuser: - return self.queryset + return super().get_queryset() query = Q(id=self.request.user.id) if self.request.user.user_type >= User.TYPE_VALUE_MAP["StateReadOnlyAdmin"]: query |= Q( @@ -178,7 +178,7 @@ def getcurrentuser(self, request): ) def destroy(self, request, *args, **kwargs): - queryset = self.queryset + queryset = self.get_queryset() username = kwargs["username"] if request.user.is_superuser: pass