From 4ecf054710bb5d91e862988371cb9826cda52e1b Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Wed, 6 Nov 2024 00:32:33 +0100 Subject: [PATCH 001/208] Model changes Rename UserRole -> RoleAssignment Test model constraints --- .../apps/account/admin/__init__.py | 2 +- src/hct_mis_api/apps/account/admin/ad.py | 6 +- src/hct_mis_api/apps/account/admin/forms.py | 10 +-- src/hct_mis_api/apps/account/admin/user.py | 4 +- .../apps/account/admin/user_role.py | 18 ++-- .../apps/account/authentication.py | 4 +- .../apps/account/export_users_xlsx.py | 4 +- src/hct_mis_api/apps/account/fixtures.py | 6 +- .../apps/account/migrations/0080_migration.py | 90 +++++++++++++++++++ src/hct_mis_api/apps/account/models.py | 60 ++++++++++--- src/hct_mis_api/apps/account/schema.py | 8 +- src/hct_mis_api/apps/account/signals.py | 22 ++--- src/hct_mis_api/apps/core/base_test_case.py | 6 +- .../management/commands/addunicefusers.py | 4 +- .../management/commands/generatefixtures.py | 4 +- .../core/management/commands/generateroles.py | 2 +- .../core/management/commands/initcypress.py | 4 +- .../apps/core/management/commands/initdemo.py | 5 +- src/hct_mis_api/apps/core/models.py | 2 +- src/hct_mis_api/apps/grievance/documents.py | 2 +- .../apps/grievance/notifications.py | 6 +- src/hct_mis_api/apps/payment/notifications.py | 4 +- src/hct_mis_api/apps/program/models.py | 2 +- src/hct_mis_api/config/env.py | 2 +- tests/selenium/conftest.py | 4 +- .../snapshots/snap_test_user_choice_data.py | 2 +- .../apps/account/test_check_permissions.py | 8 +- .../apps/account/test_incompatible_roles.py | 6 +- tests/unit/apps/account/test_models.py | 81 +++++++++++++++++ .../apps/account/test_partner_permissions.py | 6 +- .../apps/account/test_user_choice_data.py | 2 +- tests/unit/apps/account/test_user_filters.py | 4 +- .../unit/apps/account/test_user_import_csv.py | 4 +- tests/unit/apps/account/test_user_roles.py | 32 +++---- tests/unit/apps/administration/test_admin.py | 6 +- tests/unit/apps/core/test_doap.py | 4 +- .../test_filter_grievance_by_cross_area.py | 4 +- .../unit/apps/grievance/test_grievance_es.py | 1 - .../payment/test_all_payment_plan_queries.py | 4 +- .../payment/test_finish_verification_plan.py | 4 +- ...import_export_payment_plan_payment_list.py | 4 +- .../test_payment_plan_supporting_documents.py | 6 +- .../program/test_program_cycle_rest_api.py | 4 +- .../apps/registration_data/test_rest_api.py | 4 +- tests/unit/fixtures/account.py | 6 +- 45 files changed, 338 insertions(+), 135 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0080_migration.py create mode 100644 tests/unit/apps/account/test_models.py diff --git a/src/hct_mis_api/apps/account/admin/__init__.py b/src/hct_mis_api/apps/account/admin/__init__.py index 20288e67cd..127e89c413 100644 --- a/src/hct_mis_api/apps/account/admin/__init__.py +++ b/src/hct_mis_api/apps/account/admin/__init__.py @@ -2,4 +2,4 @@ from .partner import PartnerAdmin # noqa from .role import IncompatibleRolesAdmin, RoleAdmin # noqa from .user import UserAdmin # noqa -from .user_role import UserRoleAdmin # noqa +from .user_role import RoleAssignmentAdmin # noqa diff --git a/src/hct_mis_api/apps/account/admin/ad.py b/src/hct_mis_api/apps/account/admin/ad.py index a88c152173..e03dc7b292 100644 --- a/src/hct_mis_api/apps/account/admin/ad.py +++ b/src/hct_mis_api/apps/account/admin/ad.py @@ -157,7 +157,7 @@ def load_ad_users(self, request: HttpRequest) -> TemplateResponse: basic_role = account_models.Role.objects.filter(name="Basic User").first() if global_business_area and basic_role: users_role_to_bulk_create.append( - account_models.UserRole( + account_models.RoleAssignment( business_area=global_business_area, user=user, role=basic_role, @@ -166,7 +166,7 @@ def load_ad_users(self, request: HttpRequest) -> TemplateResponse: results.created.append(user) users_role_to_bulk_create.append( - account_models.UserRole(role=role, business_area=business_area, user=user) + account_models.RoleAssignment(role=role, business_area=business_area, user=user) ) except HTTPError as e: if e.response.status_code != 404: @@ -175,7 +175,7 @@ def load_ad_users(self, request: HttpRequest) -> TemplateResponse: except Http404: results.missing.append(email) account_models.User.objects.bulk_create(users_to_bulk_create) - account_models.UserRole.objects.bulk_create(users_role_to_bulk_create, ignore_conflicts=True) + account_models.RoleAssignment.objects.bulk_create(users_role_to_bulk_create, ignore_conflicts=True) ctx["results"] = results return TemplateResponse(request, "admin/load_users.html", ctx) except Exception as e: diff --git a/src/hct_mis_api/apps/account/admin/forms.py b/src/hct_mis_api/apps/account/admin/forms.py index b8ea6f4382..39644d84f2 100644 --- a/src/hct_mis_api/apps/account/admin/forms.py +++ b/src/hct_mis_api/apps/account/admin/forms.py @@ -28,16 +28,16 @@ class RoleAdminForm(forms.ModelForm): ) class Meta: - model = account_models.UserRole + model = account_models.RoleAssignment fields = "__all__" -class UserRoleAdminForm(forms.ModelForm): +class RoleAssignmentAdminForm(forms.ModelForm): role = forms.ModelChoiceField(account_models.Role.objects.order_by("name")) business_area = forms.ModelChoiceField(BusinessArea.objects.filter(is_split=False)) class Meta: - model = account_models.UserRole + model = account_models.RoleAssignment fields = "__all__" def clean(self) -> None: @@ -51,8 +51,8 @@ def clean(self) -> None: account_models.IncompatibleRoles.objects.validate_user_role(user, business_area, role) -class UserRoleInlineFormSet(forms.BaseInlineFormSet): - model = account_models.UserRole +class RoleAssignmentInlineFormSet(forms.BaseInlineFormSet): + model = account_models.RoleAssignment def add_fields(self, form: "forms.Form", index: Optional[int]) -> None: super().add_fields(form, index) diff --git a/src/hct_mis_api/apps/account/admin/user.py b/src/hct_mis_api/apps/account/admin/user.py index 0e777d6328..865eb95084 100644 --- a/src/hct_mis_api/apps/account/admin/user.py +++ b/src/hct_mis_api/apps/account/admin/user.py @@ -28,7 +28,7 @@ ImportCSVForm, ) from hct_mis_api.apps.account.admin.mixins import KoboAccessMixin -from hct_mis_api.apps.account.admin.user_role import UserRoleInline +from hct_mis_api.apps.account.admin.user_role import RoleAssignmentInline from hct_mis_api.apps.utils.admin import HopeModelAdminMixin if TYPE_CHECKING: @@ -123,7 +123,7 @@ class UserAdmin(HopeModelAdminMixin, KoboAccessMixin, BaseUserAdmin, ADUSerMixin }, ), ) - inlines = (UserRoleInline,) + inlines = (RoleAssignmentInline,) actions = ["create_kobo_user_qs", "add_business_area_role"] formfield_overrides = { JSONField: {"widget": JSONEditor}, diff --git a/src/hct_mis_api/apps/account/admin/user_role.py b/src/hct_mis_api/apps/account/admin/user_role.py index 4d40b055f3..686fd9f3b3 100644 --- a/src/hct_mis_api/apps/account/admin/user_role.py +++ b/src/hct_mis_api/apps/account/admin/user_role.py @@ -11,8 +11,8 @@ from hct_mis_api.apps.account import models as account_models from hct_mis_api.apps.account.admin.forms import ( - UserRoleAdminForm, - UserRoleInlineFormSet, + RoleAssignmentAdminForm, + RoleAssignmentInlineFormSet, ) from hct_mis_api.apps.account.models import Role from hct_mis_api.apps.utils.admin import HOPEModelAdminBase @@ -20,16 +20,16 @@ logger = logging.getLogger(__name__) -class UserRoleInline(admin.TabularInline): - model = account_models.UserRole +class RoleAssignmentInline(admin.TabularInline): + model = account_models.RoleAssignment extra = 0 - formset = UserRoleInlineFormSet + formset = RoleAssignmentInlineFormSet -@admin.register(account_models.UserRole) -class UserRoleAdmin(HOPEModelAdminBase): +@admin.register(account_models.RoleAssignment) +class RoleAssignmentAdmin(HOPEModelAdminBase): list_display = ("user", "role", "business_area") - form = UserRoleAdminForm + form = RoleAssignmentAdminForm autocomplete_fields = ("role",) raw_id_fields = ("user", "business_area", "role") search_fields = ( @@ -69,7 +69,7 @@ def _get_data(self, record: Any) -> str: objs = [] for qs in [roles]: objs.extend(qs) - objs.extend(account_models.UserRole.objects.filter(pk=record.pk)) + objs.extend(account_models.RoleAssignment.objects.filter(pk=record.pk)) collector.collect(objs) serializer = self.get_serializer("json") return serializer.serialize( diff --git a/src/hct_mis_api/apps/account/authentication.py b/src/hct_mis_api/apps/account/authentication.py index 5c4841e18f..d1277ea320 100644 --- a/src/hct_mis_api/apps/account/authentication.py +++ b/src/hct_mis_api/apps/account/authentication.py @@ -9,7 +9,7 @@ from social_core.pipeline import user as social_core_user from hct_mis_api.apps.account.microsoft_graph import MicrosoftGraphAPI -from hct_mis_api.apps.account.models import ACTIVE, Role, User, UserRole +from hct_mis_api.apps.account.models import ACTIVE, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def create_user( user.set_unusable_password() user.save() if business_area_code: - basic_user_role = UserRole() + basic_user_role = RoleAssignment() basic_user_role.role = Role.objects.filter(name="Basic User").first() basic_user_role.business_area = BusinessArea.objects.get(code=business_area_code) basic_user_role.user = user diff --git a/src/hct_mis_api/apps/account/export_users_xlsx.py b/src/hct_mis_api/apps/account/export_users_xlsx.py index 8517876e99..b175218d83 100644 --- a/src/hct_mis_api/apps/account/export_users_xlsx.py +++ b/src/hct_mis_api/apps/account/export_users_xlsx.py @@ -25,7 +25,7 @@ def value(self, user: User, business_area: str) -> str: return user.partner.name -class UserRoleField(GenericField): +class RoleAssignmentField(GenericField): def value(self, user: User, business_area: str) -> str: all_roles = user.user_roles.filter(business_area__slug=business_area) return ", ".join([f"{role.business_area.name}-{role.role.name}" for role in all_roles]) @@ -39,7 +39,7 @@ class ExportUsersXlsx: "email": GenericField("email", "E-MAIL"), "status": GenericField("status", "ACCOUNT STATUS"), "partner": PartnerField("partner", "PARTNER"), - "user_roles": UserRoleField("user_roles", "USER ROLES"), + "user_roles": RoleAssignmentField("user_roles", "USER ROLES"), } ) diff --git a/src/hct_mis_api/apps/account/fixtures.py b/src/hct_mis_api/apps/account/fixtures.py index ff4d45c103..b73058e3ff 100644 --- a/src/hct_mis_api/apps/account/fixtures.py +++ b/src/hct_mis_api/apps/account/fixtures.py @@ -7,7 +7,7 @@ import factory from factory.django import DjangoModelFactory -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea @@ -64,11 +64,11 @@ class Meta: django_get_or_create = ("name", "subsystem") -class UserRoleFactory(DjangoModelFactory): +class RoleAssignmentFactory(DjangoModelFactory): user = factory.SubFactory(UserFactory) role = factory.SubFactory(RoleFactory) business_area = factory.SubFactory(BusinessAreaFactory) class Meta: - model = UserRole + model = RoleAssignment django_get_or_create = ("user", "role") diff --git a/src/hct_mis_api/apps/account/migrations/0080_migration.py b/src/hct_mis_api/apps/account/migrations/0080_migration.py new file mode 100644 index 0000000000..f8ce7542c9 --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0080_migration.py @@ -0,0 +1,90 @@ +# Generated by Django 3.2.25 on 2024-11-05 23:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0053_migration'), + ('auth', '0012_alter_user_first_name_max_length'), + ('geo', '0009_migration'), + ('core', '0088_migration'), + ('account', '0079_migration'), + ] + + operations = [ + migrations.RenameModel( + old_name='UserRole', + new_name='RoleAssignment', + ), + migrations.AddField( + model_name='role', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='auth.group'), + ), + migrations.AddField( + model_name='role', + name='is_available_for_partner', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='role', + name='is_visible_on_ui', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='roleassignment', + name='areas', + field=models.ManyToManyField(blank=True, related_name='role_assignments', to='geo.Area'), + ), + migrations.AddField( + model_name='roleassignment', + name='full_area_access', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='roleassignment', + name='partner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.partner'), + ), + migrations.AddField( + model_name='roleassignment', + name='program', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='program.program'), + ), + migrations.AlterField( + model_name='roleassignment', + name='business_area', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='core.businessarea'), + ), + migrations.AlterField( + model_name='roleassignment', + name='expiry_date', + field=models.DateField(blank=True, help_text='After expiry date this Role Assignment will be inactive.', null=True), + ), + migrations.AlterField( + model_name='roleassignment', + name='role', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.role'), + ), + migrations.AlterField( + model_name='roleassignment', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='roleassignment', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('business_area', 'role', 'user'), name='unique_user_role_assignment'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.CheckConstraint(check=models.Q(models.Q(('partner__isnull', True), ('user__isnull', False)), models.Q(('partner__isnull', False), ('user__isnull', True)), _connector='OR'), name='user_or_partner_not_both'), + ), + ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index c4282f6905..5134271296 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -201,7 +201,7 @@ def permissions_in_business_area(self, business_area_slug: str, program_id: Opti return list of permissions based on User Role BA and User Partner if program_id is in arguments need to check if partner has access to this program """ - user_roles_query = UserRole.objects.filter(user=self, business_area__slug=business_area_slug).exclude( + user_roles_query = RoleAssignment.objects.filter(user=self, business_area__slug=business_area_slug).exclude( expiry_date__lt=timezone.now() ) all_user_roles_permissions_list = list( @@ -243,8 +243,8 @@ def has_permission( return permission in self.permissions_in_business_area(business_area.slug, program_id) @test_conditional(lru_cache()) - def cached_user_roles(self) -> QuerySet["UserRole"]: - return self.user_roles.all().select_related("business_area") + def cached_role_assignments(self) -> QuerySet["RoleAssignment"]: + return self.role_assignments.all().select_related("business_area") def can_download_storage_files(self) -> bool: return any( @@ -319,19 +319,48 @@ def formfield(self, form_class: Optional[Any] = ..., choices_form_class: Optiona return super(ArrayField, self).formfield(**defaults) -class UserRole(NaturalKeyModel, TimeStampedUUIDModel): - business_area = models.ForeignKey("core.BusinessArea", related_name="user_roles", on_delete=models.CASCADE) - user = models.ForeignKey("account.User", related_name="user_roles", on_delete=models.CASCADE) - role = models.ForeignKey("account.Role", related_name="user_roles", on_delete=models.CASCADE) +class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): + business_area = models.ForeignKey("core.BusinessArea", related_name="role_assignments", on_delete=models.CASCADE) + user = models.ForeignKey("account.User", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) + partner = models.ForeignKey("account.Partner", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) + role = models.ForeignKey("account.Role", related_name="role_assignments", on_delete=models.CASCADE) + program = models.ForeignKey("program.Program", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) + areas = models.ManyToManyField("geo.Area", related_name="role_assignments", blank=True) + full_area_access = models.BooleanField(default=False) expiry_date = models.DateField( - blank=True, null=True, help_text="After expiry date this User Role will be inactive." + blank=True, null=True, help_text="After expiry date this Role Assignment will be inactive." ) class Meta: - unique_together = ("business_area", "user", "role") + constraints = [ + # user can have only one role in BA + models.UniqueConstraint( + fields=["business_area", "role", "user"], + condition=Q(user__isnull=False), + name="unique_user_role_assignment" + ), + # either user or partner should be assigned; not both + models.CheckConstraint( + check=(Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False)), + name="user_or_partner_not_both" + ) + ] + + def clean(self) -> None: + super().clean() + # Ensure either user or partner is set, but not both + if bool(self.user) == bool(self.partner): + raise ValidationError("Either user or partner must be set, but not both.") + + + def save(self, *args: Any, **kwargs: Any) -> None: + self.clean() + super().save(*args, **kwargs) + def __str__(self) -> str: - return f"{self.user} {self.role} in {self.business_area}" + role_holder = self.user if self.user else self.partner + return f"{role_holder} {self.role} in {self.business_area}" class UserGroup(NaturalKeyModel, models.Model): @@ -374,6 +403,9 @@ class Role(NaturalKeyModel, TimeStampedUUIDModel): null=True, blank=True, ) + group = models.ForeignKey(Group, related_name="roles", on_delete=models.CASCADE, null=True, blank=True) + is_visible_on_ui = models.BooleanField(default=True) + is_available_for_partner = models.BooleanField(default=True) def natural_key(self) -> Tuple: return self.name, self.subsystem @@ -395,11 +427,11 @@ def get_roles_as_choices(cls) -> List: class IncompatibleRolesManager(models.Manager): - def validate_user_role(self, user: User, business_area: "BusinessArea", role: UserRole) -> None: + def validate_user_role(self, user: User, business_area: "BusinessArea", role: RoleAssignment) -> None: incompatible_roles = list( IncompatibleRoles.objects.filter(role_one=role).values_list("role_two", flat=True) ) + list(IncompatibleRoles.objects.filter(role_two=role).values_list("role_one", flat=True)) - incompatible_userroles = UserRole.objects.filter( + incompatible_userroles = RoleAssignment.objects.filter( business_area=business_area, role__id__in=incompatible_roles, user=user, @@ -443,8 +475,8 @@ def clean(self) -> None: failing_users = set() for role_pair in ((self.role_one, self.role_two), (self.role_two, self.role_one)): - for userrole in UserRole.objects.filter(role=role_pair[0]): - if UserRole.objects.filter( + for userrole in RoleAssignment.objects.filter(role=role_pair[0]): + if RoleAssignment.objects.filter( user=userrole.user, business_area=userrole.business_area, role=role_pair[1], diff --git a/src/hct_mis_api/apps/account/schema.py b/src/hct_mis_api/apps/account/schema.py index bfb3368d2f..67ae583e0c 100644 --- a/src/hct_mis_api/apps/account/schema.py +++ b/src/hct_mis_api/apps/account/schema.py @@ -20,7 +20,7 @@ Partner, Role, User, - UserRole, + RoleAssignment, ) from hct_mis_api.apps.account.permissions import ( ALL_GRIEVANCES_CREATE_MODIFY, @@ -45,7 +45,7 @@ from graphene import Node -def permissions_resolver(user_roles: "QuerySet[UserRole]") -> Set: +def permissions_resolver(user_roles: "QuerySet[RoleAssignment]") -> Set: all_user_roles = user_roles permissions_set = set() for user_role in all_user_roles: @@ -54,9 +54,9 @@ def permissions_resolver(user_roles: "QuerySet[UserRole]") -> Set: return permissions_set -class UserRoleNode(DjangoObjectType): +class RoleAssignmentNode(DjangoObjectType): class Meta: - model = UserRole + model = RoleAssignment exclude = ("id", "user") diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 2e3c8ae9e5..cc4d232ed5 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -5,20 +5,22 @@ from django.dispatch import receiver from django.utils import timezone -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough -@receiver(post_save, sender=UserRole) -def post_save_userrole(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: - instance.user.last_modify_date = timezone.now() - instance.user.save() +@receiver(post_save, sender=RoleAssignment) +def post_save_pre_delete_roleassignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: + if instance.user: + instance.user.last_modify_date = timezone.now() + instance.user.save() -@receiver(pre_delete, sender=UserRole) -def pre_delete_userrole(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: - instance.user.last_modify_date = timezone.now() - instance.user.save() +@receiver(pre_delete, sender=RoleAssignment) +def pre_delete_roleassignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: + if instance.user: + instance.user.last_modify_date = timezone.now() + instance.user.save() @receiver(pre_save, sender=get_user_model()) @@ -35,7 +37,7 @@ def post_save_user(sender: Any, instance: User, created: bool, *args: Any, **kwa business_area = BusinessArea.objects.filter(slug="global").first() role = Role.objects.filter(name="Basic User").first() if business_area and role: - UserRole.objects.get_or_create(business_area=business_area, user=instance, role=role) + RoleAssignment.objects.get_or_create(business_area=business_area, user=instance, role=role) @receiver(m2m_changed, sender=Partner.allowed_business_areas.through) diff --git a/src/hct_mis_api/apps/core/base_test_case.py b/src/hct_mis_api/apps/core/base_test_case.py index 32862d3529..fd9d78ff5c 100644 --- a/src/hct_mis_api/apps/core/base_test_case.py +++ b/src/hct_mis_api/apps/core/base_test_case.py @@ -19,7 +19,7 @@ from snapshottest.django import TestCase as SnapshotTestTestCase from hct_mis_api.apps.account.fixtures import PartnerFactory -from hct_mis_api.apps.account.models import Role, UserRole +from hct_mis_api.apps.account.models import Role, RoleAssignment from hct_mis_api.apps.core.models import BusinessAreaPartnerThrough from hct_mis_api.apps.core.utils import IDENTIFICATION_TYPE_TO_KEY_MAPPING from hct_mis_api.apps.household.models import IDENTIFICATION_TYPE_CHOICE, DocumentType @@ -171,10 +171,10 @@ def create_user_role_with_permissions( program: Optional["Program"] = None, areas: Optional[List["Area"]] = None, name: Optional[str] = "Role with Permissions", - ) -> UserRole: + ) -> RoleAssignment: permission_list = [perm.value for perm in permissions] role, created = Role.objects.update_or_create(name=name, defaults={"permissions": permission_list}) - user_role, _ = UserRole.objects.get_or_create(user=user, role=role, business_area=business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=user, role=role, business_area=business_area) # update Partner permissions for the program if program: diff --git a/src/hct_mis_api/apps/core/management/commands/addunicefusers.py b/src/hct_mis_api/apps/core/management/commands/addunicefusers.py index f1ff7d008d..2880983f06 100644 --- a/src/hct_mis_api/apps/core/management/commands/addunicefusers.py +++ b/src/hct_mis_api/apps/core/management/commands/addunicefusers.py @@ -2,7 +2,7 @@ from django.core.management import BaseCommand -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea emails = [ @@ -30,4 +30,4 @@ def handle(self, *args: Any, **options: Any) -> None: user = User.objects.create_user( username=username, email=email, password="PaymentModule123", is_staff=True, is_superuser=True ) - UserRole.objects.create(business_area=afg, user=user, role=role) + RoleAssignment.objects.create(business_area=afg, user=user, role=role) diff --git a/src/hct_mis_api/apps/core/management/commands/generatefixtures.py b/src/hct_mis_api/apps/core/management/commands/generatefixtures.py index 00e2c1ae18..30722456db 100644 --- a/src/hct_mis_api/apps/core/management/commands/generatefixtures.py +++ b/src/hct_mis_api/apps/core/management/commands/generatefixtures.py @@ -9,7 +9,7 @@ from django.db import transaction from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import UserRole +from hct_mis_api.apps.account.models import RoleAssignment from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.grievance.fixtures import ( @@ -230,7 +230,7 @@ def handle(self, *args: Any, **options: Any) -> None: else: self.stdout.write("Generation canceled") return - if not UserRole.objects.count(): + if not RoleAssignment.objects.count(): call_command("generateroles") for index in range(business_area_amount): for _ in range(programs_amount): diff --git a/src/hct_mis_api/apps/core/management/commands/generateroles.py b/src/hct_mis_api/apps/core/management/commands/generateroles.py index df7cd861e3..b5640eb922 100644 --- a/src/hct_mis_api/apps/core/management/commands/generateroles.py +++ b/src/hct_mis_api/apps/core/management/commands/generateroles.py @@ -15,7 +15,7 @@ def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "--delete_all", action="store_true", - help="Should delete all existing roles before creating defaults. Be aware that it would also remove all existing UserRoles and IncompatibleRoles.", + help="Should delete all existing roles before creating defaults. Be aware that it would also remove all existing RoleAssignments and IncompatibleRoles.", ) parser.add_argument( "--delete_incompatible", diff --git a/src/hct_mis_api/apps/core/management/commands/initcypress.py b/src/hct_mis_api/apps/core/management/commands/initcypress.py index 0da1be727b..7227ae8a69 100644 --- a/src/hct_mis_api/apps/core/management/commands/initcypress.py +++ b/src/hct_mis_api/apps/core/management/commands/initcypress.py @@ -4,7 +4,7 @@ from django.conf import settings from django.core.management import BaseCommand, call_command -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.management.commands.reset_business_area_sequences import ( reset_business_area_sequences, ) @@ -42,7 +42,7 @@ def handle(self, *args: Any, **options: Any) -> None: partner = Partner.objects.get(name="UNICEF") - UserRole.objects.create( + RoleAssignment.objects.create( user=User.objects.create_superuser( "cypress-username", "cypress@cypress.com", diff --git a/src/hct_mis_api/apps/core/management/commands/initdemo.py b/src/hct_mis_api/apps/core/management/commands/initdemo.py index 53400a9d27..5b99bd39ed 100644 --- a/src/hct_mis_api/apps/core/management/commands/initdemo.py +++ b/src/hct_mis_api/apps/core/management/commands/initdemo.py @@ -59,8 +59,7 @@ from django.utils import timezone import elasticsearch - -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.payment.fixtures import ( generate_delivery_mechanisms, @@ -198,7 +197,7 @@ def handle(self, *args: Any, **options: Any) -> None: for email in combined_email_list: try: user = User.objects.create_user(email, email, "password", partner=partner) - UserRole.objects.create( + RoleAssignment.objects.create( user=user, role=role_with_all_perms, business_area=afghanistan, diff --git a/src/hct_mis_api/apps/core/models.py b/src/hct_mis_api/apps/core/models.py index dca60f1810..205209a10c 100644 --- a/src/hct_mis_api/apps/core/models.py +++ b/src/hct_mis_api/apps/core/models.py @@ -25,7 +25,7 @@ from mptt.fields import TreeForeignKey -class BusinessAreaPartnerThrough(TimeStampedUUIDModel): +class BusinessAreaPartnerThrough(TimeStampedUUIDModel): # TODO: remove after migration to RoleAssignment business_area = models.ForeignKey( "BusinessArea", on_delete=models.CASCADE, diff --git a/src/hct_mis_api/apps/grievance/documents.py b/src/hct_mis_api/apps/grievance/documents.py index 126085b940..9b8d29b4be 100644 --- a/src/hct_mis_api/apps/grievance/documents.py +++ b/src/hct_mis_api/apps/grievance/documents.py @@ -6,8 +6,8 @@ from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry -from elasticsearch import Elasticsearch +from elasticsearch import Elasticsearch from hct_mis_api.apps.account.models import User from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.models import Area diff --git a/src/hct_mis_api/apps/grievance/notifications.py b/src/hct_mis_api/apps/grievance/notifications.py index 33cd8f7555..4366f16f2e 100644 --- a/src/hct_mis_api/apps/grievance/notifications.py +++ b/src/hct_mis_api/apps/grievance/notifications.py @@ -8,7 +8,7 @@ from constance import config -from hct_mis_api.apps.account.models import User, UserRole +from hct_mis_api.apps.account.models import User, RoleAssignment from hct_mis_api.apps.core.utils import encode_id_base64 from hct_mis_api.apps.grievance.models import GrievanceTicket from hct_mis_api.apps.utils.mailjet import MailjetClient @@ -95,7 +95,7 @@ def _prepare_universal_category_created_recipients(self) -> "QuerySet": GrievanceNotification.ACTION_PAYMENT_VERIFICATION_CREATED: "Releaser", GrievanceNotification.ACTION_SENSITIVE_CREATED: "Senior Management", } - user_roles = UserRole.objects.filter( + user_roles = RoleAssignment.objects.filter( role__name=action_roles_dict[self.action], business_area=self.grievance_ticket.business_area, ).exclude(expiry_date__lt=timezone.now()) @@ -105,7 +105,7 @@ def _prepare_universal_category_created_recipients(self) -> "QuerySet": return queryset.all() def _prepare_for_approval_recipients(self) -> "QuerySet[User]": - user_roles = UserRole.objects.filter( + user_roles = RoleAssignment.objects.filter( role__name="Approver", business_area=self.grievance_ticket.business_area, ).exclude(expiry_date__lt=timezone.now()) diff --git a/src/hct_mis_api/apps/payment/notifications.py b/src/hct_mis_api/apps/payment/notifications.py index c98d3e2fe4..05c6c5381e 100644 --- a/src/hct_mis_api/apps/payment/notifications.py +++ b/src/hct_mis_api/apps/payment/notifications.py @@ -7,7 +7,7 @@ from constance import config -from hct_mis_api.apps.account.models import Partner, User, UserRole +from hct_mis_api.apps.account.models import Partner, User, RoleAssignment from hct_mis_api.apps.account.permissions import ( DEFAULT_PERMISSIONS_LIST_FOR_IS_UNICEF_PARTNER, Permissions, @@ -71,7 +71,7 @@ def _prepare_user_recipients(self) -> QuerySet[User]: program = self.payment_plan.program user_roles = ( - UserRole.objects.filter( + RoleAssignment.objects.filter( role__permissions__contains=[permission], business_area=business_area, ) diff --git a/src/hct_mis_api/apps/program/models.py b/src/hct_mis_api/apps/program/models.py index 1ca109577c..e6beb0177a 100644 --- a/src/hct_mis_api/apps/program/models.py +++ b/src/hct_mis_api/apps/program/models.py @@ -41,7 +41,7 @@ ) -class ProgramPartnerThrough(TimeStampedUUIDModel): +class ProgramPartnerThrough(TimeStampedUUIDModel): # TODO: remove after migration to RoleAssignment program = models.ForeignKey( "Program", on_delete=models.CASCADE, diff --git a/src/hct_mis_api/config/env.py b/src/hct_mis_api/config/env.py index 7a2374ad95..e23d2865d2 100644 --- a/src/hct_mis_api/config/env.py +++ b/src/hct_mis_api/config/env.py @@ -31,7 +31,7 @@ "MAILJET_API_KEY": (str, ""), "MAILJET_SECRET_KEY": (str, ""), "CATCH_ALL_EMAIL": (list, []), - "DEFAULT_EMAIL_DISPLAY": (list, []), + "DEFAULT_EMAIL_DISPLAY": (str, ""), "KOBO_KF_URL": (str, "https://kf-hope.unitst.org"), "KOBO_KC_URL": (str, "https://kc-hope.unitst.org"), "KOBO_MASTER_API_TOKEN": (str, "KOBO_TOKEN"), diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index b48c34dc30..e41a34a4b4 100644 --- a/tests/selenium/conftest.py +++ b/tests/selenium/conftest.py @@ -18,7 +18,7 @@ from selenium.webdriver.chrome.options import Options from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.models import ( BusinessArea, @@ -524,7 +524,7 @@ def create_super_user(business_area: BusinessArea) -> User: email="test@example.com", partner=partner, ) - UserRole.objects.create( + RoleAssignment.objects.create( user=user, role=Role.objects.get(name="Role"), business_area=business_area, diff --git a/tests/unit/apps/account/snapshots/snap_test_user_choice_data.py b/tests/unit/apps/account/snapshots/snap_test_user_choice_data.py index af519e2c40..3ca2114d8f 100644 --- a/tests/unit/apps/account/snapshots/snap_test_user_choice_data.py +++ b/tests/unit/apps/account/snapshots/snap_test_user_choice_data.py @@ -7,7 +7,7 @@ snapshots = Snapshot() -snapshots['UserRolesTest::test_user_choice_data 1'] = { +snapshots['RoleAssignmentsTest::test_user_choice_data 1'] = { 'data': { 'userPartnerChoices': [ { diff --git a/tests/unit/apps/account/test_check_permissions.py b/tests/unit/apps/account/test_check_permissions.py index 3fbd7c7c9f..abe335404d 100644 --- a/tests/unit/apps/account/test_check_permissions.py +++ b/tests/unit/apps/account/test_check_permissions.py @@ -2,7 +2,7 @@ from django.test import TestCase from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, UserFactory -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions, check_permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough @@ -42,7 +42,7 @@ def test_user_is_unicef_and_has_access_to_business_area(self) -> None: partner = PartnerFactory(name="UNICEF") self.user.partner = partner self.user.save() - UserRole.objects.create(business_area=self.business_area, user=self.user, role=self.role) + RoleAssignment.objects.create(business_area=self.business_area, user=self.user, role=self.role) arguments = { "business_area": self.business_area.slug, @@ -67,7 +67,7 @@ def test_user_is_not_unicef_and_has_access_to_business_area_without_access_to_pr self.user.partner = partner self.user.save() - UserRole.objects.create(business_area=self.business_area, user=self.user, role=self.role) + RoleAssignment.objects.create(business_area=self.business_area, user=self.user, role=self.role) arguments = { "business_area": self.business_area.slug, @@ -83,7 +83,7 @@ def test_user_is_not_unicef_and_has_access_to_business_area_and_program(self) -> self.user.partner = partner self.user.save() - UserRole.objects.create(business_area=self.business_area, user=self.user, role=self.role) + RoleAssignment.objects.create(business_area=self.business_area, user=self.user, role=self.role) arguments = { "business_area": self.business_area.slug, diff --git a/tests/unit/apps/account/test_incompatible_roles.py b/tests/unit/apps/account/test_incompatible_roles.py index ea3bea0e46..306b3e7237 100644 --- a/tests/unit/apps/account/test_incompatible_roles.py +++ b/tests/unit/apps/account/test_incompatible_roles.py @@ -2,7 +2,7 @@ from django.test import TestCase from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import IncompatibleRoles, Role, UserRole +from hct_mis_api.apps.account.models import IncompatibleRoles, Role, RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea @@ -38,8 +38,8 @@ def test_any_users_already_with_both_roles(self) -> None: create_afghanistan() business_area = BusinessArea.objects.get(slug="afghanistan") user = UserFactory() - UserRole.objects.create(role=self.role_1, business_area=business_area, user=user) - UserRole.objects.create(role=self.role_2, business_area=business_area, user=user) + RoleAssignment.objects.create(role=self.role_1, business_area=business_area, user=user) + RoleAssignment.objects.create(role=self.role_2, business_area=business_area, user=user) test_role = IncompatibleRoles(role_one=self.role_1, role_two=self.role_2) diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py new file mode 100644 index 0000000000..d5280c16da --- /dev/null +++ b/tests/unit/apps/account/test_models.py @@ -0,0 +1,81 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import TransactionTestCase + +from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory, PartnerFactory +from hct_mis_api.apps.account.models import RoleAssignment +from hct_mis_api.apps.core.fixtures import create_afghanistan +from hct_mis_api.apps.program.fixtures import ProgramFactory +from hct_mis_api.apps.program.models import Program + + +class TestRoleAssignmentModel(TransactionTestCase): + def setUp(self) -> None: + self.business_area = create_afghanistan() + self.role = RoleFactory( + name="Test Role", + permissions=["PROGRAMME_CREATE", "PROGRAMME_FINISH"], + ) + self.user = UserFactory(first_name="Test", last_name="User") + self.partner = PartnerFactory(name="Partner") + self.program1 = ProgramFactory( + business_area=self.business_area, + name="Program 1", + status=Program.ACTIVE, + ) + self.program2 = ProgramFactory( + business_area=self.business_area, + name="Program 2", + status=Program.ACTIVE, + ) + RoleAssignmentFactory( + user=self.user, + role=self.role, + business_area=self.business_area, + ) + + def test_unique_user_role_assignment(self) -> None: + # Not possible to have the same role assigned to the same user in the same BA + with self.assertRaises(IntegrityError) as ie_context: + RoleAssignment.objects.create( + user=self.user, + role=self.role, + business_area=self.business_area, + ) + self.assertIn( + 'duplicate key value violates unique constraint "unique_user_role_assignment"', + str(ie_context.exception), + ) + + # Possible to have the same role assigned to the same partner in the same BA (not failing for two records with user=None) + RoleAssignment.objects.create( + user=None, + role=self.role, + business_area=self.business_area, + partner=self.partner, + program=self.program1, + ) + + RoleAssignment.objects.create( + user=None, + role=self.role, + business_area=self.business_area, + partner=self.partner, + program=self.program2, + ) + self.assertEqual(RoleAssignment.objects.filter(role=self.role, business_area=self.business_area, partner=self.partner).count(), 2) + + def test_user_or_partner_not_both(self) -> None: + # Not possible to have both user and partner in the same role assignment + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=self.user, + role=self.role, + business_area=self.business_area, + partner=self.partner, + program=self.program1, + ) + self.assertIn( + 'Either user or partner must be set, but not both.', + str(ve_context.exception), + ) diff --git a/tests/unit/apps/account/test_partner_permissions.py b/tests/unit/apps/account/test_partner_permissions.py index 6f73105fef..baf2761d66 100644 --- a/tests/unit/apps/account/test_partner_permissions.py +++ b/tests/unit/apps/account/test_partner_permissions.py @@ -1,7 +1,7 @@ from django.test import TestCase from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.fixtures import AreaFactory @@ -37,12 +37,12 @@ def setUpTestData(cls) -> None: program_unicef_through.areas.set([cls.area_1, cls.area_2]) cls.unicef_user = UserFactory(partner=cls.unicef_partner) - UserRole.objects.create( + RoleAssignment.objects.create( business_area=cls.business_area, user=cls.unicef_user, role=cls.role_1, ) - UserRole.objects.create( + RoleAssignment.objects.create( business_area=cls.business_area, user=cls.other_user, role=cls.role_1, diff --git a/tests/unit/apps/account/test_user_choice_data.py b/tests/unit/apps/account/test_user_choice_data.py index c4f4f2a478..defe83f722 100644 --- a/tests/unit/apps/account/test_user_choice_data.py +++ b/tests/unit/apps/account/test_user_choice_data.py @@ -4,7 +4,7 @@ from hct_mis_api.apps.core.fixtures import create_afghanistan -class UserRolesTest(APITestCase): +class RoleAssignmentsTest(APITestCase): USER_CHOICE_DATA_QUERY = """ query userChoiceData { userPartnerChoices diff --git a/tests/unit/apps/account/test_user_filters.py b/tests/unit/apps/account/test_user_filters.py index ee8c4e357c..8eee9cdfd5 100644 --- a/tests/unit/apps/account/test_user_filters.py +++ b/tests/unit/apps/account/test_user_filters.py @@ -2,7 +2,7 @@ PartnerFactory, RoleFactory, UserFactory, - UserRoleFactory, + RoleAssignmentFactory, ) from hct_mis_api.apps.account.models import User from hct_mis_api.apps.account.permissions import Permissions @@ -135,7 +135,7 @@ def setUpTestData(cls) -> None: # user with role in BA user_with_test_role = UserFactory(username="user_with_test_role", partner=None) - UserRoleFactory(user=user_with_test_role, role=cls.role, business_area=business_area) + RoleAssignmentFactory(user=user_with_test_role, role=cls.role, business_area=business_area) # user with partner with role in BA and access to program role_management = RoleFactory( diff --git a/tests/unit/apps/account/test_user_import_csv.py b/tests/unit/apps/account/test_user_import_csv.py index af155b4684..d0ba4c2235 100644 --- a/tests/unit/apps/account/test_user_import_csv.py +++ b/tests/unit/apps/account/test_user_import_csv.py @@ -12,7 +12,7 @@ PartnerFactory, RoleFactory, UserFactory, - UserRoleFactory, + RoleAssignmentFactory, ) from hct_mis_api.apps.account.models import IncompatibleRoles, Role, User from hct_mis_api.apps.core.fixtures import create_afghanistan @@ -67,7 +67,7 @@ def test_import_csv_with_kobo(self) -> None: @responses.activate def test_import_csv_detect_incompatible_roles(self) -> None: u: User = UserFactory(email="test@example.com", partner=self.partner) - UserRoleFactory(user=u, role=self.role_2, business_area=self.business_area) + RoleAssignmentFactory(user=u, role=self.role_2, business_area=self.business_area) url = reverse("admin:account_user_import_csv") res = self.app.get(url, user=self.superuser) res.form["file"] = ("users.csv", (Path(__file__).parent / "users.csv").read_bytes()) diff --git a/tests/unit/apps/account/test_user_roles.py b/tests/unit/apps/account/test_user_roles.py index 500d675844..2dc82b3955 100644 --- a/tests/unit/apps/account/test_user_roles.py +++ b/tests/unit/apps/account/test_user_roles.py @@ -5,11 +5,11 @@ from django.test import TestCase from hct_mis_api.apps.account.admin.forms import ( - UserRoleAdminForm, - UserRoleInlineFormSet, + RoleAssignmentAdminForm, + RoleAssignmentInlineFormSet, ) from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory -from hct_mis_api.apps.account.models import IncompatibleRoles, Role, User, UserRole +from hct_mis_api.apps.account.models import IncompatibleRoles, Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import ( DEFAULT_PERMISSIONS_IS_UNICEF_PARTNER, Permissions, @@ -18,7 +18,7 @@ from hct_mis_api.apps.core.models import BusinessArea -class UserRolesTest(TestCase): +class RoleAssignmentsTest(TestCase): @classmethod def setUpTestData(cls) -> None: super().setUpTestData() @@ -32,15 +32,15 @@ def setUpTestData(cls) -> None: def test_user_can_be_assigned_role(self) -> None: data = {"role": self.role_1.id, "user": self.user.id, "business_area": self.business_area_afg.id} - form = UserRoleAdminForm(data=data) + form = RoleAssignmentAdminForm(data=data) self.assertTrue(form.is_valid()) def test_user_cannot_be_assigned_incompatible_role_in_same_business_area(self) -> None: IncompatibleRoles.objects.create(role_one=self.role_1, role_two=self.role_2) - user_role = UserRole.objects.create(role=self.role_1, business_area=self.business_area_afg, user=self.user) + user_role = RoleAssignment.objects.create(role=self.role_1, business_area=self.business_area_afg, user=self.user) data = {"role": self.role_2.id, "user": self.user.id, "business_area": self.business_area_afg.id} - form = UserRoleAdminForm(data=data) + form = RoleAssignmentAdminForm(data=data) self.assertFalse(form.is_valid()) self.assertIn("role", form.errors.keys()) self.assertIn(f"This role is incompatible with {self.role_1.name}", form.errors["role"]) @@ -49,7 +49,7 @@ def test_user_cannot_be_assigned_incompatible_role_in_same_business_area(self) - user_role.role = self.role_2 user_role.save() data["role"] = self.role_1.id - form = UserRoleAdminForm(data=data) + form = RoleAssignmentAdminForm(data=data) self.assertFalse(form.is_valid()) self.assertIn("role", form.errors.keys()) self.assertIn(f"This role is incompatible with {self.role_2.name}", form.errors["role"]) @@ -63,8 +63,8 @@ def test_assign_multiple_roles_for_user_at_the_same_time(self) -> None: "user_roles-0-business_area": self.business_area_afg.id, "user_roles-1-business_area": self.business_area_afg.id, } - UserRoleFormSet = inlineformset_factory(User, UserRole, fields=("__all__"), formset=UserRoleInlineFormSet) - formset = UserRoleFormSet(instance=self.user, data=data) + RoleAssignmentFormSet = inlineformset_factory(User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet) + formset = RoleAssignmentFormSet(instance=self.user, data=data) self.assertTrue(formset.is_valid()) def test_assign_multiple_roles_for_user_at_the_same_time_fails_for_incompatible_roles(self) -> None: @@ -78,8 +78,8 @@ def test_assign_multiple_roles_for_user_at_the_same_time_fails_for_incompatible_ "user_roles-0-business_area": self.business_area_afg.id, "user_roles-1-business_area": self.business_area_afg.id, } - UserRoleFormSet = inlineformset_factory(User, UserRole, fields=("__all__"), formset=UserRoleInlineFormSet) - formset = UserRoleFormSet(instance=self.user, data=data) + RoleAssignmentFormSet = inlineformset_factory(User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet) + formset = RoleAssignmentFormSet(instance=self.user, data=data) self.assertFalse(formset.is_valid()) self.assertEqual(len(formset.errors), 2) @@ -92,15 +92,15 @@ def test_user_role_exclude_by_expiry_date(self) -> None: role_1 = Role.objects.create(name="111", permissions=[Permissions.RDI_VIEW_LIST.value]) role_2 = Role.objects.create(name="222", permissions=[Permissions.REPORTING_EXPORT.value]) # user_role_active - user_role_1 = UserRole.objects.create( + user_role_1 = RoleAssignment.objects.create( role=role_1, business_area=self.business_area_afg, user=user_not_unicef_partner ) # user_role_inactive - user_role_2 = UserRole.objects.create( + user_role_2 = RoleAssignment.objects.create( role=role_2, business_area=self.business_area_afg, user=user_not_unicef_partner, expiry_date="2024-02-16" ) # user_role_active_but_sharing_same_role_with_user_role_inactive - UserRole.objects.create( + RoleAssignment.objects.create( role=role_2, business_area=self.business_area_ukr, user=user_not_unicef_partner, @@ -142,7 +142,7 @@ def test_unicef_partner_has_permission_from_user_and_default_permission(self) -> partner = PartnerFactory(name="UNICEF") user = UserFactory(partner=partner) role = Role.objects.create(name="111", permissions=[Permissions.GRIEVANCES_CREATE.value]) - UserRole.objects.create(role=role, business_area=self.business_area_afg, user=user) + RoleAssignment.objects.create(role=role, business_area=self.business_area_afg, user=user) self.assertIn( Permissions.GRIEVANCES_CREATE.value, user.permissions_in_business_area(self.business_area_afg.slug) diff --git a/tests/unit/apps/administration/test_admin.py b/tests/unit/apps/administration/test_admin.py index 2e27669841..e7c396b0be 100644 --- a/tests/unit/apps/administration/test_admin.py +++ b/tests/unit/apps/administration/test_admin.py @@ -10,7 +10,7 @@ from parameterized import parameterized from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea @@ -58,12 +58,12 @@ def setUpTestData(cls) -> None: @staticmethod def create_user_role_with_permissions( user: "User", permissions: Iterable, business_area: "BusinessArea" - ) -> UserRole: + ) -> RoleAssignment: permission_list = [perm.value for perm in permissions] role, created = Role.objects.update_or_create( name="Role with Permissions", defaults={"permissions": permission_list} ) - user_role, _ = UserRole.objects.get_or_create(user=user, role=role, business_area=business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=user, role=role, business_area=business_area) return user_role @parameterized.expand(model_admins) diff --git a/tests/unit/apps/core/test_doap.py b/tests/unit/apps/core/test_doap.py index fea9c81885..623cf36aec 100644 --- a/tests/unit/apps/core/test_doap.py +++ b/tests/unit/apps/core/test_doap.py @@ -7,7 +7,7 @@ from django_webtest import WebTest -from hct_mis_api.apps.account.fixtures import UserFactory, UserRoleFactory +from hct_mis_api.apps.account.fixtures import UserFactory, RoleAssignmentFactory from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea @@ -19,7 +19,7 @@ def setUpTestData(cls) -> None: create_afghanistan() cls.business_area = BusinessArea.objects.get(slug="afghanistan") cls.user = UserFactory(is_superuser=True, is_staff=True) - cls.user_role = UserRoleFactory(role__name="Approver", role__subsystem="CA", business_area=cls.business_area) + cls.user_role = RoleAssignmentFactory(role__name="Approver", role__subsystem="CA", business_area=cls.business_area) cls.officer = cls.user_role.user def test_get_matrix(self) -> None: diff --git a/tests/unit/apps/grievance/test_filter_grievance_by_cross_area.py b/tests/unit/apps/grievance/test_filter_grievance_by_cross_area.py index 3fb5b15b2e..eb7508f4f3 100644 --- a/tests/unit/apps/grievance/test_filter_grievance_by_cross_area.py +++ b/tests/unit/apps/grievance/test_filter_grievance_by_cross_area.py @@ -1,7 +1,7 @@ from django.core.cache import cache from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, UserFactory -from hct_mis_api.apps.account.models import UserRole +from hct_mis_api.apps.account.models import RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.base_test_case import APITestCase from hct_mis_api.apps.core.fixtures import create_afghanistan @@ -42,7 +42,7 @@ def setUpTestData(cls) -> None: cls.user = UserFactory(partner=partner_unicef) role = RoleFactory(name="GRIEVANCES CROSS AREA FILTER", permissions=["GRIEVANCES_CROSS_AREA_FILTER"]) - UserRole.objects.create(business_area=cls.business_area, user=cls.user, role=role) + RoleAssignment.objects.create(business_area=cls.business_area, user=cls.user, role=role) cls.admin_area1 = AreaFactory(name="Admin Area 1", level=2, p_code="AREA1") cls.admin_area2 = AreaFactory(name="Admin Area 2", level=2, p_code="AREA2") diff --git a/tests/unit/apps/grievance/test_grievance_es.py b/tests/unit/apps/grievance/test_grievance_es.py index 49f90b910f..d6a8cd63da 100644 --- a/tests/unit/apps/grievance/test_grievance_es.py +++ b/tests/unit/apps/grievance/test_grievance_es.py @@ -7,7 +7,6 @@ from django.core.management import call_command from elasticsearch import Elasticsearch - from hct_mis_api.apps.account.fixtures import UserFactory from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.base_test_case import APITestCase diff --git a/tests/unit/apps/payment/test_all_payment_plan_queries.py b/tests/unit/apps/payment/test_all_payment_plan_queries.py index a680775e29..8637f7011b 100644 --- a/tests/unit/apps/payment/test_all_payment_plan_queries.py +++ b/tests/unit/apps/payment/test_all_payment_plan_queries.py @@ -358,9 +358,9 @@ def test_fetch_all_payment_plans(self) -> None: @freeze_time("2020-10-10") def test_fetch_all_payments_for_open_payment_plan(self) -> None: - from hct_mis_api.apps.account.models import UserRole + from hct_mis_api.apps.account.models import RoleAssignment - role = UserRole.objects.get(user=self.user).role + role = RoleAssignment.objects.get(user=self.user).role role.permissions.append("PM_VIEW_FSP_AUTH_CODE") role.save() diff --git a/tests/unit/apps/payment/test_finish_verification_plan.py b/tests/unit/apps/payment/test_finish_verification_plan.py index 7c11643f96..27f5845508 100644 --- a/tests/unit/apps/payment/test_finish_verification_plan.py +++ b/tests/unit/apps/payment/test_finish_verification_plan.py @@ -6,7 +6,7 @@ from constance.test import override_config -from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, UserRoleFactory +from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.grievance.models import GrievanceTicket @@ -40,7 +40,7 @@ def setUpTestData(cls) -> None: payment_record_amount = 10 user = UserFactory() role = RoleFactory(name="Releaser") - UserRoleFactory(user=user, role=role, business_area=business_area) + RoleAssignmentFactory(user=user, role=role, business_area=business_area) afghanistan_areas_qs = Area.objects.filter(area_type__area_level=2, area_type__country__iso_code3="AFG") diff --git a/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py b/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py index 79beaa3754..55868a7fb7 100644 --- a/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py +++ b/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py @@ -14,7 +14,7 @@ from graphql import GraphQLError from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import ( @@ -558,7 +558,7 @@ def test_flex_fields_admin_visibility(self) -> None: user = User.objects.create_superuser(username="admin", password="password", email="admin@example.com") permission_list = [Permissions.PM_ADMIN_FINANCIAL_SERVICE_PROVIDER_UPDATE.name] role, created = Role.objects.update_or_create(name="LOL", defaults={"permissions": permission_list}) - user_role, _ = UserRole.objects.get_or_create(user=user, role=role, business_area=self.business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=user, role=role, business_area=self.business_area) decimal_flexible_attribute = FlexibleAttribute( type=FlexibleAttribute.DECIMAL, name="flex_decimal_i_f", diff --git a/tests/unit/apps/payment/test_payment_plan_supporting_documents.py b/tests/unit/apps/payment/test_payment_plan_supporting_documents.py index b78e003c69..d4f823e146 100644 --- a/tests/unit/apps/payment/test_payment_plan_supporting_documents.py +++ b/tests/unit/apps/payment/test_payment_plan_supporting_documents.py @@ -11,7 +11,7 @@ from rest_framework.test import APIClient, APIRequestFactory from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import Role, UserRole +from hct_mis_api.apps.account.models import Role, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.payment.api.serializers import ( @@ -112,7 +112,7 @@ def setUp(cls) -> None: role, created = Role.objects.update_or_create( name="TestName", defaults={"permissions": [Permissions.PM_UPLOAD_SUPPORTING_DOCUMENT.value]} ) - user_role, _ = UserRole.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) cls.payment_plan = PaymentPlanFactory( status=PaymentPlan.Status.OPEN, ) @@ -172,7 +172,7 @@ def setUp(cls) -> None: ] }, ) - user_role, _ = UserRole.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) cls.payment_plan = PaymentPlanFactory( status=PaymentPlan.Status.OPEN, ) diff --git a/tests/unit/apps/program/test_program_cycle_rest_api.py b/tests/unit/apps/program/test_program_cycle_rest_api.py index 84c0a4e52d..1ce6eae480 100644 --- a/tests/unit/apps/program/test_program_cycle_rest_api.py +++ b/tests/unit/apps/program/test_program_cycle_rest_api.py @@ -16,7 +16,7 @@ PartnerFactory, UserFactory, ) -from hct_mis_api.apps.account.models import Role, User, UserRole +from hct_mis_api.apps.account.models import Role, User, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.payment.fixtures import PaymentPlanFactory from hct_mis_api.apps.payment.models import PaymentPlan @@ -47,7 +47,7 @@ def setUpTestData(cls) -> None: cls.user = UserFactory(username="Hope_Test_DRF", password="SpeedUp", partner=partner, is_superuser=True) permission_list = [perm.value for perm in user_permissions] role, created = Role.objects.update_or_create(name="TestName", defaults={"permissions": permission_list}) - user_role, _ = UserRole.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) cls.client = APIClient() cls.program = ProgramFactory( diff --git a/tests/unit/apps/registration_data/test_rest_api.py b/tests/unit/apps/registration_data/test_rest_api.py index 806df4d619..053fcdbe9c 100644 --- a/tests/unit/apps/registration_data/test_rest_api.py +++ b/tests/unit/apps/registration_data/test_rest_api.py @@ -7,7 +7,7 @@ from rest_framework.test import APIClient, APIRequestFactory from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory -from hct_mis_api.apps.account.models import Role, UserRole +from hct_mis_api.apps.account.models import Role, RoleAssignment from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -26,7 +26,7 @@ def setUpTestData(cls) -> None: cls.user = UserFactory(username="Hope_Test_DRF", password="HopePass", partner=partner, is_superuser=True) permission_list = [perm.value for perm in user_permissions] role, created = Role.objects.update_or_create(name="TestName", defaults={"permissions": permission_list}) - user_role, _ = UserRole.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=cls.user, role=role, business_area=cls.business_area) cls.program = ProgramFactory( name="Test Program", status=Program.ACTIVE, diff --git a/tests/unit/fixtures/account.py b/tests/unit/fixtures/account.py index fda4f5db83..cef37e945d 100644 --- a/tests/unit/fixtures/account.py +++ b/tests/unit/fixtures/account.py @@ -3,7 +3,7 @@ import pytest from hct_mis_api.apps.account.fixtures import PartnerFactory -from hct_mis_api.apps.account.models import Partner, Role, User, UserRole +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.models import Program, ProgramPartnerThrough @@ -58,10 +58,10 @@ def _create_user_role_with_permissions( program: Optional[Program] = None, areas: Optional[List[Area]] = None, name: Optional[str] = "Role with Permissions", - ) -> UserRole: + ) -> RoleAssignment: permission_list = [perm.value for perm in permissions] role, created = Role.objects.update_or_create(name=name, defaults={"permissions": permission_list}) - user_role, _ = UserRole.objects.get_or_create(user=user, role=role, business_area=business_area) + user_role, _ = RoleAssignment.objects.get_or_create(user=user, role=role, business_area=business_area) # update Partner permissions for the program if program: From 87db3921d926a35c922f4d1af25b7b792f461b03 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 26 Nov 2024 00:01:49 +0100 Subject: [PATCH 002/208] store previous version of backends --- src/hct_mis_api/apps/core/backends.py | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/hct_mis_api/apps/core/backends.py diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py new file mode 100644 index 0000000000..f24b87b718 --- /dev/null +++ b/src/hct_mis_api/apps/core/backends.py @@ -0,0 +1,92 @@ +from typing import Optional + +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.models import Permission, AnonymousUser +from django.core.cache import cache +from django.db.models import Q, Model +from django.utils import timezone + +from hct_mis_api.apps.account.models import RoleAssignment, User +from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.core.utils import get_selected_program, get_selected_business_area +from hct_mis_api.apps.program.models import Program + + +class PermissionsBackend(BaseBackend): + def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: + if not obj: + program = None + business_area = None + filters = {} + else: + if isinstance(obj, BusinessArea): + program = None + business_area = obj + filters = {"business_area": business_area} + elif isinstance(obj, Program): + program = obj + business_area = obj.business_area + filters = { + "business_area": business_area, + "program": program, + } + if hasattr(obj, "program"): + program = obj.program + business_area = obj.business_area + filters = {"business_area": business_area, "program": program} + elif hasattr(obj, "business_area"): + program = None + business_area = obj.business_area + filters = {"business_area": business_area} + else: + return set() + + if not hasattr(user, "_perm_cache"): + user._perm_cache = {} + + perm_cache_name = f"{business_area or 'None'}_{program or 'None'}" + + if not user._perm_cache.get(perm_cache_name): + """ + The permissions are fetched from: + * the user's Group + * RoleAssignment - where they can be stored either on the Group or on the Role + and assigned either to the User or to their Partner + """ + permissions_set = set() + + # permissions from the User's Group + user_group_permissions = Permission.objects.filter( + group__user=user + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) + + # role assignments from thy67ue User or their Partner + role_assignments = RoleAssignment.objects.filter( + (Q(user=user) | Q(partner__user=user)) + & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) + ).exclude(expiry_date__lt=timezone.now()) + + # permissions from the RoleAssignments' Groups + role_group_permissions = Permission.objects.filter( + group__role_assignments__in=role_assignments + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in role_group_permissions) + + # permissions from RoleAssignment's Roles + for role_assignment in role_assignments: + role_permissions = role_assignment.role.permissions + permissions_set.update(role_permissions) + + user._perm_cache[perm_cache_name] = permissions_set + return user._perm_cache[perm_cache_name] + + def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Model] = None) -> bool: + if user_obj.is_superuser: + return True + if isinstance(user_obj, AnonymousUser): + return False + return super().has_perm(user_obj, perm, obj) + + def get_all_permissions(self, user_obj, obj=None): + return self.get_all_user_permissions(user_obj, obj) From 7725a34bac1eb103b3ddb44f0ccb7edd4346e4c4 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 26 Nov 2024 05:47:21 +0100 Subject: [PATCH 003/208] backends, caches, signals --- src/hct_mis_api/apps/account/caches.py | 13 ++++ src/hct_mis_api/apps/account/models.py | 15 ++-- src/hct_mis_api/apps/account/signals.py | 74 +++++++++++++++++++- src/hct_mis_api/apps/core/backends.py | 93 +++++++++++++------------ src/hct_mis_api/apps/core/utils.py | 16 +++++ src/hct_mis_api/config/settings.py | 2 +- 6 files changed, 163 insertions(+), 50 deletions(-) create mode 100644 src/hct_mis_api/apps/account/caches.py diff --git a/src/hct_mis_api/apps/account/caches.py b/src/hct_mis_api/apps/account/caches.py new file mode 100644 index 0000000000..e79273c1c7 --- /dev/null +++ b/src/hct_mis_api/apps/account/caches.py @@ -0,0 +1,13 @@ +from typing import Optional, TYPE_CHECKING + +from hct_mis_api.apps.account.models import User +from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.program.models import Program + + +def get_user_permissions_version_key(user: "User") -> str: + return f"user:{str(user.id)}:version" + + +def get_user_permissions_cache_key(user: "User", user_version: int, business_area: Optional["BusinessArea"], program: Optional["Program"]) -> str: + return f"permissions:{str(user.id)}:{user_version}:{business_area.slug if business_area else 'None'}:{program.id if program else 'None'}" diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 5134271296..aaa4a55d08 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -323,13 +323,15 @@ class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): business_area = models.ForeignKey("core.BusinessArea", related_name="role_assignments", on_delete=models.CASCADE) user = models.ForeignKey("account.User", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) partner = models.ForeignKey("account.Partner", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - role = models.ForeignKey("account.Role", related_name="role_assignments", on_delete=models.CASCADE) + role = models.ForeignKey("account.Role", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) program = models.ForeignKey("program.Program", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) areas = models.ManyToManyField("geo.Area", related_name="role_assignments", blank=True) full_area_access = models.BooleanField(default=False) expiry_date = models.DateField( blank=True, null=True, help_text="After expiry date this Role Assignment will be inactive." ) + group = models.ForeignKey(Group, related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) + # TODO: only Group OR Role should be set, not both class Meta: constraints = [ @@ -343,6 +345,11 @@ class Meta: models.CheckConstraint( check=(Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False)), name="user_or_partner_not_both" + ), + # either group or role should be assigned; not both + models.CheckConstraint( + check=(Q(group__isnull=False, role__isnull=True) | Q(group__isnull=True, role__isnull=False)), + name="group_or_role_not_both" ) ] @@ -351,13 +358,14 @@ def clean(self) -> None: # Ensure either user or partner is set, but not both if bool(self.user) == bool(self.partner): raise ValidationError("Either user or partner must be set, but not both.") - + # Ensure either group or role is set, but not both + if bool(self.group) == bool(self.role): + raise ValidationError("Either group or role must be set, but not both.") def save(self, *args: Any, **kwargs: Any) -> None: self.clean() super().save(*args, **kwargs) - def __str__(self) -> str: role_holder = self.user if self.user else self.partner return f"{role_holder} {self.role} in {self.business_area}" @@ -403,7 +411,6 @@ class Role(NaturalKeyModel, TimeStampedUUIDModel): null=True, blank=True, ) - group = models.ForeignKey(Group, related_name="roles", on_delete=models.CASCADE, null=True, blank=True) is_visible_on_ui = models.BooleanField(default=True) is_available_for_partner = models.BooleanField(default=True) diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index cc4d232ed5..8c64f5b8fd 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -1,13 +1,19 @@ -from typing import Any +from typing import Any, Iterable from django.contrib.auth import get_user_model -from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save +from django.db.models import Q +from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save, post_delete from django.dispatch import receiver from django.utils import timezone +from hct_mis_api.api.caches import get_or_create_cache_key +from hct_mis_api.apps.account.caches import get_user_permissions_version_key from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from django.contrib.auth.models import Group +from django.core.cache import cache + @receiver(post_save, sender=RoleAssignment) def post_save_pre_delete_roleassignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: @@ -54,3 +60,67 @@ def allowed_business_areas_changed(sender: Any, instance: Partner, action: str, elif action == "post_clear": removed_business_areas = getattr(instance, "_removed_business_areas", []) BusinessAreaPartnerThrough.objects.filter(partner=instance, business_area__in=removed_business_areas).delete() + + +# Signals for permissions caches invalidation + +def _invalidate_user_permissions_cache(users: Iterable) -> None: + for user in users: + version_key = get_user_permissions_version_key(user) + get_or_create_cache_key(version_key, 0) + cache.incr(version_key) + + +@receiver(post_save, sender=RoleAssignment) +@receiver(pre_delete, sender=RoleAssignment) +def invalidate_permissions_cache_on_role_assignment_change(sender, instance, **kwargs): + """ + Invalidate the cache for the User/Partner's Users associated with the RoleAssignment + when the RoleAssignment is created, updated, or deleted. + """ + if hasattr(instance, 'user'): + users = [instance.user] + else: + users = instance.partner.users.all() + _invalidate_user_permissions_cache(users) + + +@receiver(post_save, sender=Role) +@receiver(pre_delete, sender=Role) +def invalidate_permissions_cache_on_role_change(sender, instance, **kwargs): + """ + Invalidate the cache for the User/Partner's Users associated with the Role through a RoleAssignment + when the Role is created, updated, or deleted. + """ + users = User.objects.filter(Q(role_assignments__role=instance) | Q(partner__role_assignments__role=instance)).distinct() + _invalidate_user_permissions_cache(users) + + +@receiver(m2m_changed, sender=Group.permissions.through) +def invalidate_permissions_cache_on_group_permissions_change(sender, instance, action, **kwargs): + """ + Invalidate the cache for all Users that are assigned to that Group + or are assigned to this Group's RoleAssignment + or their Partner is assigned to this Group's RoleAssignment + when the Group's permissions are updated. + """ + if action in ["post_add", "post_remove", "post_clear"]: + users = User.objects.filter( + Q(groups=instance) | Q(role_assignments__group=instance) | Q(partner__role_assignments__group=instance) + ).distinct() + _invalidate_user_permissions_cache(users) + + +@receiver(post_save, sender=Group) +@receiver(pre_delete, sender=Group) +def invalidate_permissions_cache_on_group_change(sender, instance, **kwargs): + """ + Invalidate the cache for all Users that are assigned to that Group + or are assigned to this Group's RoleAssignment + or their Partner is assigned to this Group's RoleAssignment + when the Group is created, updated, or deleted. + """ + users = User.objects.filter( + Q(groups=instance) | Q(role_assignments__group=instance) | Q(partner__role_assignments__group=instance) + ).distinct() + _invalidate_user_permissions_cache(users) diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index f24b87b718..a019ce0b4c 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, TYPE_CHECKING from django.contrib.auth.backends import BaseBackend from django.contrib.auth.models import Permission, AnonymousUser @@ -6,11 +6,15 @@ from django.db.models import Q, Model from django.utils import timezone +from hct_mis_api.api.caches import get_or_create_cache_key +from hct_mis_api.apps.account.caches import get_user_permissions_version_key, get_user_permissions_cache_key from hct_mis_api.apps.account.models import RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea -from hct_mis_api.apps.core.utils import get_selected_program, get_selected_business_area from hct_mis_api.apps.program.models import Program +if TYPE_CHECKING: + from hct_mis_api.apps.account.models import User + class PermissionsBackend(BaseBackend): def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: @@ -30,7 +34,7 @@ def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> se "business_area": business_area, "program": program, } - if hasattr(obj, "program"): + elif hasattr(obj, "program"): program = obj.program business_area = obj.business_area filters = {"business_area": business_area, "program": program} @@ -41,45 +45,48 @@ def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> se else: return set() - if not hasattr(user, "_perm_cache"): - user._perm_cache = {} - - perm_cache_name = f"{business_area or 'None'}_{program or 'None'}" - - if not user._perm_cache.get(perm_cache_name): - """ - The permissions are fetched from: - * the user's Group - * RoleAssignment - where they can be stored either on the Group or on the Role - and assigned either to the User or to their Partner - """ - permissions_set = set() - - # permissions from the User's Group - user_group_permissions = Permission.objects.filter( - group__user=user - ).values_list("content_type__app_label", "codename") - permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) - - # role assignments from thy67ue User or their Partner - role_assignments = RoleAssignment.objects.filter( - (Q(user=user) | Q(partner__user=user)) - & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) - ).exclude(expiry_date__lt=timezone.now()) - - # permissions from the RoleAssignments' Groups - role_group_permissions = Permission.objects.filter( - group__role_assignments__in=role_assignments - ).values_list("content_type__app_label", "codename") - permissions_set.update(f"{app}.{codename}" for app, codename in role_group_permissions) - - # permissions from RoleAssignment's Roles - for role_assignment in role_assignments: - role_permissions = role_assignment.role.permissions - permissions_set.update(role_permissions) - - user._perm_cache[perm_cache_name] = permissions_set - return user._perm_cache[perm_cache_name] + user_version = get_or_create_cache_key(get_user_permissions_version_key(user), 1) + cache_key = get_user_permissions_cache_key(user, user_version, business_area, program) + + cached_permissions = cache.get(cache_key) + + if cached_permissions: + return cached_permissions + + """ + The permissions are fetched from: + * the user's Group + * RoleAssignment - where they can be stored either on the Group or on the Role + and assigned either to the User or to their Partner + """ + permissions_set = set() + + # permissions from the User's Group + user_group_permissions = Permission.objects.filter( + group__users=user + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) + + # role assignments from the User or their Partner + role_assignments = RoleAssignment.objects.filter( + (Q(user=user) | Q(partner__user=user)) + & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) + ).exclude(expiry_date__lt=timezone.now()) + + # permissions from the RoleAssignments' Groups + role_group_permissions = Permission.objects.filter( + group__role_assignments__in=role_assignments + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in role_group_permissions) + + # permissions from RoleAssignment's Roles + for role_assignment in role_assignments: + role_permissions = role_assignment.role.permissions + permissions_set.update(role_permissions) + + cache.set(cache_key, permissions_set, timeout=None) + + return permissions_set def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Model] = None) -> bool: if user_obj.is_superuser: @@ -88,5 +95,5 @@ def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Mode return False return super().has_perm(user_obj, perm, obj) - def get_all_permissions(self, user_obj, obj=None): + def get_all_permissions(self, user_obj: "User", obj: "Model|None" = None) -> set[str]: return self.get_all_user_permissions(user_obj, obj) diff --git a/src/hct_mis_api/apps/core/utils.py b/src/hct_mis_api/apps/core/utils.py index e495f30f04..b58ec0623b 100644 --- a/src/hct_mis_api/apps/core/utils.py +++ b/src/hct_mis_api/apps/core/utils.py @@ -90,6 +90,22 @@ def get_program_id_from_headers(headers: Union[Dict, "HttpHeaders"]) -> Optional program_id = decode_id_string(program_id) if program_id != "all" and program_id != "undefined" else None return program_id +# TODO: change +def get_selected_program(request) -> "Program | None": + if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request + program = request.data.get("variables", {}).get("program") + elif isinstance(request.GET, dict): # REST API Request + program = request.GET.get("program") + return program + + +def get_selected_business_area(request) -> "BusinessArea | None": + if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request + program = request.data.get("variables", {}).get("business_area") + elif isinstance(request.GET, dict): # REST API Request + program = request.GET.get("business_area") + return program + def unique_slugify( instance: "Model", diff --git a/src/hct_mis_api/config/settings.py b/src/hct_mis_api/config/settings.py index 94bf7a97b0..dbc5d33c43 100644 --- a/src/hct_mis_api/config/settings.py +++ b/src/hct_mis_api/config/settings.py @@ -277,7 +277,7 @@ ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 7 AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", + "hct_mis_api.apps.core.backends.PermissionsBackend", "social_core.backends.azuread_tenant.AzureADTenantOAuth2", ] From 01e6126db80e1a7ecdca647f3463932fb26f6f09 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 3 Dec 2024 01:18:01 +0100 Subject: [PATCH 004/208] store version with constriants on models --- src/hct_mis_api/apps/account/models.py | 37 +++++++++++++++++--------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index aaa4a55d08..ec0104913a 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -331,26 +331,32 @@ class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): blank=True, null=True, help_text="After expiry date this Role Assignment will be inactive." ) group = models.ForeignKey(Group, related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - # TODO: only Group OR Role should be set, not both class Meta: constraints = [ - # user can have only one role in BA + # either user or partner should be assigned; not both + models.CheckConstraint( + check=(Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False)), + name="user_or_partner_not_both" + ), + # program and areas can only be assigned for partner roles; not for user roles + models.CheckConstraint( + check=Q(user__isnull=True) | (Q(user__isnull=False) & Q(program__isnull=True) & Q(areas__isnull=True)), + name="program_and_areas_null_for_user" + ), + # unique combination of user, role, and business_area; applies only when a user is assigned, not a partner. + # (For partner assignments, the role can be reused within the same business_area + # if linked to different programs, as the assignment is considered per program, not per business_area.) models.UniqueConstraint( fields=["business_area", "role", "user"], condition=Q(user__isnull=False), name="unique_user_role_assignment" ), - # either user or partner should be assigned; not both + # Partner can only be assigned roles that have flag is_available_for_partner as True models.CheckConstraint( - check=(Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False)), - name="user_or_partner_not_both" + check=Q(partner__isnull=True) | Q(role__is_available_for_partner=True), + name="partner_only_available_roles" ), - # either group or role should be assigned; not both - models.CheckConstraint( - check=(Q(group__isnull=False, role__isnull=True) | Q(group__isnull=True, role__isnull=False)), - name="group_or_role_not_both" - ) ] def clean(self) -> None: @@ -358,9 +364,14 @@ def clean(self) -> None: # Ensure either user or partner is set, but not both if bool(self.user) == bool(self.partner): raise ValidationError("Either user or partner must be set, but not both.") - # Ensure either group or role is set, but not both - if bool(self.group) == bool(self.role): - raise ValidationError("Either group or role must be set, but not both.") + # Ensure program and areas can only be assigned for partner roles; not for user roles + if self.user and (self.program or self.areas.exists()): + raise ValidationError("Program and areas can only be assigned for partner roles; not for user roles.") + # Ensure user role assignment is unique within the business area + if self.user and RoleAssignment.objects.filter( + business_area=self.business_area, role=self.role, user=self.user + ).exclude(id=self.id).exists(): + raise ValidationError("This role is already assigned to the user in the business area.") def save(self, *args: Any, **kwargs: Any) -> None: self.clean() From f32b4047a43f1cdef7aa1bce2a0884ac2a97973b Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 3 Dec 2024 01:58:08 +0100 Subject: [PATCH 005/208] backends and model fixes, constraints, tests for backends initial --- src/hct_mis_api/apps/account/fixtures.py | 3 +- .../apps/account/migrations/0081_migration.py | 33 +++ src/hct_mis_api/apps/account/models.py | 14 +- src/hct_mis_api/apps/core/backends.py | 15 +- tests/unit/apps/core/test_backends.py | 193 ++++++++++++++++++ 5 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0081_migration.py create mode 100644 tests/unit/apps/core/test_backends.py diff --git a/src/hct_mis_api/apps/account/fixtures.py b/src/hct_mis_api/apps/account/fixtures.py index b73058e3ff..1707d9bfaf 100644 --- a/src/hct_mis_api/apps/account/fixtures.py +++ b/src/hct_mis_api/apps/account/fixtures.py @@ -66,9 +66,10 @@ class Meta: class RoleAssignmentFactory(DjangoModelFactory): user = factory.SubFactory(UserFactory) + partner = factory.SubFactory(PartnerFactory, user=None) role = factory.SubFactory(RoleFactory) business_area = factory.SubFactory(BusinessAreaFactory) class Meta: model = RoleAssignment - django_get_or_create = ("user", "role") + django_get_or_create = ("user", "partner", "role") diff --git a/src/hct_mis_api/apps/account/migrations/0081_migration.py b/src/hct_mis_api/apps/account/migrations/0081_migration.py new file mode 100644 index 0000000000..3b4152ad6c --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0081_migration.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.25 on 2024-12-03 00:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('account', '0080_migration'), + ] + + operations = [ + migrations.RemoveField( + model_name='role', + name='group', + ), + migrations.AddField( + model_name='roleassignment', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='auth.group'), + ), + migrations.AlterField( + model_name='roleassignment', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.role'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.CheckConstraint(check=models.Q(('user__isnull', True), models.Q(('user__isnull', False), ('program__isnull', True)), _connector='OR'), name='program_and_areas_null_for_user'), + ), + ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index ec0104913a..242222a7a7 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -336,12 +336,12 @@ class Meta: constraints = [ # either user or partner should be assigned; not both models.CheckConstraint( - check=(Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False)), + check=Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False), name="user_or_partner_not_both" ), - # program and areas can only be assigned for partner roles; not for user roles + # program can only be assigned for partner roles; not for user roles models.CheckConstraint( - check=Q(user__isnull=True) | (Q(user__isnull=False) & Q(program__isnull=True) & Q(areas__isnull=True)), + check=Q(user__isnull=True) | (Q(user__isnull=False) & Q(program__isnull=True)), name="program_and_areas_null_for_user" ), # unique combination of user, role, and business_area; applies only when a user is assigned, not a partner. @@ -352,11 +352,6 @@ class Meta: condition=Q(user__isnull=False), name="unique_user_role_assignment" ), - # Partner can only be assigned roles that have flag is_available_for_partner as True - models.CheckConstraint( - check=Q(partner__isnull=True) | Q(role__is_available_for_partner=True), - name="partner_only_available_roles" - ), ] def clean(self) -> None: @@ -372,6 +367,9 @@ def clean(self) -> None: business_area=self.business_area, role=self.role, user=self.user ).exclude(id=self.id).exists(): raise ValidationError("This role is already assigned to the user in the business area.") + # Ensure partner can only be assigned roles that have flag is_available_for_partner as True + if self.partner and not self.role.is_available_for_partner: + raise ValidationError("Partner can only be assigned roles that are available for partners.") def save(self, *args: Any, **kwargs: Any) -> None: self.clean() diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index a019ce0b4c..668798a824 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -17,7 +17,7 @@ class PermissionsBackend(BaseBackend): - def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: + def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: if not obj: program = None business_area = None @@ -53,6 +53,14 @@ def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> se if cached_permissions: return cached_permissions + # If user does not have access to program, return empty set + if program and not RoleAssignment.objects.filter( + partner=user.partner, + program=program, + expiry_date__gt=timezone.now() + ).exists(): + return set() + """ The permissions are fetched from: * the user's Group @@ -71,7 +79,7 @@ def get_all_user_permissions(self, user: "User", obj: "Model|None" = None) -> se role_assignments = RoleAssignment.objects.filter( (Q(user=user) | Q(partner__user=user)) & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) - ).exclude(expiry_date__lt=timezone.now()) + ).exclude(expiry_date__gt=timezone.now()) # permissions from the RoleAssignments' Groups role_group_permissions = Permission.objects.filter( @@ -94,6 +102,3 @@ def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Mode if isinstance(user_obj, AnonymousUser): return False return super().has_perm(user_obj, perm, obj) - - def get_all_permissions(self, user_obj: "User", obj: "Model|None" = None) -> set[str]: - return self.get_all_user_permissions(user_obj, obj) diff --git a/tests/unit/apps/core/test_backends.py b/tests/unit/apps/core/test_backends.py new file mode 100644 index 0000000000..66dc29f108 --- /dev/null +++ b/tests/unit/apps/core/test_backends.py @@ -0,0 +1,193 @@ +from django.test import TestCase +from django.contrib.auth.models import Group, Permission +from unittest.mock import patch + +from hct_mis_api.apps.account.fixtures import UserFactory, PartnerFactory, RoleFactory, RoleAssignmentFactory +from hct_mis_api.apps.core.fixtures import create_afghanistan, create_ukraine +from hct_mis_api.apps.program.fixtures import ProgramFactory +from hct_mis_api.apps.program.models import Program +from hct_mis_api.apps.core.backends import PermissionsBackend +from django.utils import timezone + + +class TestPermissionsBackend(TestCase): + def setUp(self): + self.partner = PartnerFactory(name="TestPartner") + self.user = UserFactory(partner=self.partner) + self.group = Group.objects.create(name="TestGroup") + self.permission = Permission.objects.create(codename="test_permission", name="Test Permission", + content_type_id=1) + + self.business_area = create_afghanistan() + self.program = ProgramFactory(status=Program.ACTIVE, name="Test Program", business_area=self.business_area) + + self.role_assignment_user = RoleAssignmentFactory( + user=self.user, + business_area=self.business_area, + role=None, + ) + self.role_assignment_partner = RoleAssignmentFactory( + partner=self.partner, + business_area=self.business_area, + role=None, + ) + + self.backend = PermissionsBackend() + + def test_get_all_permissions_no_cache(self): + self.group.permissions.add(self.permission) + self.user.groups.add(self.group) + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.test_permission", permissions) + + def test_get_all_permissions_with_cache(self): + self.group.permissions.add(self.permission) + self.user.groups.add(self.group) + + with self.assertNumQueries(4): + permissions = self.backend.get_all_permissions(self.user, self.business_area) + with self.assertNumQueries(0): + cached_permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertEqual(permissions, cached_permissions) + + @patch("hct_mis_api.api.backends.cache.get") + def test_cache_get(self, mock_cache_get): + mock_cache_get.return_value = {"auth.test_permission"} + permissions = self.backend.get_all_permissions(self.user, self.business_area) + mock_cache_get.assert_called_once_with( + "user_permissions_version_1_1_1" + ) + self.assertIn("auth.test_permission", permissions) + + def test_role_assignment_user_role_permissions(self): + role = RoleFactory(name="GRIEVANCES CROSS AREA FILTER", permissions=["PROGRAMME_CREATE"]) + + self.role_assignment_user.role = role + self.role_assignment_user.save() + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.role_permission", permissions) # NOPE + + def test_role_assignment_user_group_permissions(self): + self.role_assignment_user.group = self.group + self.role_assignment_user.save() + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.role_permission", permissions) # NOPE + + def test_role_assignment_partner_role_permissions(self): + role = RoleFactory(name="Test Role", permissions=["PROGRAMME_FINISH"]) + self.role_assignment_partner.role = role + self.role_assignment_partner.save() + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.partner_permission", permissions) # NOPE + + def test_role_assignment_partner_group_permissions(self): + self.role_assignment_partner.group = self.group + self.role_assignment_partner.save() + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.partner_permission", permissions) # NOPE + + def test_has_perm_for_superuser(self): + self.user.is_superuser = True + self.user.save() + + self.assertTrue(self.backend.has_perm(self.user, "auth.test_permission")) + + def test_permissions_exclusion_expired_assignments(self): + self.role_assignment_user.expiry_date = timezone.now() - timezone.timedelta(days=1) + self.role_assignment_user.save() + + permissions = self.backend.get_all_permissions(self.user) + self.assertNotIn("auth.test_permission", permissions) + + def test_get_permissions_for_program(self): + + # Fetch permissions for the program + permissions = self.backend.get_all_permissions(self.user, self.program) + + self.assertIn("auth.program_permission", permissions) # NOPE + + def test_get_permissions_for_program_other(self): + program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) + + permissions = self.backend.get_all_permissions(self.user, program_other) + + self.assertEqual(permissions, set()) + + def test_get_permissions_from_all_sources(self): + permission1 = Permission.objects.create(codename="test_permission1", name="Test Permission 1", + content_type_id=1) + permission2 = Permission.objects.create(codename="test_permission2", name="Test Permission 2", + content_type_id=1) + permission3 = Permission.objects.create(codename="test_permission3", name="Test Permission 3", + content_type_id=1) + # permission on a user group + group_user = Group.objects.create(name="TestGroupUser") + group_user.permissions.add(permission1) + self.user.groups.add(self.group) + + # permission on a RoleAssignment group for user + group_role_assignment_user = Group.objects.create(name="TestGroupRoleAssignmentUser") + group_role_assignment_user.permissions.add(permission2) + self.role_assignment_user.group = self.group + self.role_assignment_user.save() + + # permission on a RoleAssignment group for partner + group_role_assignment_partner = Group.objects.create(name="TestGroupRoleAssignmentPartner") + group_role_assignment_partner.permissions.add(permission3) + self.role_assignment_partner.group = self.group + self.role_assignment_partner.save() + + # permission on a RoleAssignment role for user + role_user = RoleFactory(name="Test Role User", permissions=["PROGRAMME_CREATE"]) + self.role_assignment_user.role = role_user + self.role_assignment_user.save() + + # permission on a RoleAssignment role for partner + role_partner = RoleFactory(name="Test Role Partner", permissions=["PROGRAMME_FINISH"]) + self.role_assignment_partner.role = role_partner + self.role_assignment_partner.save() + + # all permissions + permissions = self.backend.get_all_permissions(self.user) + + self.assertIn("auth.test_permission1", permissions) + self.assertIn("auth.test_permission2", permissions) + self.assertIn("auth.test_permission3", permissions) + self.assertIn("auth.role_permission", permissions) # # NOPE + + # permissions for BA + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn("auth.test_permission1", permissions) + self.assertIn("auth.test_permission1", permissions) + self.assertIn("auth.test_permission2", permissions) + self.assertIn("auth.test_permission3", permissions) + self.assertIn("auth.role_permission", permissions) # # NOPE + + # permissions for other BA + business_area_other = create_ukraine() + permissions = self.backend.get_all_permissions(self.user, business_area_other) + self.assertEqual(permissions, set()) + + # permissions for program + permissions = self.backend.get_all_permissions(self.user, self.program) + self.assertIn("auth.test_permission1", permissions) + self.assertIn("auth.test_permission2", permissions) + self.assertIn("auth.test_permission3", permissions) + self.assertIn("auth.role_permission", permissions) # # NOPE + + # permissions for other program + program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) + permissions = self.backend.get_all_permissions(self.user, program_other) + self.assertEqual(permissions, set()) From 1326567bdbdf57921d4e737a0d626abf9c251e83 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 3 Dec 2024 06:26:18 +0100 Subject: [PATCH 006/208] fixes to permissions, model, factory, add tests for various scenario --- src/hct_mis_api/apps/account/fixtures.py | 12 +- .../apps/account/fixtures/data.json | 4 +- .../apps/account/migrations/0003_migration.py | 71 ++++++++ .../apps/account/migrations/0080_migration.py | 90 ---------- .../apps/account/migrations/0081_migration.py | 33 ---- src/hct_mis_api/apps/account/models.py | 8 +- src/hct_mis_api/apps/account/signals.py | 4 +- src/hct_mis_api/apps/core/backends.py | 43 ++--- src/hct_mis_api/config/settings.py | 1 + src/hct_mis_api/migrations_script/main.py | 1 + tests/unit/apps/core/test_backends.py | 158 +++++++++++------- 11 files changed, 214 insertions(+), 211 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0003_migration.py delete mode 100644 src/hct_mis_api/apps/account/migrations/0080_migration.py delete mode 100644 src/hct_mis_api/apps/account/migrations/0081_migration.py diff --git a/src/hct_mis_api/apps/account/fixtures.py b/src/hct_mis_api/apps/account/fixtures.py index 5fa50cd34e..b8766ce56c 100644 --- a/src/hct_mis_api/apps/account/fixtures.py +++ b/src/hct_mis_api/apps/account/fixtures.py @@ -65,11 +65,19 @@ class Meta: class RoleAssignmentFactory(DjangoModelFactory): - user = factory.SubFactory(UserFactory) - partner = factory.SubFactory(PartnerFactory, user=None) role = factory.SubFactory(RoleFactory) business_area = factory.SubFactory(BusinessAreaFactory) class Meta: model = RoleAssignment django_get_or_create = ("user", "partner", "role") + + @factory.lazy_attribute + def partner(self): + # Only create partner if user is not provided + return None if self.user else PartnerFactory() + + @factory.lazy_attribute + def user(self): + # Only create user if partner is not provided + return None if self.partner else UserFactory() diff --git a/src/hct_mis_api/apps/account/fixtures/data.json b/src/hct_mis_api/apps/account/fixtures/data.json index e5c1bb55b3..4c618eec5d 100644 --- a/src/hct_mis_api/apps/account/fixtures/data.json +++ b/src/hct_mis_api/apps/account/fixtures/data.json @@ -84,7 +84,7 @@ } }, { - "model": "account.userrole", + "model": "account.roleassignment", "pk": "bc46afbd-a0b7-48f3-abf9-8a4c4b033092", "fields": { "created_at": "2022-03-30 09:05:24.581-00:00", @@ -95,7 +95,7 @@ } }, { - "model": "account.userrole", + "model": "account.roleassignment", "pk": "ef880fed-cf92-4f97-b346-cc25fee1b4cd", "fields": { "created_at": "2022-03-30 09:08:03.635-00:00", diff --git a/src/hct_mis_api/apps/account/migrations/0003_migration.py b/src/hct_mis_api/apps/account/migrations/0003_migration.py new file mode 100644 index 0000000000..1a5c54297e --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0003_migration.py @@ -0,0 +1,71 @@ +# Generated by Django 3.2.25 on 2024-12-03 01:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('geo', '0001_migration'), + ('program', '0001_migration'), + ('core', '0002_migration'), + ('account', '0002_migration'), + ] + + operations = [ + migrations.CreateModel( + name='RoleAssignment', + fields=[ + ('id', model_utils.fields.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('full_area_access', models.BooleanField(default=False)), + ('expiry_date', models.DateField(blank=True, help_text='After expiry date this Role Assignment will be inactive.', null=True)), + ('areas', models.ManyToManyField(blank=True, related_name='role_assignments', to='geo.Area')), + ('business_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='core.businessarea')), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='auth.group')), + ('partner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.partner')), + ('program', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='program.program')), + ], + ), + migrations.AddField( + model_name='role', + name='is_available_for_partner', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='role', + name='is_visible_on_ui', + field=models.BooleanField(default=True), + ), + migrations.DeleteModel( + name='UserRole', + ), + migrations.AddField( + model_name='roleassignment', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.role'), + ), + migrations.AddField( + model_name='roleassignment', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.CheckConstraint(check=models.Q(models.Q(('partner__isnull', True), ('user__isnull', False)), models.Q(('partner__isnull', False), ('user__isnull', True)), _connector='OR'), name='user_or_partner_not_both'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.CheckConstraint(check=models.Q(('user__isnull', True), models.Q(('user__isnull', False), ('program__isnull', True)), _connector='OR'), name='program_and_areas_null_for_user'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('business_area', 'role', 'user'), name='unique_user_role_assignment'), + ), + ] diff --git a/src/hct_mis_api/apps/account/migrations/0080_migration.py b/src/hct_mis_api/apps/account/migrations/0080_migration.py deleted file mode 100644 index f8ce7542c9..0000000000 --- a/src/hct_mis_api/apps/account/migrations/0080_migration.py +++ /dev/null @@ -1,90 +0,0 @@ -# Generated by Django 3.2.25 on 2024-11-05 23:01 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('program', '0053_migration'), - ('auth', '0012_alter_user_first_name_max_length'), - ('geo', '0009_migration'), - ('core', '0088_migration'), - ('account', '0079_migration'), - ] - - operations = [ - migrations.RenameModel( - old_name='UserRole', - new_name='RoleAssignment', - ), - migrations.AddField( - model_name='role', - name='group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='roles', to='auth.group'), - ), - migrations.AddField( - model_name='role', - name='is_available_for_partner', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='role', - name='is_visible_on_ui', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='roleassignment', - name='areas', - field=models.ManyToManyField(blank=True, related_name='role_assignments', to='geo.Area'), - ), - migrations.AddField( - model_name='roleassignment', - name='full_area_access', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='roleassignment', - name='partner', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.partner'), - ), - migrations.AddField( - model_name='roleassignment', - name='program', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='program.program'), - ), - migrations.AlterField( - model_name='roleassignment', - name='business_area', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='core.businessarea'), - ), - migrations.AlterField( - model_name='roleassignment', - name='expiry_date', - field=models.DateField(blank=True, help_text='After expiry date this Role Assignment will be inactive.', null=True), - ), - migrations.AlterField( - model_name='roleassignment', - name='role', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.role'), - ), - migrations.AlterField( - model_name='roleassignment', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterUniqueTogether( - name='roleassignment', - unique_together=set(), - ), - migrations.AddConstraint( - model_name='roleassignment', - constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('business_area', 'role', 'user'), name='unique_user_role_assignment'), - ), - migrations.AddConstraint( - model_name='roleassignment', - constraint=models.CheckConstraint(check=models.Q(models.Q(('partner__isnull', True), ('user__isnull', False)), models.Q(('partner__isnull', False), ('user__isnull', True)), _connector='OR'), name='user_or_partner_not_both'), - ), - ] diff --git a/src/hct_mis_api/apps/account/migrations/0081_migration.py b/src/hct_mis_api/apps/account/migrations/0081_migration.py deleted file mode 100644 index 3b4152ad6c..0000000000 --- a/src/hct_mis_api/apps/account/migrations/0081_migration.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.2.25 on 2024-12-03 00:25 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ('account', '0080_migration'), - ] - - operations = [ - migrations.RemoveField( - model_name='role', - name='group', - ), - migrations.AddField( - model_name='roleassignment', - name='group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='auth.group'), - ), - migrations.AlterField( - model_name='roleassignment', - name='role', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_assignments', to='account.role'), - ), - migrations.AddConstraint( - model_name='roleassignment', - constraint=models.CheckConstraint(check=models.Q(('user__isnull', True), models.Q(('user__isnull', False), ('program__isnull', True)), _connector='OR'), name='program_and_areas_null_for_user'), - ), - ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 242222a7a7..8adb8df646 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -249,19 +249,19 @@ def cached_role_assignments(self) -> QuerySet["RoleAssignment"]: def can_download_storage_files(self) -> bool: return any( self.has_permission(Permissions.DOWNLOAD_STORAGE_FILE.name, role.business_area) - for role in self.cached_user_roles() + for role in self.cached_role_assignments() ) def can_change_fsp(self) -> bool: return any( self.has_permission(Permissions.PM_ADMIN_FINANCIAL_SERVICE_PROVIDER_UPDATE.name, role.business_area) - for role in self.cached_user_roles() + for role in self.cached_role_assignments() ) def can_add_business_area_to_partner(self) -> bool: return any( self.has_permission(Permissions.CAN_ADD_BUSINESS_AREA_TO_PARTNER.name, role.business_area) - for role in self.cached_user_roles() + for role in self.cached_role_assignments() ) def email_user( # type: ignore @@ -368,7 +368,7 @@ def clean(self) -> None: ).exclude(id=self.id).exists(): raise ValidationError("This role is already assigned to the user in the business area.") # Ensure partner can only be assigned roles that have flag is_available_for_partner as True - if self.partner and not self.role.is_available_for_partner: + if self.partner and self.role and not self.role.is_available_for_partner: raise ValidationError("Partner can only be assigned roles that are available for partners.") def save(self, *args: Any, **kwargs: Any) -> None: diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 8c64f5b8fd..598b8b2faf 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -78,10 +78,10 @@ def invalidate_permissions_cache_on_role_assignment_change(sender, instance, **k Invalidate the cache for the User/Partner's Users associated with the RoleAssignment when the RoleAssignment is created, updated, or deleted. """ - if hasattr(instance, 'user'): + if instance.user: users = [instance.user] else: - users = instance.partner.users.all() + users = instance.partner.user_set.all() _invalidate_user_permissions_cache(users) diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index 668798a824..07d9927824 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -8,7 +8,7 @@ from hct_mis_api.api.caches import get_or_create_cache_key from hct_mis_api.apps.account.caches import get_user_permissions_version_key, get_user_permissions_cache_key -from hct_mis_api.apps.account.models import RoleAssignment, User +from hct_mis_api.apps.account.models import RoleAssignment, User, Role from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.models import Program @@ -53,12 +53,12 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str if cached_permissions: return cached_permissions - # If user does not have access to program, return empty set + # If permission is checked for a Program and User does not have access to it, return empty set if program and not RoleAssignment.objects.filter( - partner=user.partner, - program=program, - expiry_date__gt=timezone.now() - ).exists(): + Q(partner=user.partner) + & Q(business_area=business_area) + & (Q(program=None) | Q(program=program)) + ).exclude(expiry_date__lt=timezone.now()).exists(): return set() """ @@ -67,30 +67,35 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str * RoleAssignment - where they can be stored either on the Group or on the Role and assigned either to the User or to their Partner """ - permissions_set = set() - - # permissions from the User's Group - user_group_permissions = Permission.objects.filter( - group__users=user - ).values_list("content_type__app_label", "codename") - permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) # role assignments from the User or their Partner role_assignments = RoleAssignment.objects.filter( (Q(user=user) | Q(partner__user=user)) & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) - ).exclude(expiry_date__gt=timezone.now()) + ).exclude(expiry_date__lt=timezone.now()) + + if business_area and not role_assignments.exists(): + return set() + + permissions_set = set() # permissions from the RoleAssignments' Groups - role_group_permissions = Permission.objects.filter( + role_assignment_group_permissions = Permission.objects.filter( group__role_assignments__in=role_assignments ).values_list("content_type__app_label", "codename") - permissions_set.update(f"{app}.{codename}" for app, codename in role_group_permissions) + permissions_set.update(f"{app}.{codename}" for app, codename in role_assignment_group_permissions) # permissions from RoleAssignment's Roles - for role_assignment in role_assignments: - role_permissions = role_assignment.role.permissions - permissions_set.update(role_permissions) + role_assignment_role_permissions = Role.objects.filter( + role_assignments__in=role_assignments + ).values_list("permissions", flat=True) + permissions_set.update(permission for permission_list in role_assignment_role_permissions for permission in permission_list) + + # permissions from the User's Group + user_group_permissions = Permission.objects.filter( + group__user=user + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) cache.set(cache_key, permissions_set, timeout=None) diff --git a/src/hct_mis_api/config/settings.py b/src/hct_mis_api/config/settings.py index 0660ce2f49..2a560e9da3 100644 --- a/src/hct_mis_api/config/settings.py +++ b/src/hct_mis_api/config/settings.py @@ -289,6 +289,7 @@ ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 7 AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", "hct_mis_api.apps.core.backends.PermissionsBackend", "social_core.backends.azuread_tenant.AzureADTenantOAuth2", ] diff --git a/src/hct_mis_api/migrations_script/main.py b/src/hct_mis_api/migrations_script/main.py index 805284688b..35e2033249 100644 --- a/src/hct_mis_api/migrations_script/main.py +++ b/src/hct_mis_api/migrations_script/main.py @@ -52,6 +52,7 @@ def apply_migrations(): ("payment", "0002_migration"), ("payment", "0003_migration"), ("aurora", "0003_migration"), + ("account", "0003_migration"), ] fake_migrations(excluded_migrations) apply_migrations() diff --git a/tests/unit/apps/core/test_backends.py b/tests/unit/apps/core/test_backends.py index 66dc29f108..0aa2e2e8b8 100644 --- a/tests/unit/apps/core/test_backends.py +++ b/tests/unit/apps/core/test_backends.py @@ -1,9 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.contrib.auth.models import Group, Permission from unittest.mock import patch from hct_mis_api.apps.account.fixtures import UserFactory, PartnerFactory, RoleFactory, RoleAssignmentFactory from hct_mis_api.apps.core.fixtures import create_afghanistan, create_ukraine +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program from hct_mis_api.apps.core.backends import PermissionsBackend @@ -14,9 +16,12 @@ class TestPermissionsBackend(TestCase): def setUp(self): self.partner = PartnerFactory(name="TestPartner") self.user = UserFactory(partner=self.partner) - self.group = Group.objects.create(name="TestGroup") + + self.content_type = ContentType.objects.get_for_model(BusinessArea) self.permission = Permission.objects.create(codename="test_permission", name="Test Permission", - content_type_id=1) + content_type=self.content_type) + self.group = Group.objects.create(name="TestGroup") + self.group.permissions.add(self.permission) self.business_area = create_afghanistan() self.program = ProgramFactory(status=Program.ACTIVE, name="Test Program", business_area=self.business_area) @@ -34,14 +39,6 @@ def setUp(self): self.backend = PermissionsBackend() - def test_get_all_permissions_no_cache(self): - self.group.permissions.add(self.permission) - self.user.groups.add(self.group) - - permissions = self.backend.get_all_permissions(self.user, self.business_area) - - self.assertIn("auth.test_permission", permissions) - def test_get_all_permissions_with_cache(self): self.group.permissions.add(self.permission) self.user.groups.add(self.group) @@ -53,24 +50,32 @@ def test_get_all_permissions_with_cache(self): self.assertEqual(permissions, cached_permissions) - @patch("hct_mis_api.api.backends.cache.get") + @patch("hct_mis_api.apps.core.backends.cache.get") def test_cache_get(self, mock_cache_get): - mock_cache_get.return_value = {"auth.test_permission"} + mock_cache_get.return_value = {self._get_permission_name_combined(self.permission)} permissions = self.backend.get_all_permissions(self.user, self.business_area) - mock_cache_get.assert_called_once_with( - "user_permissions_version_1_1_1" - ) - self.assertIn("auth.test_permission", permissions) + mock_cache_get.assert_called() + assert mock_cache_get.call_count == 2 + self.assertIn(self._get_permission_name_combined(self.permission), permissions) + + def test_user_group_permissions(self): + self.group.permissions.add(self.permission) + self.user.groups.add(self.group) + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + + self.assertIn(self._get_permission_name_combined(self.permission), permissions) def test_role_assignment_user_role_permissions(self): - role = RoleFactory(name="GRIEVANCES CROSS AREA FILTER", permissions=["PROGRAMME_CREATE"]) + role = RoleFactory(name="Role for User", permissions=["PROGRAMME_CREATE", "PROGRAMME_UPDATE"]) self.role_assignment_user.role = role self.role_assignment_user.save() permissions = self.backend.get_all_permissions(self.user, self.business_area) - self.assertIn("auth.role_permission", permissions) # NOPE + self.assertIn("PROGRAMME_CREATE", permissions) + self.assertIn("PROGRAMME_UPDATE", permissions) def test_role_assignment_user_group_permissions(self): self.role_assignment_user.group = self.group @@ -78,16 +83,16 @@ def test_role_assignment_user_group_permissions(self): permissions = self.backend.get_all_permissions(self.user, self.business_area) - self.assertIn("auth.role_permission", permissions) # NOPE + self.assertIn(self._get_permission_name_combined(self.permission), permissions) def test_role_assignment_partner_role_permissions(self): - role = RoleFactory(name="Test Role", permissions=["PROGRAMME_FINISH"]) + role = RoleFactory(name="Role for Partner", permissions=["PROGRAMME_FINISH"]) self.role_assignment_partner.role = role self.role_assignment_partner.save() permissions = self.backend.get_all_permissions(self.user, self.business_area) - self.assertIn("auth.partner_permission", permissions) # NOPE + self.assertIn("PROGRAMME_FINISH", permissions) def test_role_assignment_partner_group_permissions(self): self.role_assignment_partner.group = self.group @@ -95,57 +100,93 @@ def test_role_assignment_partner_group_permissions(self): permissions = self.backend.get_all_permissions(self.user, self.business_area) - self.assertIn("auth.partner_permission", permissions) # NOPE + self.assertIn(self._get_permission_name_combined(self.permission), permissions) def test_has_perm_for_superuser(self): self.user.is_superuser = True self.user.save() - self.assertTrue(self.backend.has_perm(self.user, "auth.test_permission")) + self.assertTrue(self.backend.has_perm(self.user, f"{self.content_type.app_label}.{self.permission.codename}")) + self.assertTrue(self.backend.has_perm(self.user, "PROGRAMME_FINISH", self.program)) + + def test_role_expired(self): + self.role_assignment_user.group = self.group + self.role_assignment_user.save() + + permissions = self.backend.get_all_permissions(self.user, self.business_area) + self.assertIn(self._get_permission_name_combined(self.permission), permissions) - def test_permissions_exclusion_expired_assignments(self): self.role_assignment_user.expiry_date = timezone.now() - timezone.timedelta(days=1) self.role_assignment_user.save() - permissions = self.backend.get_all_permissions(self.user) - self.assertNotIn("auth.test_permission", permissions) + permissions = self.backend.get_all_permissions(self.user, self.business_area) + self.assertNotIn(self._get_permission_name_combined(self.permission), permissions) def test_get_permissions_for_program(self): + role = RoleFactory(name="Role for Partner", permissions=["PROGRAMME_FINISH"]) + self.role_assignment_partner.role = role + self.role_assignment_partner.program = self.program + self.role_assignment_partner.save() - # Fetch permissions for the program - permissions = self.backend.get_all_permissions(self.user, self.program) + role = RoleFactory(name="Role for User", permissions=["PROGRAMME_CREATE", "PROGRAMME_UPDATE"]) + self.role_assignment_user.role = role + self.role_assignment_user.save() - self.assertIn("auth.program_permission", permissions) # NOPE + permissions_in_program = self.backend.get_all_permissions(self.user, self.program) + self.assertIn("PROGRAMME_FINISH", permissions_in_program) + self.assertIn("PROGRAMME_CREATE", permissions_in_program) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program) - def test_get_permissions_for_program_other(self): + # no permissions for other program program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) + permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) + self.assertEqual(set(), permissions_in_program_other) - permissions = self.backend.get_all_permissions(self.user, program_other) - - self.assertEqual(permissions, set()) + # partner loses permission for the program and gets permission for the other + self.role_assignment_partner.program = program_other + self.role_assignment_partner.save() + permissions_in_program = self.backend.get_all_permissions(self.user, self.program) + self.assertEqual(set(), permissions_in_program) + permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) + self.assertIn("PROGRAMME_FINISH", permissions_in_program_other) + self.assertIn("PROGRAMME_CREATE", permissions_in_program_other) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) + + # partner gets access to all programs in the business area (program=None) + self.role_assignment_partner.program = None + self.role_assignment_partner.save() + permissions_in_program = self.backend.get_all_permissions(self.user, self.program) + self.assertIn("PROGRAMME_FINISH", permissions_in_program) + self.assertIn("PROGRAMME_CREATE", permissions_in_program) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program) + permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) + self.assertIn("PROGRAMME_FINISH", permissions_in_program_other) + self.assertIn("PROGRAMME_CREATE", permissions_in_program_other) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) def test_get_permissions_from_all_sources(self): permission1 = Permission.objects.create(codename="test_permission1", name="Test Permission 1", - content_type_id=1) + content_type=self.content_type) permission2 = Permission.objects.create(codename="test_permission2", name="Test Permission 2", - content_type_id=1) + content_type=self.content_type) permission3 = Permission.objects.create(codename="test_permission3", name="Test Permission 3", - content_type_id=1) + content_type=self.content_type) # permission on a user group group_user = Group.objects.create(name="TestGroupUser") group_user.permissions.add(permission1) - self.user.groups.add(self.group) + self.user.groups.add(group_user) # permission on a RoleAssignment group for user group_role_assignment_user = Group.objects.create(name="TestGroupRoleAssignmentUser") group_role_assignment_user.permissions.add(permission2) - self.role_assignment_user.group = self.group + self.role_assignment_user.group = group_role_assignment_user self.role_assignment_user.save() # permission on a RoleAssignment group for partner group_role_assignment_partner = Group.objects.create(name="TestGroupRoleAssignmentPartner") group_role_assignment_partner.permissions.add(permission3) - self.role_assignment_partner.group = self.group + self.role_assignment_partner.group = group_role_assignment_partner + self.role_assignment_partner.program = self.program self.role_assignment_partner.save() # permission on a RoleAssignment role for user @@ -158,36 +199,35 @@ def test_get_permissions_from_all_sources(self): self.role_assignment_partner.role = role_partner self.role_assignment_partner.save() - # all permissions + # outside-BA permissions (only from user's group) permissions = self.backend.get_all_permissions(self.user) - - self.assertIn("auth.test_permission1", permissions) - self.assertIn("auth.test_permission2", permissions) - self.assertIn("auth.test_permission3", permissions) - self.assertIn("auth.role_permission", permissions) # # NOPE + self.assertEqual({self._get_permission_name_combined(permission1)}, permissions) # permissions for BA permissions = self.backend.get_all_permissions(self.user, self.business_area) + self.assertIn(self._get_permission_name_combined(permission1), permissions) + self.assertIn(self._get_permission_name_combined(permission2), permissions) + self.assertIn(self._get_permission_name_combined(permission3), permissions) + self.assertIn("PROGRAMME_CREATE", permissions) + self.assertIn("PROGRAMME_FINISH", permissions) - self.assertIn("auth.test_permission1", permissions) - self.assertIn("auth.test_permission1", permissions) - self.assertIn("auth.test_permission2", permissions) - self.assertIn("auth.test_permission3", permissions) - self.assertIn("auth.role_permission", permissions) # # NOPE - - # permissions for other BA + # permissions for other BA - empty business_area_other = create_ukraine() permissions = self.backend.get_all_permissions(self.user, business_area_other) - self.assertEqual(permissions, set()) + self.assertEqual(set(), permissions) # permissions for program permissions = self.backend.get_all_permissions(self.user, self.program) - self.assertIn("auth.test_permission1", permissions) - self.assertIn("auth.test_permission2", permissions) - self.assertIn("auth.test_permission3", permissions) - self.assertIn("auth.role_permission", permissions) # # NOPE + self.assertIn(self._get_permission_name_combined(permission1), permissions) + self.assertIn(self._get_permission_name_combined(permission2), permissions) + self.assertIn(self._get_permission_name_combined(permission3), permissions) + self.assertIn("PROGRAMME_CREATE", permissions) + self.assertIn("PROGRAMME_FINISH", permissions) - # permissions for other program + # permissions for other program - empty (partner does not have access to this program) program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) permissions = self.backend.get_all_permissions(self.user, program_other) - self.assertEqual(permissions, set()) + self.assertEqual(set(), permissions) + + def _get_permission_name_combined(self, permission): + return f"{self.content_type.app_label}.{permission.codename}" \ No newline at end of file From c7611fcaac96df666666d5c558b4e8202a32d6e3 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 3 Dec 2024 06:32:25 +0100 Subject: [PATCH 007/208] append errors --- src/hct_mis_api/apps/account/models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 8adb8df646..f0a560d23e 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -356,20 +356,23 @@ class Meta: def clean(self) -> None: super().clean() + errors = [] # Ensure either user or partner is set, but not both if bool(self.user) == bool(self.partner): - raise ValidationError("Either user or partner must be set, but not both.") + errors.append("Either user or partner must be set, but not both.") # Ensure program and areas can only be assigned for partner roles; not for user roles if self.user and (self.program or self.areas.exists()): - raise ValidationError("Program and areas can only be assigned for partner roles; not for user roles.") + errors.append("Program and areas can only be assigned for partner roles; not for user roles.") # Ensure user role assignment is unique within the business area if self.user and RoleAssignment.objects.filter( business_area=self.business_area, role=self.role, user=self.user ).exclude(id=self.id).exists(): - raise ValidationError("This role is already assigned to the user in the business area.") + errors.append("This role is already assigned to the user in the business area.") # Ensure partner can only be assigned roles that have flag is_available_for_partner as True if self.partner and self.role and not self.role.is_available_for_partner: - raise ValidationError("Partner can only be assigned roles that are available for partners.") + errors.append("Partner can only be assigned roles that are available for partners.") + if errors: + raise ValidationError(errors) def save(self, *args: Any, **kwargs: Any) -> None: self.clean() From e73bb7a9f89a5729ac985a362120b3082801fcec Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Mon, 9 Dec 2024 18:22:57 +0100 Subject: [PATCH 008/208] celery task to invalidate cache for expired roles, test for celery task --- src/hct_mis_api/apps/account/celery_tasks.py | 29 ++++ src/hct_mis_api/apps/account/signals.py | 4 +- src/hct_mis_api/apps/core/tasks_schedules.py | 4 + tests/unit/apps/account/test_celery_tasks.py | 146 +++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/hct_mis_api/apps/account/celery_tasks.py create mode 100644 tests/unit/apps/account/test_celery_tasks.py diff --git a/src/hct_mis_api/apps/account/celery_tasks.py b/src/hct_mis_api/apps/account/celery_tasks.py new file mode 100644 index 0000000000..823b93260f --- /dev/null +++ b/src/hct_mis_api/apps/account/celery_tasks.py @@ -0,0 +1,29 @@ +import datetime +import logging + +from django.db.models import Q +from django.utils import timezone +from typing import Any + +from django.db import transaction + +from hct_mis_api.apps.account.models import RoleAssignment, User +from hct_mis_api.apps.account.signals import _invalidate_user_permissions_cache +from hct_mis_api.apps.core.celery import app + +from hct_mis_api.apps.utils.logs import log_start_and_end +from hct_mis_api.apps.utils.sentry import sentry_tags + +logger = logging.getLogger(__name__) + + +# @app.task(bind=True, default_retry_delay=60, max_retries=3) +# @log_start_and_end +# @sentry_tags +def invalidate_permissions_cache_for_user_if_expired_role() -> bool: + # Invalidate permissions cache for users with roles that expired a day before + day_ago = timezone.now() - datetime.timedelta(days=1) + users = User.objects.filter( + Q(role_assignments__expiry_date=day_ago.date()) | Q(partner__role_assignments__expiry_date=day_ago.date())).distinct() + _invalidate_user_permissions_cache(users) + return True diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 598b8b2faf..67e263cee9 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -16,14 +16,14 @@ @receiver(post_save, sender=RoleAssignment) -def post_save_pre_delete_roleassignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: +def post_save_pre_delete_role_assignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: if instance.user: instance.user.last_modify_date = timezone.now() instance.user.save() @receiver(pre_delete, sender=RoleAssignment) -def pre_delete_roleassignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: +def pre_delete_role_assignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: if instance.user: instance.user.last_modify_date = timezone.now() instance.user.save() diff --git a/src/hct_mis_api/apps/core/tasks_schedules.py b/src/hct_mis_api/apps/core/tasks_schedules.py index 556506e621..b3ce93bbd3 100644 --- a/src/hct_mis_api/apps/core/tasks_schedules.py +++ b/src/hct_mis_api/apps/core/tasks_schedules.py @@ -53,4 +53,8 @@ "task": "hct_mis_api.apps.dashboard.celery_tasks.update_dashboard_figures", "schedule": crontab(hour="*/24"), }, + "invalidate_permissions_cache_for_user_if_expired_role": { + "task": "hct_mis_api.apps.account.celery_tasks.invalidate_permissions_cache_for_user_if_expired_role", + "schedule": crontab(hour="*/24"), + }, } diff --git a/tests/unit/apps/account/test_celery_tasks.py b/tests/unit/apps/account/test_celery_tasks.py new file mode 100644 index 0000000000..19e322ec06 --- /dev/null +++ b/tests/unit/apps/account/test_celery_tasks.py @@ -0,0 +1,146 @@ +from datetime import timedelta +from unittest.mock import patch +from django.core.cache import cache +from django.utils import timezone +import pytest +from hct_mis_api.apps.account.caches import get_user_permissions_version_key +from hct_mis_api.apps.account.celery_tasks import invalidate_permissions_cache_for_user_if_expired_role +from hct_mis_api.apps.account.fixtures import BusinessAreaFactory, RoleAssignmentFactory, PartnerFactory, UserFactory, \ + RoleFactory +from hct_mis_api.apps.account.models import User +from hct_mis_api.apps.account.signals import _invalidate_user_permissions_cache + +from hct_mis_api.apps.program.fixtures import ProgramFactory +from hct_mis_api.apps.program.models import Program + +pytestmark = pytest.mark.django_db + + +class TestInvalidatePermissionsCacheForUserIfExpiredRoleTask: + @pytest.fixture(autouse=True) + def set_up(self, afghanistan: BusinessAreaFactory) -> None: + self.partner = PartnerFactory(name="TestPartner") + self.user1 = UserFactory(partner=self.partner) + self.user2 = UserFactory(partner=None) + self.afghanistan = afghanistan + self.program = ProgramFactory(status=Program.ACTIVE, name="Test Program", business_area=self.afghanistan) + role1 = RoleFactory(name="Test Expired Role") + role2 = RoleFactory(name="Test OK Role") + role3 = RoleFactory(name="Test Partner Expired Role") + + self.role_assignment_user1 = RoleAssignmentFactory( + user=self.user1, + business_area=self.afghanistan, + role=role1, + ) + self.role_assignment_ok_user1 = RoleAssignmentFactory( + user=self.user1, + business_area=self.afghanistan, + role=role2, + ) + self.role_assignment_user2 = RoleAssignmentFactory( + user=self.user2, + business_area=self.afghanistan, + role=None, + ) + self.role_assignment_partner = RoleAssignmentFactory( + partner=self.partner, + business_area=self.afghanistan, + role=role3, + ) + + self.version_key_user1_before = self._get_cache_version(self.user1) + self.version_key_user2_before = self._get_cache_version(self.user2) + + @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") + def test_invalidate_permissions_cache_role_on_user(self, mock_invalidate_cache) -> None: + mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache + invalidate_permissions_cache_for_user_if_expired_role() + mock_invalidate_cache.assert_called_once() + affected_users = list(mock_invalidate_cache.call_args[0][0]) + assert len(affected_users) == 0 + assert self._get_cache_version(self.user1) == self.version_key_user1_before + assert self._get_cache_version(self.user2) == self.version_key_user2_before + + self.role_assignment_user1.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_user1.save() + self.version_key_user1_before += 1 # increased version from signal + + result = invalidate_permissions_cache_for_user_if_expired_role() + assert len(mock_invalidate_cache.call_args_list) == 2 # called second time now + affected_users = mock_invalidate_cache.call_args[0][0] # users from most recent call + + assert len(affected_users) == 1 + assert self.user1 in affected_users + assert result is True + + assert self._get_cache_version(self.user1) == self.version_key_user1_before + 1 + assert self._get_cache_version(self.user2) == self.version_key_user2_before + + @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") + def test_invalidate_permissions_cache_role_on_users(self, mock_invalidate_cache) -> None: + mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache + self.role_assignment_user1.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_user1.save() + self.role_assignment_user2.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_user2.save() + self.version_key_user1_before += 1 # increased version from signal + self.version_key_user2_before += 1 # increased version from signal + + result = invalidate_permissions_cache_for_user_if_expired_role() + mock_invalidate_cache.assert_called_once() + affected_users = mock_invalidate_cache.call_args[0][0] + + assert len(affected_users) == 2 + assert self.user1 in affected_users + assert self.user2 in affected_users + assert result is True + + assert self._get_cache_version(self.user1) == self.version_key_user1_before + 1 + assert self._get_cache_version(self.user2) == self.version_key_user2_before + 1 + + @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") + def test_invalidate_permissions_cache_role_on_partner(self, mock_invalidate_cache) -> None: + mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache + self.role_assignment_partner.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_partner.save() + self.version_key_user1_before += 1 # increased version from signal + + result = invalidate_permissions_cache_for_user_if_expired_role() + mock_invalidate_cache.assert_called_once() + affected_users = mock_invalidate_cache.call_args[0][0] + + assert len(affected_users) == 1 + assert self.user1 in affected_users + assert result is True + + assert self._get_cache_version(self.user1) == self.version_key_user1_before + 1 + assert self._get_cache_version(self.user2) == self.version_key_user2_before + + @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") + def test_invalidate_permissions_cache_role_on_users_and_partner(self, mock_invalidate_cache) -> None: + mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache + self.role_assignment_partner.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_partner.save() + self.role_assignment_user1.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_user1.save() + self.role_assignment_user2.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment_user2.save() + self.version_key_user1_before += 2 # increased version from signals (on user + partner) + self.version_key_user2_before += 1 # increased version from signal + + result = invalidate_permissions_cache_for_user_if_expired_role() + mock_invalidate_cache.assert_called_once() + affected_users = mock_invalidate_cache.call_args[0][0] + + assert len(affected_users) == 2 + assert self.user1 in affected_users + assert self.user2 in affected_users + assert result is True + + assert self._get_cache_version(self.user1) == self.version_key_user1_before + 1 + assert self._get_cache_version(self.user2) == self.version_key_user2_before + 1 + + def _get_cache_version(self, user: User) -> int: + version_key = get_user_permissions_version_key(user) + return cache.get(version_key) From 8c64111efb64e928a0a14c330df151e031a676c0 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Tue, 10 Dec 2024 00:52:14 +0100 Subject: [PATCH 009/208] add many tests, to the model constraints and to signals for cache invalidation --- src/hct_mis_api/apps/account/celery_tasks.py | 8 +- tests/unit/apps/account/test_models.py | 104 +++++++- ...ignals_for_invalidate_permission_caches.py | 251 ++++++++++++++++++ 3 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py diff --git a/src/hct_mis_api/apps/account/celery_tasks.py b/src/hct_mis_api/apps/account/celery_tasks.py index 823b93260f..6a7c030b6c 100644 --- a/src/hct_mis_api/apps/account/celery_tasks.py +++ b/src/hct_mis_api/apps/account/celery_tasks.py @@ -17,10 +17,10 @@ logger = logging.getLogger(__name__) -# @app.task(bind=True, default_retry_delay=60, max_retries=3) -# @log_start_and_end -# @sentry_tags -def invalidate_permissions_cache_for_user_if_expired_role() -> bool: +@app.task(bind=True, default_retry_delay=60, max_retries=3) +@log_start_and_end +@sentry_tags +def invalidate_permissions_cache_for_user_if_expired_role(self) -> bool: # Invalidate permissions cache for users with roles that expired a day before day_ago = timezone.now() - datetime.timedelta(days=1) users = User.objects.filter( diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py index d5280c16da..3866e76589 100644 --- a/tests/unit/apps/account/test_models.py +++ b/tests/unit/apps/account/test_models.py @@ -1,10 +1,10 @@ from django.core.exceptions import ValidationError -from django.db import IntegrityError from django.test import TransactionTestCase from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory, PartnerFactory from hct_mis_api.apps.account.models import RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan +from hct_mis_api.apps.geo.fixtures import AreaFactory from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -16,6 +16,10 @@ def setUp(self) -> None: name="Test Role", permissions=["PROGRAMME_CREATE", "PROGRAMME_FINISH"], ) + self.role2 = RoleFactory( + name="Test Role 2", + permissions=["PROGRAMME_UPDATE"], + ) self.user = UserFactory(first_name="Test", last_name="User") self.partner = PartnerFactory(name="Partner") self.program1 = ProgramFactory( @@ -36,15 +40,15 @@ def setUp(self) -> None: def test_unique_user_role_assignment(self) -> None: # Not possible to have the same role assigned to the same user in the same BA - with self.assertRaises(IntegrityError) as ie_context: + with self.assertRaises(ValidationError) as ve_context: RoleAssignment.objects.create( user=self.user, role=self.role, business_area=self.business_area, ) self.assertIn( - 'duplicate key value violates unique constraint "unique_user_role_assignment"', - str(ie_context.exception), + "This role is already assigned to the user in the business area.", + str(ve_context.exception), ) # Possible to have the same role assigned to the same partner in the same BA (not failing for two records with user=None) @@ -65,12 +69,26 @@ def test_unique_user_role_assignment(self) -> None: ) self.assertEqual(RoleAssignment.objects.filter(role=self.role, business_area=self.business_area, partner=self.partner).count(), 2) + def test_user_or_partner(self) -> None: + # Either user or partner must be set + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=None, + partner=None, + role=self.role2, + business_area=self.business_area, + ) + self.assertIn( + 'Either user or partner must be set, but not both.', + str(ve_context.exception), + ) + def test_user_or_partner_not_both(self) -> None: # Not possible to have both user and partner in the same role assignment with self.assertRaises(ValidationError) as ve_context: RoleAssignment.objects.create( user=self.user, - role=self.role, + role=self.role2, business_area=self.business_area, partner=self.partner, program=self.program1, @@ -79,3 +97,79 @@ def test_user_or_partner_not_both(self) -> None: 'Either user or partner must be set, but not both.', str(ve_context.exception), ) + + def test_program_and_areas_only_for_partner(self) -> None: + # Only partner can have program assigned + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=self.role2, + business_area=self.business_area, + program=self.program1, + ) + self.assertIn( + 'Program and areas can only be assigned for partner roles; not for user roles.', + str(ve_context.exception), + ) + + # Only partner can have areas assigned + area = AreaFactory(name="Test Area") + role_assignment = RoleAssignment.objects.create( + user=self.user, + partner=None, + role=self.role2, + business_area=self.business_area, + program=None, + ) + with self.assertRaises(ValidationError) as ve_context: + role_assignment.areas.add(area) + role_assignment.save() + self.assertIn( + 'Program and areas can only be assigned for partner roles; not for user roles.', + str(ve_context.exception), + ) + + # Partner can have program and areas assigned + role_assignment = RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=self.role2, + business_area=self.business_area, + program=self.program1, + ) + self.assertIsNotNone(role_assignment.id) + + def test_is_available_for_partner_flag(self) -> None: + # is_available_for_partner flag is set to True + role_assignment = RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=self.role2, + business_area=self.business_area, + ) + self.assertIsNotNone(role_assignment.id) + + # is_available_for_partner flag is set to False + self.role2.is_available_for_partner = False + self.role2.save() + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=self.role2, + business_area=self.business_area, + ) + self.assertIn( + 'Partner can only be assigned roles that are available for partners.', + str(ve_context.exception), + ) + + # user can be assigned the role despite the flag + role_assignment_user = RoleAssignment.objects.create( + user=self.user, + partner=None, + role=self.role2, + business_area=self.business_area, + ) + self.assertIsNotNone(role_assignment_user.id) diff --git a/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py new file mode 100644 index 0000000000..2e83fb996a --- /dev/null +++ b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py @@ -0,0 +1,251 @@ +from datetime import timedelta +from django.utils import timezone + +from django.contrib.auth.models import Permission, Group +from django.test import TestCase +from django.contrib.contenttypes.models import ContentType + +from django.core.cache import cache + +from hct_mis_api.apps.account.caches import get_user_permissions_version_key +from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, RoleAssignmentFactory, UserFactory +from hct_mis_api.apps.account.models import User +from hct_mis_api.apps.core.fixtures import create_afghanistan +from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.program.fixtures import ProgramFactory +from hct_mis_api.apps.program.models import Program + + +class TestSignalsForInvalidatePermissionCaches(TestCase): + def setUp(self) -> None: + super().setUp() + self.business_area_afg = create_afghanistan() + + self.partner1 = PartnerFactory(name="Partner") + self.partner2 = PartnerFactory(name="Partner 2") + + self.user1_partner1 = UserFactory(partner=self.partner1) + self.user2_partner1 = UserFactory(partner=self.partner1) + self.user1_partner2 = UserFactory(partner=self.partner2) + self.user2_partner2 = UserFactory(partner=self.partner2) + + self.role1 = RoleFactory(name="Role 1") + self.role2 = RoleFactory(name="Role 2") + + self.program = ProgramFactory.create( + status=Program.DRAFT, business_area=self.business_area_afg, partner_access=Program.ALL_PARTNERS_ACCESS + ) + + self.role_assignment1 = RoleAssignmentFactory( + user=self.user1_partner1, + partner=None, + role=self.role1, + business_area=self.business_area_afg, + ) + self.role_assignment2 = RoleAssignmentFactory( + user=self.user1_partner2, + partner=None, + role=self.role1, + business_area=self.business_area_afg, + ) + self.role_assignment3 = RoleAssignmentFactory( + user=None, + partner=self.partner1, + role=self.role2, + business_area=self.business_area_afg, + ) + + # group on a user + self.group1 = Group.objects.create(name="Test Group") + self.content_type = ContentType.objects.get_for_model(BusinessArea) + permission = Permission.objects.create(codename="test_permission", name="Test Permission", content_type=self.content_type) + self.group1.permissions.add(permission) + self.user1_partner1.groups.add(self.group1) + + # group on a user's role assignment + self.group2 = Group.objects.create(name="Test Group 2") + self.role_assignment2.group = self.group2 + self.role_assignment2.save() + + # group on a partner's role assignment + self.group3 = Group.objects.create(name="Test Group 3") + self.role_assignment3.group = self.group3 + self.role_assignment3.save() + + self.version_key_user1_partner1_before = self._get_cache_version(self.user1_partner1) + self.version_key_user2_partner1_before = self._get_cache_version(self.user2_partner1) + self.version_key_user1_partner2_before = self._get_cache_version(self.user1_partner2) + self.version_key_user2_partner2_before = self._get_cache_version(self.user2_partner2) + + def test_invalidate_cache_on_role_change_for_user(self) -> None: + self.role1.permissions = ["PROGRAMME_CREATE", "PROGRAMME_FINISH"] + self.role1.save() + + # users with role_assignments connected to the role should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_cache_on_role_change_for_partner(self) -> None: + self.role2.permissions = ["PROGRAMME_CREATE", "PROGRAMME_FINISH"] + self.role2.save() + + # users with partner's role_assignments connected to the role should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_cache_on_group_permissions_change_for_user(self) -> None: + permission = Permission.objects.create(codename="test_permission_new_1", name="Test Permission 2", content_type=self.content_type) + self.group1.permissions.add(permission) + + # users connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + # remove permission from the group + self.group1.permissions.remove(permission) + + # users connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 2) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_cache_on_group_permissions_change_for_user_role_assignment(self) -> None: + permission = Permission.objects.create(codename="test_permission_new_2", name="Test Permission 2", content_type=self.content_type) + self.group2.permissions.add(permission) + + # users with role_assignments connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + # remove permission from the group + self.group2.permissions.remove(permission) + + # users with role_assignments connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 2) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_cache_on_group_permissions_change_for_partner_role_assignment(self) -> None: + permission = Permission.objects.create(codename="test_permission_new_3", name="Test Permission 2", + content_type=self.content_type) + self.group3.permissions.add(permission) + + # users with partner with role_assignments connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + # remove permission from the group + self.group3.permissions.remove(permission) + + # users with partner with role_assignments connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 2) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before + 2) + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_permissions_cache_on_group_change_for_user(self) -> None: + self.group1.delete() + + # users connected with the group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_permissions_cache_on_group_change_for_user_role_assignment(self) -> None: + self.group2.delete() + + # users with role_assignments connected with the group should have their cache invalidated + # increased by 2 because of the signal on the RoleAssignment as well + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 2) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_permissions_cache_on_group_change_for_partner_role_assignment(self) -> None: + self.group3.delete() + + # users with partner with role_assignments connected with the group should have their cache invalidated + # increased by 2 because of the signal on the RoleAssignment as well + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 2) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before + 2) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_permissions_cache_on_role_assignment_change_for_user(self) -> None: + self.role_assignment1.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment1.save() + + self.role_assignment1.group = self.group3 + self.role_assignment1.save() + + self.role_assignment1.role = self.role2 + self.role_assignment1.save() + + # users connected to the role_assignment should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 3) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_permissions_cache_on_role_assignment_change_for_partner(self) -> None: + self.role_assignment3.expiry_date = (timezone.now() - timedelta(days=1)).date() + self.role_assignment3.save() + + self.role_assignment3.program = self.program + self.role_assignment3.save() + + self.role_assignment3.role = self.role1 + self.role_assignment3.save() + + # users with partner connected to the role_assignment should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 3) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before + 3) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + @staticmethod + def _get_cache_version(user: User) -> int: + version_key = get_user_permissions_version_key(user) + return cache.get(version_key) + + def tearDown(self) -> None: + super().tearDown() + cache.clear() From a1abe92c6a287846ddd1e3e94a7bf1e03911fd3f Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Fri, 13 Dec 2024 00:33:48 +0100 Subject: [PATCH 010/208] additional signals for invalidate chache, more tests, rewrite methods on account models, apply new logic for rest, some graphql requests --- src/hct_mis_api/apps/account/admin/filters.py | 4 +- src/hct_mis_api/apps/account/admin/forms.py | 1 + src/hct_mis_api/apps/account/admin/user.py | 12 +- .../apps/account/admin/user_role.py | 1 + src/hct_mis_api/apps/account/filters.py | 17 +- src/hct_mis_api/apps/account/models.py | 149 +++++++++--------- src/hct_mis_api/apps/account/permissions.py | 8 +- src/hct_mis_api/apps/account/schema.py | 16 +- src/hct_mis_api/apps/account/signals.py | 35 +++- src/hct_mis_api/apps/accountability/schema.py | 2 +- src/hct_mis_api/apps/core/backends.py | 12 ++ src/hct_mis_api/apps/core/validators.py | 2 +- src/hct_mis_api/apps/grievance/mutations.py | 11 +- src/hct_mis_api/apps/grievance/schema.py | 28 ++-- src/hct_mis_api/config/settings.py | 2 +- .../apps/account/test_partner_permissions.py | 55 ++++--- ...ignals_for_invalidate_permission_caches.py | 50 ++++-- 17 files changed, 245 insertions(+), 160 deletions(-) diff --git a/src/hct_mis_api/apps/account/admin/filters.py b/src/hct_mis_api/apps/account/admin/filters.py index f8e9f3cd5e..7790773045 100644 --- a/src/hct_mis_api/apps/account/admin/filters.py +++ b/src/hct_mis_api/apps/account/admin/filters.py @@ -35,10 +35,10 @@ class BusinessAreaFilter(SimpleListFilter): template = "adminfilters/combobox.html" def lookups(self, request: HttpRequest, model_admin: "ModelAdmin[Any]") -> List: - return BusinessArea.objects.filter(user_roles__isnull=False).values_list("id", "name").distinct() + return BusinessArea.objects.filter(role_assignments__user__isnull=False).values_list("id", "name").distinct() def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: - return queryset.filter(user_roles__business_area=self.value()).distinct() if self.value() else queryset + return queryset.filter(role_assignments__business_area=self.value()).distinct() if self.value() else queryset class PermissionFilter(SimpleListFilter): diff --git a/src/hct_mis_api/apps/account/admin/forms.py b/src/hct_mis_api/apps/account/admin/forms.py index 39644d84f2..bbdbbff6e2 100644 --- a/src/hct_mis_api/apps/account/admin/forms.py +++ b/src/hct_mis_api/apps/account/admin/forms.py @@ -59,6 +59,7 @@ def add_fields(self, form: "forms.Form", index: Optional[int]) -> None: form.fields["business_area"].choices = [ (str(x.id), str(x)) for x in BusinessArea.objects.filter(is_split=False) ] + form.fields["role"].required = True def clean(self) -> None: super().clean() diff --git a/src/hct_mis_api/apps/account/admin/user.py b/src/hct_mis_api/apps/account/admin/user.py index 865eb95084..ad33de0945 100644 --- a/src/hct_mis_api/apps/account/admin/user.py +++ b/src/hct_mis_api/apps/account/admin/user.py @@ -176,10 +176,10 @@ def privileges(self, request: HttpRequest, pk: "UUID") -> TemplateResponse: context["permissions"] = [p.split(".") for p in sorted(all_perms)] ba_perms = defaultdict(list) ba_roles = defaultdict(list) - for role in user.user_roles.all(): + for role in user.role_assignments.all(): ba_roles[role.business_area.slug].append(role.role) - for role in user.user_roles.values_list("business_area__slug", flat=True).distinct("business_area"): + for role in user.role_assignments.values_list("business_area__slug", flat=True).distinct("business_area"): ba_perms[role].extend(user.permissions_in_business_area(role)) context["business_ares_permissions"] = dict(ba_perms) @@ -212,14 +212,14 @@ def add_business_area_role(self, request: HttpRequest, queryset: QuerySet) -> Ht if crud == "ADD": try: account_models.IncompatibleRoles.objects.validate_user_role(u, ba, role) - ur, is_new = u.user_roles.get_or_create(business_area=ba, role=role) + ur, is_new = u.role_assignments.get_or_create(business_area=ba, role=role) if is_new: added += 1 self.log_addition(request, ur, "Role added") except ValidationError as e: self.message_user(request, str(e), messages.ERROR) elif crud == "REMOVE": - to_delete = u.user_roles.filter(business_area=ba, role=role).first() + to_delete = u.role_assignments.filter(business_area=ba, role=role).first() if to_delete: removed += 1 self.log_deletion(request, to_delete, str(to_delete)) @@ -298,7 +298,7 @@ def import_csv(self, request: HttpRequest) -> TemplateResponse: defaults={"username": username}, ) if isnew: - ur = u.user_roles.create(business_area=business_area, role=role) + ur = u.role_assignments.create(business_area=business_area, role=role) self.log_addition(request, u, "User imported by CSV") self.log_addition(request, ur, "User Role added") else: # check role validity @@ -306,7 +306,7 @@ def import_csv(self, request: HttpRequest) -> TemplateResponse: account_models.IncompatibleRoles.objects.validate_user_role( u, business_area, role ) - u.user_roles.get_or_create(business_area=business_area, role=role) + u.role_assignments.get_or_create(business_area=business_area, role=role) self.log_addition(request, ur, "User Role added") except ValidationError as e: self.message_user( diff --git a/src/hct_mis_api/apps/account/admin/user_role.py b/src/hct_mis_api/apps/account/admin/user_role.py index 686fd9f3b3..d4f409c6cf 100644 --- a/src/hct_mis_api/apps/account/admin/user_role.py +++ b/src/hct_mis_api/apps/account/admin/user_role.py @@ -22,6 +22,7 @@ class RoleAssignmentInline(admin.TabularInline): model = account_models.RoleAssignment + fields = ["business_area", "role", "expiry_date"] extra = 0 formset = RoleAssignmentInlineFormSet diff --git a/src/hct_mis_api/apps/account/filters.py b/src/hct_mis_api/apps/account/filters.py index 858c990e5e..c9df553680 100644 --- a/src/hct_mis_api/apps/account/filters.py +++ b/src/hct_mis_api/apps/account/filters.py @@ -8,6 +8,7 @@ from hct_mis_api.apps.account.models import USER_STATUS_CHOICES, Partner, Role from hct_mis_api.apps.core.utils import CustomOrderingFilter, decode_id_string +from hct_mis_api.apps.program.models import Program if TYPE_CHECKING: from uuid import UUID @@ -66,11 +67,15 @@ def search_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User return qs.filter(q_obj) def business_area_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": - return qs.filter(Q(user_roles__business_area__slug=value) | Q(partner__business_areas__slug=value)) + return qs.filter(Q(role_assignments__business_area__slug=value) | Q(partner__role_assignments__business_area__slug=value)) def program_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": program_id = decode_id_string(value) - return qs.filter(partner__programs__id=program_id) + business_area = Program.objects.get(id=program_id).business_area + return qs.filter( + Q(partner__role_assignments__program__id=program_id) + | Q(partner__role_assignments__program=None, partner__role_assignments__business_area=business_area) + ) def partners_filter(self, qs: "QuerySet", name: str, values: List["UUID"]) -> "QuerySet[User]": q_obj = Q() @@ -83,10 +88,10 @@ def roles_filter(self, qs: "QuerySet", name: str, values: List) -> "QuerySet[Use q_obj = Q() for value in values: q_obj |= Q( - user_roles__role__id=value, - user_roles__business_area__slug=business_area_slug, + role_assignments__role__id=value, + role_assignments__business_area__slug=business_area_slug, ) | Q( - partner__business_area_partner_through__roles__id=value, - partner__business_areas__slug=business_area_slug, + partner__role_assignments__roles__id=value, + partner__role_assignments__business_areas__slug=business_area_slug, ) return qs.filter(q_obj) diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index f0a560d23e..8b6aac8c9a 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -6,7 +6,7 @@ from django import forms from django.conf import settings from django.contrib.admin.widgets import FilteredSelectMultiple -from django.contrib.auth.models import AbstractUser, Group +from django.contrib.auth.models import AbstractUser, Group, Permission from django.contrib.postgres.fields import ArrayField, CICharField from django.core.exceptions import ValidationError from django.core.validators import ( @@ -29,7 +29,7 @@ ) from hct_mis_api.apps.account.utils import test_conditional from hct_mis_api.apps.core.mixins import LimitBusinessAreaModelMixin -from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.utils.mailjet import MailjetClient from hct_mis_api.apps.utils.models import TimeStampedUUIDModel @@ -79,6 +79,10 @@ def __str__(self) -> str: def is_child(self) -> bool: return self.parent is None + @property + def is_unicef_subpartner(self) -> bool: + return self.parent and self.parent.is_unicef + @property def is_parent(self) -> bool: return self.id in Partner.objects.exclude(parent__isnull=True).values_list("parent", flat=True) @@ -89,19 +93,15 @@ def get_partners_as_choices(cls) -> List: @classmethod def get_partners_for_program_as_choices(cls, business_area_id: str, program_id: Optional[str] = None) -> List: - partners = cls.objects.exclude(name=settings.DEFAULT_EMPTY_PARTNER) + role_assignments = RoleAssignment.objects.filter(business_area_id=business_area_id) if program_id: - return [ - (partner.id, partner.name) - for partner in partners - if program_id in partner.get_program_ids_for_business_area(business_area_id) - ] - else: - return [ - (partner.id, partner.name) - for partner in partners - if partner.get_program_ids_for_business_area(business_area_id) - ] + role_assignments.filter(Q(program_id=program_id) | Q(program=None)) + partners = cls.objects.filter(role_assignments__in=role_assignments).distinct() + + return [ + (partner.id, partner.name) + for partner in partners + ] @property def is_unicef(self) -> bool: @@ -116,27 +116,32 @@ def is_editable(self) -> bool: return not self.is_unicef and not self.is_default def has_full_area_access_in_program(self, program_id: Union[str, UUID]) -> bool: - return self.is_unicef or ( - self.program_partner_through.filter(program_id=program_id).first() - and self.program_partner_through.filter(program_id=program_id).first().full_area_access - ) + return RoleAssignment.objects.filter(partner=self, program_id=program_id, full_area_access=True).exists() def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: + from hct_mis_api.apps.program.models import Program + if self.role_assignments.filter(business_area_id=business_area_id, program=None).exists(): + programs_ids = Program.objects.filter(business_area_id=business_area_id).values_list("id", flat=True) + else: + programs_ids = self.role_assignments.filter(business_area_id=business_area_id).values_list("program_id", flat=True) return [ str(program_id) - for program_id in self.programs.filter(business_area_id=business_area_id).values_list("id", flat=True) + for program_id in programs_ids ] def has_program_access(self, program_id: Union[str, UUID]) -> bool: - return self.is_unicef or self.programs.filter(id=program_id).exists() + from hct_mis_api.apps.program.models import Program + return RoleAssignment.objects.filter( + Q(partner=self) + & Q(business_area=Program.objects.get(id=program_id).business_area) + & (Q(program=None) | Q(program_id=program_id)) + ).exclude(expiry_date__lt=timezone.now()).exists() def has_area_access(self, area_id: Union[str, UUID], program_id: Union[str, UUID]) -> bool: - return self.is_unicef or self.get_program_areas(program_id).filter(id=area_id).exists() + return self.get_program_areas(program_id).filter(id=area_id).exists() def get_program_areas(self, program_id: Union[str, UUID]) -> QuerySet[Area]: - return Area.objects.filter( - program_partner_through__partner=self, program_partner_through__program_id=program_id - ) + return Area.objects.filter(role_assignments__partner=self, role_assignments__program_id=program_id) def get_roles_for_business_area( self, business_area_slug: Optional[str] = None, business_area_id: Optional["UUID"] = None @@ -148,16 +153,19 @@ def get_roles_for_business_area( business_area_id = BusinessArea.objects.get(slug=business_area_slug).id return Role.objects.filter( - business_area_partner_through__partner=self, - business_area_partner_through__business_area_id=business_area_id, + role_assignments__partner=self, + role_assignments__business_area_id=business_area_id, ) - def add_roles_in_business_area(self, business_area_id: str, roles: List["Role"]) -> None: - business_area_partner_through, _ = BusinessAreaPartnerThrough.objects.get_or_create( - partner=self, - business_area_id=business_area_id, - ) - business_area_partner_through.roles.add(*roles) + # TODO: permissions: possibly remove + def add_roles_in_business_area(self, business_area_id: str, roles: List["Role"], program_id: Optional[str] = None) -> None: + for role in roles: + RoleAssignment.objects.get_or_create( + partner=self, + business_area_id=business_area_id, + program_id=program_id, + role=role + ) class User(AbstractUser, NaturalKeyModel, UUIDModel): @@ -190,57 +198,52 @@ def __str__(self) -> str: return self.email or self.username def save(self, *args: Any, **kwargs: Any) -> None: + print("Saving user:") if not self.partner: self.partner, _ = Partner.objects.get_or_create(name=settings.DEFAULT_EMPTY_PARTNER) if not self.partner.pk: self.partner.save() super().save(*args, **kwargs) - def permissions_in_business_area(self, business_area_slug: str, program_id: Optional[UUID] = None) -> List: + def permissions_in_business_area(self, business_area_slug: str, program_id: Optional[UUID] = None) -> set: """ - return list of permissions based on User Role BA and User Partner - if program_id is in arguments need to check if partner has access to this program + return list of permissions for the given business area and program, + retrieved from RoleAssignments of the user and their partner """ - user_roles_query = RoleAssignment.objects.filter(user=self, business_area__slug=business_area_slug).exclude( - expiry_date__lt=timezone.now() - ) - all_user_roles_permissions_list = list( - Role.objects.filter(user_roles__in=user_roles_query).values_list("permissions", flat=True) - ) - - # Regular user, need to check access to the program - if not self.partner.is_unicef: - # Check program access - if program_id and not self.partner.has_program_access(program_id): - return [] - - # Prepare partner permissions - partner_roles_in_ba = self.partner.get_roles_for_business_area(business_area_slug=business_area_slug) - all_partner_roles_permissions_list = [ - perm for perm in partner_roles_in_ba.values_list("permissions", flat=True) if perm - ] - elif all_user_roles_permissions_list: - # Default partner permissions for UNICEF partner with access to business area - all_partner_roles_permissions_list = [DEFAULT_PERMISSIONS_LIST_FOR_IS_UNICEF_PARTNER] + if program_id: + if not self.partner.has_program_access(program_id): + return set() + role_assignments = RoleAssignment.objects.filter( + Q(partner__user=self, business_area__slug=business_area_slug, program_id=program_id) | + Q(partner__user=self, business_area__slug=business_area_slug, program=None) | + Q(user=self, business_area__slug=business_area_slug) + ).exclude(expiry_date__lt=timezone.now()) else: - all_partner_roles_permissions_list = [] - return list( - set( - [perm for perms in all_partner_roles_permissions_list for perm in perms] - + [perm for perms in all_user_roles_permissions_list if perms for perm in perms] - ) - ) + role_assignments = RoleAssignment.objects.filter( + Q(partner__user=self, business_area__slug=business_area_slug) | + Q(user=self, business_area__slug=business_area_slug) + ).exclude(expiry_date__lt=timezone.now()) + + permissions_set = set() + # permissions from group field in RoleAssignment + role_assignment_group_permissions = Permission.objects.filter( + group__role_assignments__in=role_assignments + ).values_list("content_type__app_label", "codename") + permissions_set.update(f"{app}.{codename}" for app, codename in role_assignment_group_permissions) + + # permissions from role field in RoleAssignment + role_assignment_role_permissions = Role.objects.filter( + role_assignments__in=role_assignments + ).values_list("permissions", flat=True) + permissions_set.update(permission for permission_list in role_assignment_role_permissions for permission in permission_list) + + return permissions_set @property def business_areas(self) -> QuerySet[BusinessArea]: return BusinessArea.objects.filter( - Q(Q(user_roles__user=self) & ~Q(user_roles__expiry_date__lt=timezone.now())) | Q(partners=self.partner) - ).distinct() - - def has_permission( - self, permission: str, business_area: BusinessArea, program_id: Optional[UUID] = None, write: bool = False - ) -> bool: - return permission in self.permissions_in_business_area(business_area.slug, program_id) + Q(role_assignments__user=self) | Q(role_assignments__partner__user=self) + ).exclude(role_assignments__expiry_date__lt=timezone.now()).distinct() @test_conditional(lru_cache()) def cached_role_assignments(self) -> QuerySet["RoleAssignment"]: @@ -248,19 +251,19 @@ def cached_role_assignments(self) -> QuerySet["RoleAssignment"]: def can_download_storage_files(self) -> bool: return any( - self.has_permission(Permissions.DOWNLOAD_STORAGE_FILE.name, role.business_area) + self.has_perm(Permissions.DOWNLOAD_STORAGE_FILE.name, role.business_area) for role in self.cached_role_assignments() ) def can_change_fsp(self) -> bool: return any( - self.has_permission(Permissions.PM_ADMIN_FINANCIAL_SERVICE_PROVIDER_UPDATE.name, role.business_area) + self.has_perm(Permissions.PM_ADMIN_FINANCIAL_SERVICE_PROVIDER_UPDATE.name, role.business_area) for role in self.cached_role_assignments() ) def can_add_business_area_to_partner(self) -> bool: return any( - self.has_permission(Permissions.CAN_ADD_BUSINESS_AREA_TO_PARTNER.name, role.business_area) + self.has_perm(Permissions.CAN_ADD_BUSINESS_AREA_TO_PARTNER.name, role.business_area) for role in self.cached_role_assignments() ) diff --git a/src/hct_mis_api/apps/account/permissions.py b/src/hct_mis_api/apps/account/permissions.py index 3a5b197a25..a94175c227 100644 --- a/src/hct_mis_api/apps/account/permissions.py +++ b/src/hct_mis_api/apps/account/permissions.py @@ -315,6 +315,8 @@ def has_permission(cls, info: Any, **kwargs: Any) -> bool: def check_permissions(user: Any, permissions: Iterable[Permissions], **kwargs: Any) -> bool: + from hct_mis_api.apps.program.models import Program + if not user.is_authenticated: return False @@ -328,8 +330,10 @@ def check_permissions(user: Any, permissions: Iterable[Permissions], **kwargs: A ) if business_area is None: return False - program_id = get_program_id_from_headers(kwargs) - return any(user.has_permission(permission.name, business_area, program_id) for permission in permissions) + + program = Program.objects.filter(id=get_program_id_from_headers(kwargs)).first() + obj = program or business_area + return any(user.has_perm(permission.name, obj) for permission in permissions) def hopePermissionClass(permission: Permissions) -> Type[BasePermission]: diff --git a/src/hct_mis_api/apps/account/schema.py b/src/hct_mis_api/apps/account/schema.py index 67ae583e0c..1798e36264 100644 --- a/src/hct_mis_api/apps/account/schema.py +++ b/src/hct_mis_api/apps/account/schema.py @@ -68,7 +68,13 @@ class Meta: class PartnerRoleNode(DjangoObjectType): class Meta: - model = BusinessAreaPartnerThrough + model = RoleAssignment + exclude = ("id",) + + +class UserRoleNode(DjangoObjectType): + class Meta: + model = RoleAssignment exclude = ("id",) @@ -103,12 +109,16 @@ class Meta: class UserNode(DjangoObjectType): business_areas = DjangoFilterConnectionField(UserBusinessAreaNode) partner_roles = graphene.List(PartnerRoleNode) + user_roles = graphene.List(UserRoleNode) def resolve_business_areas(self, info: Any) -> "QuerySet[BusinessArea]": return info.context.user.business_areas def resolve_partner_roles(self, info: Any) -> "QuerySet[Role]": - return self.partner.business_area_partner_through.all() + return self.partner.role_assignments.all() + + def resolve_user_roles(self, info: Any) -> "QuerySet[Role]": + return self.role_assignments.all() class Meta: model = get_user_model() @@ -233,7 +243,7 @@ def resolve_partner_for_grievance_choices( def resolve_has_available_users_to_export(self, info: Any, business_area_slug: str) -> bool: return ( get_user_model() - .objects.prefetch_related("user_roles") + .objects.prefetch_related("role_assignments") .filter(available_for_export=True, is_superuser=False, user_roles__business_area__slug=business_area_slug) .exists() ) diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 67e263cee9..6e573724dc 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -16,14 +16,8 @@ @receiver(post_save, sender=RoleAssignment) -def post_save_pre_delete_role_assignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: - if instance.user: - instance.user.last_modify_date = timezone.now() - instance.user.save() - - @receiver(pre_delete, sender=RoleAssignment) -def pre_delete_role_assignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: +def post_save_pre_delete_role_assignment(sender: Any, instance: User, *args: Any, **kwargs: Any) -> None: if instance.user: instance.user.last_modify_date = timezone.now() instance.user.save() @@ -124,3 +118,30 @@ def invalidate_permissions_cache_on_group_change(sender, instance, **kwargs): Q(groups=instance) | Q(role_assignments__group=instance) | Q(partner__role_assignments__group=instance) ).distinct() _invalidate_user_permissions_cache(users) + + +@receiver(m2m_changed, sender=User.groups.through) +def invalidate_permissions_cache_on_user_groups_change(action, instance, pk_set, **kwargs): + """ + Invalidate the cache for a User when their Groups are modified. + """ + if action in {"post_add", "post_remove", "post_clear"}: + _invalidate_user_permissions_cache([instance]) + + +@receiver(post_save, sender=User) +@receiver(pre_delete, sender=User) +def invalidate_permissions_cache_on_user_change(sender, instance, **kwargs): + """ + Invalidate the cache for a User when they are updated. (For example change of partner or is_superuser flag) + """ + _invalidate_user_permissions_cache([instance]) + + +@receiver(post_save, sender=Partner) +@receiver(pre_delete, sender=Partner) +def invalidate_permissions_cache_on_partner_change(sender, instance, **kwargs): + """ + Invalidate the cache for a User when they are updated. (For example change of partner or is_superuser flag) + """ + _invalidate_user_permissions_cache([instance]) diff --git a/src/hct_mis_api/apps/accountability/schema.py b/src/hct_mis_api/apps/accountability/schema.py index 387c779769..24cc58c21f 100644 --- a/src/hct_mis_api/apps/accountability/schema.py +++ b/src/hct_mis_api/apps/accountability/schema.py @@ -120,7 +120,7 @@ def resolve_all_feedbacks(self, info: Any, **kwargs: Any) -> QuerySet[Feedback]: business_area_id = BusinessArea.objects.get(slug=business_area_slug).id queryset = Feedback.objects.filter(business_area__slug=business_area_slug).select_related("admin2") - if not user.partner.is_unicef: # Full access to all AdminAreas if is_unicef + if not user.partner.has_full_area_access_in_program: queryset = filter_feedback_based_on_partner_areas_2(queryset, user.partner, business_area_id, program_id) return queryset diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index 07d9927824..826d79273b 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -16,8 +16,12 @@ from hct_mis_api.apps.account.models import User +# TODO: perms: add area check class PermissionsBackend(BaseBackend): def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: + print(user) + print(obj) + if not obj: program = None business_area = None @@ -50,6 +54,9 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str cached_permissions = cache.get(cache_key) + print(cached_permissions) + print(user_version) + if cached_permissions: return cached_permissions @@ -99,9 +106,14 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str cache.set(cache_key, permissions_set, timeout=None) + print("sd") + print(permissions_set) + return permissions_set def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Model] = None) -> bool: + print("sd original") + print(perm) if user_obj.is_superuser: return True if isinstance(user_obj, AnonymousUser): diff --git a/src/hct_mis_api/apps/core/validators.py b/src/hct_mis_api/apps/core/validators.py index a2e4a6bad3..557611e136 100644 --- a/src/hct_mis_api/apps/core/validators.py +++ b/src/hct_mis_api/apps/core/validators.py @@ -367,7 +367,7 @@ def validate_partners_data(cls, *args: Any, **kwargs: Any) -> Optional[None]: if ( partner_access == Program.SELECTED_PARTNERS_ACCESS - and not partner.is_unicef + and not partner.is_unicef_subpartner and partner.id not in partners_ids ): raise ValidationError("Please assign access to your partner before saving the programme.") diff --git a/src/hct_mis_api/apps/grievance/mutations.py b/src/hct_mis_api/apps/grievance/mutations.py index 89106c009c..8086b36860 100644 --- a/src/hct_mis_api/apps/grievance/mutations.py +++ b/src/hct_mis_api/apps/grievance/mutations.py @@ -582,12 +582,11 @@ def mutate( if isinstance(grievance_ticket.ticket_details, TicketNeedsAdjudicationDetails): partner = user.partner - if not partner.is_unicef: - for selected_individual in grievance_ticket.ticket_details.selected_individuals.all(): - if not partner.has_area_access( - area_id=selected_individual.household.admin2.id, program_id=selected_individual.program.id - ): - raise PermissionDenied("Permission Denied: User does not have access to close ticket") + for selected_individual in grievance_ticket.ticket_details.selected_individuals.all(): + if not partner.has_area_access( + area_id=selected_individual.household.admin2.id, program_id=selected_individual.program.id + ): + raise PermissionDenied("Permission Denied: User does not have access to close ticket") if not grievance_ticket.can_change_status(status): log_and_raise("New status is incorrect") diff --git a/src/hct_mis_api/apps/grievance/schema.py b/src/hct_mis_api/apps/grievance/schema.py index 55cff156d4..b2b2d726fe 100644 --- a/src/hct_mis_api/apps/grievance/schema.py +++ b/src/hct_mis_api/apps/grievance/schema.py @@ -144,18 +144,17 @@ def check_node_permission(cls, info: Any, object_instance: GrievanceTicket) -> N owner_perm, business_area, program_id ) partner = user.partner - has_partner_area_access = partner.is_unicef ticket_program_id = str(object_instance.programs.first().id) if object_instance.programs.first() else None - if not partner.is_unicef: - if not object_instance.admin2 or not ticket_program_id: - # admin2 is empty or non-program ticket -> no restrictions for admin area - has_partner_area_access = True - else: - has_partner_area_access = partner.has_area_access( - area_id=object_instance.admin2.id, program_id=ticket_program_id - ) + # TODO: perms: move area check to has_perm + if not object_instance.admin2 or not ticket_program_id: + # admin2 is empty or non-program ticket -> no restrictions for admin area + has_partner_area_access = True + else: + has_partner_area_access = partner.has_area_access( + area_id=object_instance.admin2.id, program_id=ticket_program_id + ) if ( - user.has_permission(perm, business_area, ticket_program_id) or check_creator or check_assignee + user.has_perm(perm, ticket_program_id or business_area) or check_creator or check_assignee ) and has_partner_area_access: return None @@ -576,13 +575,8 @@ def resolve_all_grievance_ticket(self, info: Any, **kwargs: Any) -> QuerySet: queryset = queryset.prefetch_related(*to_prefetch) - # Full access to all AdminAreas if is_unicef - # and ignore filtering for Cross Area tickets - if not user.partner.is_unicef and not ( - kwargs.get("is_cross_area", False) - and program_id - and user.partner.has_full_area_access_in_program(program_id) - ): + # Ignore filtering for Cross Area tickets + if not (kwargs.get("is_cross_area", False) and program_id and user.partner.has_full_area_access_in_program(program_id)): queryset = filter_grievance_tickets_based_on_partner_areas_2( queryset, user.partner, business_area_id, program_id ) diff --git a/src/hct_mis_api/config/settings.py b/src/hct_mis_api/config/settings.py index 2a560e9da3..6f5921483a 100644 --- a/src/hct_mis_api/config/settings.py +++ b/src/hct_mis_api/config/settings.py @@ -289,8 +289,8 @@ ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 7 AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", "hct_mis_api.apps.core.backends.PermissionsBackend", + "django.contrib.auth.backends.ModelBackend", "social_core.backends.azuread_tenant.AzureADTenantOAuth2", ] diff --git a/tests/unit/apps/account/test_partner_permissions.py b/tests/unit/apps/account/test_partner_permissions.py index baf2761d66..910b6fa59d 100644 --- a/tests/unit/apps/account/test_partner_permissions.py +++ b/tests/unit/apps/account/test_partner_permissions.py @@ -22,20 +22,25 @@ def setUpTestData(cls) -> None: cls.business_area = BusinessArea.objects.get(slug="afghanistan") cls.program = ProgramFactory.create(status=Program.DRAFT, business_area=cls.business_area) cls.other_partner = PartnerFactory(name="Partner") - ba_partner_through = BusinessAreaPartnerThrough.objects.create( - business_area=cls.business_area, partner=cls.other_partner - ) - ba_partner_through.roles.set([cls.role_2]) - program_partner_through = ProgramPartnerThrough.objects.create(program=cls.program, partner=cls.other_partner) - program_partner_through.areas.set([cls.area_1]) cls.other_user = UserFactory(partner=cls.other_partner) + role_assignment_partner_other = RoleAssignment.objects.create( + business_area=cls.business_area, + program=cls.program, + partner=cls.other_partner, + role=cls.role_2, + ) + role_assignment_partner_other.areas.set([cls.area_1]) cls.unicef_partner = PartnerFactory(name="UNICEF") - program_unicef_through, _ = ProgramPartnerThrough.objects.get_or_create( - program=cls.program, partner=cls.unicef_partner - ) - program_unicef_through.areas.set([cls.area_1, cls.area_2]) cls.unicef_user = UserFactory(partner=cls.unicef_partner) + role_assignment_partner_unicef = RoleAssignment.objects.create( + business_area=cls.business_area, + program=cls.program, + partner=cls.unicef_partner, + role=cls.role_1, + ) + role_assignment_partner_unicef.areas.set([cls.area_1, cls.area_2]) + RoleAssignment.objects.create( business_area=cls.business_area, @@ -120,7 +125,7 @@ def test_partner_permissions_in_business_area(self) -> None: # empty list because wrong program id empty_list = User.permissions_in_business_area( - self.other_user, business_area_slug=self.business_area.slug, program_id=self.business_area.pk + self.other_user, business_area_slug=self.business_area.slug, program_id=self.program.pk ) self.assertEqual(empty_list, list()) @@ -138,43 +143,43 @@ def test_partner_permissions_in_business_area(self) -> None: def test_partner_has_permission(self) -> None: # check user_roles - user_has_one_role = User.has_permission(self.unicef_user, "PROGRAMME_CREATE", self.business_area) + user_has_one_role = User.has_perm(self.unicef_user, "PROGRAMME_CREATE", self.business_area) self.assertTrue(user_has_one_role) # check user_roles - user_without_access = User.has_permission(self.unicef_user, "Role_Not_Added", self.business_area) + user_without_access = User.has_perm(self.unicef_user, "Role_Not_Added", self.business_area) self.assertFalse(user_without_access) # check partner_roles - user_with_partner_role = User.has_permission(self.other_user, "PROGRAMME_FINISH", self.business_area) + user_with_partner_role = User.has_perm(self.other_user, "PROGRAMME_FINISH", self.business_area) self.assertTrue(user_with_partner_role) # check user_roles and partner_roles - user_with_partner_role = User.has_permission(self.other_user, "PROGRAMME_CREATE", self.business_area) + user_with_partner_role = User.has_perm(self.other_user, "PROGRAMME_CREATE", self.business_area) self.assertTrue(user_with_partner_role) # check user_roles and partner_roles with program_id - user_with_partner_role_and_program_access = User.has_permission( - self.other_user, "PROGRAMME_CREATE", self.business_area, self.program.pk + user_with_partner_role_and_program_access = User.has_perm( + self.other_user, "PROGRAMME_CREATE", self.program ) self.assertTrue(user_with_partner_role_and_program_access) - user_with_partner_role_and_program_access = User.has_permission( - self.other_user, "PROGRAMME_FINISH", self.business_area, self.program.pk + user_with_partner_role_and_program_access = User.has_perm( + self.other_user, "PROGRAMME_FINISH", self.program ) self.assertTrue(user_with_partner_role_and_program_access) # check perms wrong program_id - user_without_access = User.has_permission( - self.other_user, "PROGRAMME_FINISH", self.business_area, self.business_area.pk + user_without_access = User.has_perm( + self.other_user, "PROGRAMME_FINISH", self.business_area ) self.assertFalse(user_without_access) # check with program_id user partner is_unicef - unicef_user_without_perms = User.has_permission( - self.unicef_user, "PROGRAMME_FINISH", self.business_area, self.program.pk + unicef_user_without_perms = User.has_perm( + self.unicef_user, "PROGRAMME_FINISH", self.program ) self.assertFalse(unicef_user_without_perms) - unicef_user_with_perms = User.has_permission( - self.unicef_user, "PROGRAMME_CREATE", self.business_area, self.program.pk + unicef_user_with_perms = User.has_perm( + self.unicef_user, "PROGRAMME_CREATE", self.program ) self.assertTrue(unicef_user_with_perms) diff --git a/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py index 2e83fb996a..6c48f4e836 100644 --- a/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py +++ b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py @@ -9,7 +9,7 @@ from hct_mis_api.apps.account.caches import get_user_permissions_version_key from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, RoleAssignmentFactory, UserFactory -from hct_mis_api.apps.account.models import User +from hct_mis_api.apps.account.models import User, RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.fixtures import ProgramFactory @@ -77,6 +77,21 @@ def setUp(self) -> None: self.version_key_user1_partner2_before = self._get_cache_version(self.user1_partner2) self.version_key_user2_partner2_before = self._get_cache_version(self.user2_partner2) + def test_invalidate_cache_on_user_change(self) -> None: + self.user1_partner1.is_superuser = True + self.user1_partner1.save() + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + + self.user1_partner1.partner = self.partner2 + self.user1_partner1.save() + + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 2) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + def test_invalidate_cache_on_role_change_for_user(self) -> None: self.role1.permissions = ["PROGRAMME_CREATE", "PROGRAMME_FINISH"] self.role1.save() @@ -170,7 +185,18 @@ def test_invalidate_cache_on_group_permissions_change_for_partner_role_assignmen self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_permissions_cache_on_group_change_for_user(self) -> None: + def test_invalidate_cache_on_group_change_for_user(self) -> None: + self.user1_partner1.groups.remove(self.group1) + + # user with changed group should have their cache invalidated + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 1) + + # no invalidation for the rest of the users + self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) + self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) + + def test_invalidate_cache_on_group_delete_for_user(self): self.group1.delete() # users connected with the group should have their cache invalidated @@ -181,19 +207,20 @@ def test_invalidate_permissions_cache_on_group_change_for_user(self) -> None: self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_permissions_cache_on_group_change_for_user_role_assignment(self) -> None: + def test_invalidate_cache_on_group_delete_for_user_role_assignment(self) -> None: self.group2.delete() - # users with role_assignments connected with the group should have their cache invalidated - # increased by 2 because of the signal on the RoleAssignment as well - self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 2) + # increased by additional 2 signals: + # * signal on the RoleAssignment + # * signal on User update triggered because of cascade delete of the RoleAssignment + self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before + 3) # no invalidation for the rest of the users self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before) self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_permissions_cache_on_group_change_for_partner_role_assignment(self) -> None: + def test_invalidate_cache_on_group_delete_for_partner_role_assignment(self) -> None: self.group3.delete() # users with partner with role_assignments connected with the group should have their cache invalidated @@ -205,25 +232,28 @@ def test_invalidate_permissions_cache_on_group_change_for_partner_role_assignmen self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_permissions_cache_on_role_assignment_change_for_user(self) -> None: + def test_invalidate_cache_on_role_assignment_change_for_user(self) -> None: self.role_assignment1.expiry_date = (timezone.now() - timedelta(days=1)).date() self.role_assignment1.save() self.role_assignment1.group = self.group3 self.role_assignment1.save() + self.role_assignment1.role = self.role2 self.role_assignment1.save() + # users connected to the role_assignment should have their cache invalidated - self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 3) + # +6: 3 signals on RoleAssignment and 3 signals on User to update modify_date because of role_assignment change + self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 6) # no invalidation for the rest of the users self.assertEqual(self._get_cache_version(self.user2_partner1), self.version_key_user2_partner1_before) self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_permissions_cache_on_role_assignment_change_for_partner(self) -> None: + def test_invalidate_cache_on_role_assignment_change_for_partner(self) -> None: self.role_assignment3.expiry_date = (timezone.now() - timedelta(days=1)).date() self.role_assignment3.save() From 9778fd7ff337bf2a4a771b22b1ab94407cde66a9 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Sun, 12 Jan 2025 15:07:48 +0100 Subject: [PATCH 011/208] user also has program in their RoleAssignment, modify area acess check with new model, modify tests --- src/hct_mis_api/apps/account/fixtures.py | 20 +++- .../apps/account/migrations/0004_migration.py | 60 +++++++++++ src/hct_mis_api/apps/account/models.py | 100 ++++++++---------- src/hct_mis_api/apps/account/signals.py | 9 -- src/hct_mis_api/apps/accountability/schema.py | 2 +- src/hct_mis_api/apps/core/backends.py | 6 +- src/hct_mis_api/apps/grievance/filters.py | 2 +- src/hct_mis_api/apps/grievance/schema.py | 9 +- src/hct_mis_api/apps/grievance/utils.py | 26 ++--- src/hct_mis_api/apps/household/schema.py | 73 +++++++------ tests/unit/apps/account/test_models.py | 74 ------------- .../apps/account/test_partner_permissions.py | 37 +++---- tests/unit/apps/core/test_backends.py | 50 ++++++++- 13 files changed, 244 insertions(+), 224 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0004_migration.py diff --git a/src/hct_mis_api/apps/account/fixtures.py b/src/hct_mis_api/apps/account/fixtures.py index b8766ce56c..71c0a05a96 100644 --- a/src/hct_mis_api/apps/account/fixtures.py +++ b/src/hct_mis_api/apps/account/fixtures.py @@ -7,8 +7,9 @@ import factory from factory.django import DjangoModelFactory -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment, AdminAreaLimitedTo from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.program.fixtures import ProgramFactory class PartnerFactory(DjangoModelFactory): @@ -81,3 +82,20 @@ def partner(self): def user(self): # Only create user if partner is not provided return None if self.partner else UserFactory() + + +class AdminAreaLimitedToFactory(DjangoModelFactory): + partner = factory.SubFactory(PartnerFactory) + program = factory.SubFactory(ProgramFactory) + + class Meta: + model = AdminAreaLimitedTo + + @factory.post_generation + def areas(self, create, extracted, **kwargs): + if not create: + return + + if extracted: + for area in extracted: + self.areas.add(area) diff --git a/src/hct_mis_api/apps/account/migrations/0004_migration.py b/src/hct_mis_api/apps/account/migrations/0004_migration.py new file mode 100644 index 0000000000..5c737ae06d --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0004_migration.py @@ -0,0 +1,60 @@ +# Generated by Django 3.2.25 on 2025-01-09 19:26 + +from django.db import migrations, models +import django.db.models.deletion +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('geo', '0001_migration'), + ('program', '0001_migration'), + ('account', '0003_migration'), + ] + + operations = [ + migrations.CreateModel( + name='AdminAreaLimitedTo', + fields=[ + ('id', model_utils.fields.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.RemoveConstraint( + model_name='roleassignment', + name='program_and_areas_null_for_user', + ), + migrations.RemoveConstraint( + model_name='roleassignment', + name='unique_user_role_assignment', + ), + migrations.RemoveField( + model_name='roleassignment', + name='areas', + ), + migrations.RemoveField( + model_name='roleassignment', + name='full_area_access', + ), + migrations.AddField( + model_name='adminarealimitedto', + name='areas', + field=models.ManyToManyField(blank=True, related_name='admin_area_limits', to='geo.Area'), + ), + migrations.AddField( + model_name='adminarealimitedto', + name='partner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_area_limits', to='account.partner'), + ), + migrations.AddField( + model_name='adminarealimitedto', + name='program', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admin_area_limits', to='program.program'), + ), + ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 8b6aac8c9a..e41f79aabf 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -115,9 +115,6 @@ def is_default(self) -> bool: def is_editable(self) -> bool: return not self.is_unicef and not self.is_default - def has_full_area_access_in_program(self, program_id: Union[str, UUID]) -> bool: - return RoleAssignment.objects.filter(partner=self, program_id=program_id, full_area_access=True).exists() - def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: from hct_mis_api.apps.program.models import Program if self.role_assignments.filter(business_area_id=business_area_id, program=None).exists(): @@ -138,34 +135,17 @@ def has_program_access(self, program_id: Union[str, UUID]) -> bool: ).exclude(expiry_date__lt=timezone.now()).exists() def has_area_access(self, area_id: Union[str, UUID], program_id: Union[str, UUID]) -> bool: - return self.get_program_areas(program_id).filter(id=area_id).exists() - - def get_program_areas(self, program_id: Union[str, UUID]) -> QuerySet[Area]: - return Area.objects.filter(role_assignments__partner=self, role_assignments__program_id=program_id) - - def get_roles_for_business_area( - self, business_area_slug: Optional[str] = None, business_area_id: Optional["UUID"] = None - ) -> QuerySet["Role"]: - if not business_area_slug and not business_area_id: - return Role.objects.none() - - if not business_area_id and business_area_slug: - business_area_id = BusinessArea.objects.get(slug=business_area_slug).id - - return Role.objects.filter( - role_assignments__partner=self, - role_assignments__business_area_id=business_area_id, + return ( + not self.has_area_limits_in_program(program_id) + or self.get_area_limits_for_program(program_id).filter(id=area_id).exists() ) - # TODO: permissions: possibly remove - def add_roles_in_business_area(self, business_area_id: str, roles: List["Role"], program_id: Optional[str] = None) -> None: - for role in roles: - RoleAssignment.objects.get_or_create( - partner=self, - business_area_id=business_area_id, - program_id=program_id, - role=role - ) + def get_area_limits_for_program(self, program_id: Union[str, UUID]) -> QuerySet[Area]: + area_limits = AdminAreaLimitedTo.objects.filter(partner=self, program_id=program_id) + return Area.objects.filter(admin_area_limits__in=area_limits) + + def has_area_limits_in_program(self, program_id: Union[str, UUID]) -> bool: + return self.get_area_limits_for_program(program_id).exists() class User(AbstractUser, NaturalKeyModel, UUIDModel): @@ -198,25 +178,48 @@ def __str__(self) -> str: return self.email or self.username def save(self, *args: Any, **kwargs: Any) -> None: - print("Saving user:") if not self.partner: self.partner, _ = Partner.objects.get_or_create(name=settings.DEFAULT_EMPTY_PARTNER) if not self.partner.pk: self.partner.save() super().save(*args, **kwargs) + def has_program_access(self, program_id: Union[str, UUID]) -> bool: + from hct_mis_api.apps.program.models import Program + return RoleAssignment.objects.filter( + Q(user=self) | Q(partner__user=self) + & Q(business_area=Program.objects.get(id=program_id).business_area) + & (Q(program=None) | Q(program_id=program_id)) + ).exclude(expiry_date__lt=timezone.now()).exists() + + def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: + from hct_mis_api.apps.program.models import Program + if RoleAssignment.objects.filter( + Q(user=self) | Q(partner__user=self), business_area_id=business_area_id, program=None + ).exists(): + programs_ids = Program.objects.filter(business_area_id=business_area_id).values_list("id", flat=True) + else: + RoleAssignment.objects.filter( + Q(user=self) | Q(partner__user=self), business_area_id=business_area_id, + ).values_list("program_id", flat=True) + return [ + str(program_id) + for program_id in programs_ids + ] + def permissions_in_business_area(self, business_area_slug: str, program_id: Optional[UUID] = None) -> set: """ return list of permissions for the given business area and program, retrieved from RoleAssignments of the user and their partner """ if program_id: - if not self.partner.has_program_access(program_id): + if not self.has_program_access(program_id): return set() role_assignments = RoleAssignment.objects.filter( Q(partner__user=self, business_area__slug=business_area_slug, program_id=program_id) | Q(partner__user=self, business_area__slug=business_area_slug, program=None) | - Q(user=self, business_area__slug=business_area_slug) + Q(user=self, business_area__slug=business_area_slug, program_id=program_id) | + Q(user=self, business_area__slug=business_area_slug, program=None) ).exclude(expiry_date__lt=timezone.now()) else: role_assignments = RoleAssignment.objects.filter( @@ -328,8 +331,6 @@ class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): partner = models.ForeignKey("account.Partner", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) role = models.ForeignKey("account.Role", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) program = models.ForeignKey("program.Program", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - areas = models.ManyToManyField("geo.Area", related_name="role_assignments", blank=True) - full_area_access = models.BooleanField(default=False) expiry_date = models.DateField( blank=True, null=True, help_text="After expiry date this Role Assignment will be inactive." ) @@ -342,19 +343,6 @@ class Meta: check=Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False), name="user_or_partner_not_both" ), - # program can only be assigned for partner roles; not for user roles - models.CheckConstraint( - check=Q(user__isnull=True) | (Q(user__isnull=False) & Q(program__isnull=True)), - name="program_and_areas_null_for_user" - ), - # unique combination of user, role, and business_area; applies only when a user is assigned, not a partner. - # (For partner assignments, the role can be reused within the same business_area - # if linked to different programs, as the assignment is considered per program, not per business_area.) - models.UniqueConstraint( - fields=["business_area", "role", "user"], - condition=Q(user__isnull=False), - name="unique_user_role_assignment" - ), ] def clean(self) -> None: @@ -363,14 +351,6 @@ def clean(self) -> None: # Ensure either user or partner is set, but not both if bool(self.user) == bool(self.partner): errors.append("Either user or partner must be set, but not both.") - # Ensure program and areas can only be assigned for partner roles; not for user roles - if self.user and (self.program or self.areas.exists()): - errors.append("Program and areas can only be assigned for partner roles; not for user roles.") - # Ensure user role assignment is unique within the business area - if self.user and RoleAssignment.objects.filter( - business_area=self.business_area, role=self.role, user=self.user - ).exclude(id=self.id).exists(): - errors.append("This role is already assigned to the user in the business area.") # Ensure partner can only be assigned roles that have flag is_available_for_partner as True if self.partner and self.role and not self.role.is_available_for_partner: errors.append("Partner can only be assigned roles that are available for partners.") @@ -386,6 +366,16 @@ def __str__(self) -> str: return f"{role_holder} {self.role} in {self.business_area}" +class AdminAreaLimitedTo(TimeStampedUUIDModel): + """ + Model to limit the admin area access for a partner. + Partners with full area access for a certain program will not have any area limits - no record in this model. + """ + partner = models.ForeignKey("account.Partner", related_name="admin_area_limits", on_delete=models.CASCADE) + program = models.ForeignKey("program.Program", related_name="admin_area_limits", on_delete=models.CASCADE) + areas = models.ManyToManyField("geo.Area", related_name="admin_area_limits", blank=True) + + class UserGroup(NaturalKeyModel, models.Model): business_area = models.ForeignKey("core.BusinessArea", related_name="user_groups", on_delete=models.CASCADE) user = models.ForeignKey("account.User", related_name="user_groups", on_delete=models.CASCADE) diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 6e573724dc..f2b8f83103 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -136,12 +136,3 @@ def invalidate_permissions_cache_on_user_change(sender, instance, **kwargs): Invalidate the cache for a User when they are updated. (For example change of partner or is_superuser flag) """ _invalidate_user_permissions_cache([instance]) - - -@receiver(post_save, sender=Partner) -@receiver(pre_delete, sender=Partner) -def invalidate_permissions_cache_on_partner_change(sender, instance, **kwargs): - """ - Invalidate the cache for a User when they are updated. (For example change of partner or is_superuser flag) - """ - _invalidate_user_permissions_cache([instance]) diff --git a/src/hct_mis_api/apps/accountability/schema.py b/src/hct_mis_api/apps/accountability/schema.py index 24cc58c21f..c482ca77ff 100644 --- a/src/hct_mis_api/apps/accountability/schema.py +++ b/src/hct_mis_api/apps/accountability/schema.py @@ -120,7 +120,7 @@ def resolve_all_feedbacks(self, info: Any, **kwargs: Any) -> QuerySet[Feedback]: business_area_id = BusinessArea.objects.get(slug=business_area_slug).id queryset = Feedback.objects.filter(business_area__slug=business_area_slug).select_related("admin2") - if not user.partner.has_full_area_access_in_program: + if user.partner.has_area_limits_in_program(program_id): queryset = filter_feedback_based_on_partner_areas_2(queryset, user.partner, business_area_id, program_id) return queryset diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index 826d79273b..7f43878676 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -16,12 +16,8 @@ from hct_mis_api.apps.account.models import User -# TODO: perms: add area check class PermissionsBackend(BaseBackend): def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: - print(user) - print(obj) - if not obj: program = None business_area = None @@ -62,7 +58,7 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str # If permission is checked for a Program and User does not have access to it, return empty set if program and not RoleAssignment.objects.filter( - Q(partner=user.partner) + Q(partner=user.partner) | Q(user=user) & Q(business_area=business_area) & (Q(program=None) | Q(program=program)) ).exclude(expiry_date__lt=timezone.now()).exists(): diff --git a/src/hct_mis_api/apps/grievance/filters.py b/src/hct_mis_api/apps/grievance/filters.py index aef3c96a25..4849240de2 100644 --- a/src/hct_mis_api/apps/grievance/filters.py +++ b/src/hct_mis_api/apps/grievance/filters.py @@ -312,7 +312,7 @@ def filter_is_cross_area(self, qs: QuerySet, name: str, value: bool) -> QuerySet if ( value is True and user.has_permission(perm, business_area, program_id) - and (user.partner.has_full_area_access_in_program(program_id) or not program_id) + and (not user.partner.has_area_limits_in_program(program_id) or not program_id) ): return qs.filter(needs_adjudication_ticket_details__is_cross_area=True) else: diff --git a/src/hct_mis_api/apps/grievance/schema.py b/src/hct_mis_api/apps/grievance/schema.py index b2b2d726fe..063221e9df 100644 --- a/src/hct_mis_api/apps/grievance/schema.py +++ b/src/hct_mis_api/apps/grievance/schema.py @@ -145,7 +145,6 @@ def check_node_permission(cls, info: Any, object_instance: GrievanceTicket) -> N ) partner = user.partner ticket_program_id = str(object_instance.programs.first().id) if object_instance.programs.first() else None - # TODO: perms: move area check to has_perm if not object_instance.admin2 or not ticket_program_id: # admin2 is empty or non-program ticket -> no restrictions for admin area has_partner_area_access = True @@ -576,9 +575,9 @@ def resolve_all_grievance_ticket(self, info: Any, **kwargs: Any) -> QuerySet: queryset = queryset.prefetch_related(*to_prefetch) # Ignore filtering for Cross Area tickets - if not (kwargs.get("is_cross_area", False) and program_id and user.partner.has_full_area_access_in_program(program_id)): + if not (kwargs.get("is_cross_area", False) and program_id and not user.partner.has_area_limits_in_program(program_id)): queryset = filter_grievance_tickets_based_on_partner_areas_2( - queryset, user.partner, business_area_id, program_id + queryset, user, business_area_id, program_id ) if program_id is None: @@ -610,7 +609,9 @@ def resolve_cross_area_filter_available(self, info: Any, **kwargs: Any) -> bool: perm = Permissions.GRIEVANCES_CROSS_AREA_FILTER.value - return user.has_permission(perm, business_area, program_id) and user.partner.has_full_area_access_in_program( + # Access to the cross-area filter, in addition to the standard permissions check, + # is available only if user does not have ANY area limits in the program (has full-area-access) + return user.has_permission(perm, business_area, program_id) and not user.partner.has_area_limits_in_program( program_id ) diff --git a/src/hct_mis_api/apps/grievance/utils.py b/src/hct_mis_api/apps/grievance/utils.py index 9a85a133bb..7ac7aa6f19 100644 --- a/src/hct_mis_api/apps/grievance/utils.py +++ b/src/hct_mis_api/apps/grievance/utils.py @@ -8,7 +8,7 @@ from django.db.models import Q, QuerySet from django.shortcuts import get_object_or_404 -from hct_mis_api.apps.account.models import Partner +from hct_mis_api.apps.account.models import Partner, User from hct_mis_api.apps.accountability.models import Feedback from hct_mis_api.apps.core.utils import decode_id_string from hct_mis_api.apps.grievance.models import ( @@ -125,13 +125,13 @@ def delete_grievance_documents(ticket_id: str, ids_to_delete: List[str]) -> None def filter_grievance_tickets_based_on_partner_areas_2( queryset: QuerySet["GrievanceTicket"], - user_partner: Partner, + user: User, business_area_id: str, program_id: Optional[str], ) -> QuerySet["GrievanceTicket"]: return filter_based_on_partner_areas_2( queryset=queryset, - user_partner=user_partner, + user=user, business_area_id=business_area_id, program_id=program_id, lookup_id="programs__id__in", @@ -157,7 +157,7 @@ def filter_feedback_based_on_partner_areas_2( def filter_based_on_partner_areas_2( queryset: QuerySet["GrievanceTicket", "Feedback"], - user_partner: Partner, + user: User, business_area_id: str, program_id: Optional[str], lookup_id: str, @@ -166,20 +166,22 @@ def filter_based_on_partner_areas_2( try: programs_for_business_area = [] filter_q = Q() - if program_id and user_partner.has_program_access(program_id): + if program_id and user.has_program_access(program_id): programs_for_business_area = [program_id] elif not program_id: - programs_for_business_area = user_partner.get_program_ids_for_business_area(business_area_id) + programs_for_business_area = user.get_program_ids_for_business_area(business_area_id) # if user does not have access to any program/selected program -> return empty queryset for program-related obj if not programs_for_business_area: return queryset.model.objects.none() - programs_permissions = [ - (program_id, user_partner.get_program_areas(program_id)) for program_id in programs_for_business_area - ] - for perm_program_id, areas_ids in programs_permissions: - program_q = Q(**{lookup_id: id_container(perm_program_id)}) + + for program_id in programs_for_business_area: + program_q = Q(**{lookup_id: id_container(program_id)}) areas_null_and_program_q = program_q & Q(admin2__isnull=True) - filter_q |= Q(areas_null_and_program_q | Q(program_q & Q(admin2__in=areas_ids))) + # apply admin area limits if partner has restrictions + area_limits = user.partner.get_area_limits_for_program(program_id) + areas_query = Q(admin2__in=area_limits) if area_limits.exists() else Q() + + filter_q |= Q(areas_null_and_program_q | Q(program_q & areas_query)) # add Feedbacks without program for "All Programmes" query if queryset.model is Feedback and not program_id: diff --git a/src/hct_mis_api/apps/household/schema.py b/src/hct_mis_api/apps/household/schema.py index fe8980214d..1534cc6abc 100644 --- a/src/hct_mis_api/apps/household/schema.py +++ b/src/hct_mis_api/apps/household/schema.py @@ -355,15 +355,18 @@ def check_node_permission(cls, info: Any, object_instance: Individual) -> None: if not user.partner.has_program_access(object_instance.program_id): raise PermissionDenied("Permission Denied") if object_instance.household_id and object_instance.household.admin_area_id: - areas_from_partner = user.partner.get_program_areas(object_instance.program_id) - household = object_instance.household - areas_from_household = [ - household.admin1_id, - household.admin2_id, - household.admin3_id, - ] - if not areas_from_partner.filter(id__in=areas_from_household).exists(): - raise PermissionDenied("Permission Denied") + # check if user has access to the area + area_limits = user.partner.get_area_limits_for_program(program_id) + if area_limits.exists(): + household = object_instance.household + areas_from_household = [ + household.admin1_id, + household.admin2_id, + household.admin3_id, + ] + if not area_limits.filter(id__in=areas_from_household).exists(): + raise PermissionDenied("Permission Denied") + # if user can't simply view all individuals, we check if they can do it because of grievance or rdi details if not user.has_permission( @@ -530,10 +533,12 @@ def check_node_permission(cls, info: Any, object_instance: Household) -> None: if not user.partner.has_program_access(object_instance.program_id): raise PermissionDenied("Permission Denied") if object_instance.admin_area_id: - areas_from_partner = user.partner.get_program_areas(object_instance.program_id) - areas_from_household = [object_instance.admin1_id, object_instance.admin2_id, object_instance.admin3_id] - if not areas_from_partner.filter(id__in=areas_from_household).exists(): - raise PermissionDenied("Permission Denied") + # check if user has access to the area + area_limits = user.partner.get_area_limits_for_program(program_id) + if area_limits.exists(): + areas_from_household = [object_instance.admin1_id, object_instance.admin2_id, object_instance.admin3_id] + if not area_limits.filter(id__in=areas_from_household).exists(): + raise PermissionDenied("Permission Denied") # if user doesn't have permission to view all households or RDI details, we check based on their grievance tickets if not user.has_permission( @@ -725,19 +730,20 @@ def resolve_all_individuals(self, info: Any, **kwargs: Any) -> QuerySet[Individu programs_for_business_area = user.partner.get_program_ids_for_business_area(business_area_id) if not programs_for_business_area: return Individual.objects.none() - programs_permissions = [ - (program_id, user.partner.get_program_areas(program_id)) for program_id in programs_for_business_area - ] filter_q = Q() - for program_id, areas_ids in programs_permissions: + for program_id in programs_for_business_area: + program_q = Q(program_id=program_id) + areas_null_and_program_q = program_q & Q(household__admin_area__isnull=True) + # apply admin area limits if partner has restrictions + area_limits = user.partner.get_area_limits_for_program(program_id) areas_query = Q( - Q(household__admin1__in=areas_ids) - | Q(household__admin2__in=areas_ids) - | Q(household__admin3__in=areas_ids) - | Q(household__admin_area__isnull=True) - ) - filter_q |= Q(Q(program_id=program_id) & areas_query) + Q(household__admin1__in=area_limits) + | Q(household__admin2__in=area_limits) + | Q(household__admin3__in=area_limits) + ) if area_limits.exists() else Q() + + filter_q |= Q(areas_null_and_program_q | Q(program_q & areas_query)) queryset = queryset.filter(filter_q) return queryset @@ -774,19 +780,20 @@ def resolve_all_households(self, info: Any, **kwargs: Any) -> QuerySet: programs_for_business_area = user.partner.get_program_ids_for_business_area(business_area_id) if not programs_for_business_area: return Household.objects.none() - programs_permissions = [ - (program_id, user.partner.get_program_areas(program_id)) for program_id in programs_for_business_area - ] filter_q = Q() - for program_id, areas_ids in programs_permissions: + for program_id in programs_for_business_area: + program_q = Q(program_id=program_id) + areas_null_and_program_q = program_q & Q(admin_area__isnull=True) + # apply admin area limits if partner has restrictions + area_limits = user.partner.get_area_limits_for_program(program_id) areas_query = Q( - Q(admin1__in=areas_ids) - | Q(admin2__in=areas_ids) - | Q(admin3__in=areas_ids) - | Q(admin_area__isnull=True) - ) - filter_q |= Q(Q(program_id=program_id) & areas_query) + Q(admin1__in=area_limits) + | Q(admin2__in=area_limits) + | Q(admin3__in=area_limits) + ) if area_limits.exists() else Q() + + filter_q |= Q(areas_null_and_program_q | Q(program_q & areas_query)) queryset = queryset.filter(filter_q) diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py index 3866e76589..8775f9b04c 100644 --- a/tests/unit/apps/account/test_models.py +++ b/tests/unit/apps/account/test_models.py @@ -4,7 +4,6 @@ from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory, PartnerFactory from hct_mis_api.apps.account.models import RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan -from hct_mis_api.apps.geo.fixtures import AreaFactory from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -38,37 +37,6 @@ def setUp(self) -> None: business_area=self.business_area, ) - def test_unique_user_role_assignment(self) -> None: - # Not possible to have the same role assigned to the same user in the same BA - with self.assertRaises(ValidationError) as ve_context: - RoleAssignment.objects.create( - user=self.user, - role=self.role, - business_area=self.business_area, - ) - self.assertIn( - "This role is already assigned to the user in the business area.", - str(ve_context.exception), - ) - - # Possible to have the same role assigned to the same partner in the same BA (not failing for two records with user=None) - RoleAssignment.objects.create( - user=None, - role=self.role, - business_area=self.business_area, - partner=self.partner, - program=self.program1, - ) - - RoleAssignment.objects.create( - user=None, - role=self.role, - business_area=self.business_area, - partner=self.partner, - program=self.program2, - ) - self.assertEqual(RoleAssignment.objects.filter(role=self.role, business_area=self.business_area, partner=self.partner).count(), 2) - def test_user_or_partner(self) -> None: # Either user or partner must be set with self.assertRaises(ValidationError) as ve_context: @@ -98,48 +66,6 @@ def test_user_or_partner_not_both(self) -> None: str(ve_context.exception), ) - def test_program_and_areas_only_for_partner(self) -> None: - # Only partner can have program assigned - with self.assertRaises(ValidationError) as ve_context: - RoleAssignment.objects.create( - user=self.user, - partner=None, - role=self.role2, - business_area=self.business_area, - program=self.program1, - ) - self.assertIn( - 'Program and areas can only be assigned for partner roles; not for user roles.', - str(ve_context.exception), - ) - - # Only partner can have areas assigned - area = AreaFactory(name="Test Area") - role_assignment = RoleAssignment.objects.create( - user=self.user, - partner=None, - role=self.role2, - business_area=self.business_area, - program=None, - ) - with self.assertRaises(ValidationError) as ve_context: - role_assignment.areas.add(area) - role_assignment.save() - self.assertIn( - 'Program and areas can only be assigned for partner roles; not for user roles.', - str(ve_context.exception), - ) - - # Partner can have program and areas assigned - role_assignment = RoleAssignment.objects.create( - user=None, - partner=self.partner, - role=self.role2, - business_area=self.business_area, - program=self.program1, - ) - self.assertIsNotNone(role_assignment.id) - def test_is_available_for_partner_flag(self) -> None: # is_available_for_partner flag is set to True role_assignment = RoleAssignment.objects.create( diff --git a/tests/unit/apps/account/test_partner_permissions.py b/tests/unit/apps/account/test_partner_permissions.py index 910b6fa59d..4914796e95 100644 --- a/tests/unit/apps/account/test_partner_permissions.py +++ b/tests/unit/apps/account/test_partner_permissions.py @@ -1,7 +1,7 @@ from django.test import TestCase -from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory, AdminAreaLimitedToFactory +from hct_mis_api.apps.account.models import Role, User, RoleAssignment, AdminAreaLimitedTo from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.fixtures import AreaFactory @@ -23,24 +23,22 @@ def setUpTestData(cls) -> None: cls.program = ProgramFactory.create(status=Program.DRAFT, business_area=cls.business_area) cls.other_partner = PartnerFactory(name="Partner") cls.other_user = UserFactory(partner=cls.other_partner) - role_assignment_partner_other = RoleAssignment.objects.create( + RoleAssignment.objects.create( business_area=cls.business_area, program=cls.program, partner=cls.other_partner, role=cls.role_2, ) - role_assignment_partner_other.areas.set([cls.area_1]) + AdminAreaLimitedToFactory.objects.create(partner=cls.other_partner, program=cls.program, areas=[cls.area_1]) cls.unicef_partner = PartnerFactory(name="UNICEF") cls.unicef_user = UserFactory(partner=cls.unicef_partner) - role_assignment_partner_unicef = RoleAssignment.objects.create( + RoleAssignment.objects.create( business_area=cls.business_area, program=cls.program, partner=cls.unicef_partner, role=cls.role_1, ) - role_assignment_partner_unicef.areas.set([cls.area_1, cls.area_2]) - RoleAssignment.objects.create( business_area=cls.business_area, @@ -67,25 +65,16 @@ def test_get_partner_program_ids_for_business_area(self) -> None: resp_2 = self.unicef_user.partner.get_program_ids_for_business_area(business_area_id=self.business_area.pk) self.assertListEqual(resp_2, [str(self.program.pk)]) - def test_get_partner_roles_for_business_area(self) -> None: - empty_qs = self.other_user.partner.get_roles_for_business_area() - self.assertQuerysetEqual(empty_qs, Role.objects.none()) - - resp_1 = self.other_user.partner.get_roles_for_business_area(business_area_slug=self.business_area.slug) - resp_2 = self.other_user.partner.get_roles_for_business_area(business_area_id=self.business_area.pk) - - self.assertQuerysetEqual(resp_1, resp_2) - self.assertQuerysetEqual(resp_1, Role.objects.filter(pk=self.role_2.pk)) - - resp_3 = self.unicef_user.partner.get_roles_for_business_area(business_area_slug=self.business_area.slug) - self.assertQuerysetEqual(resp_3, Role.objects.none()) - - def test_get_partner_areas_per_program(self) -> None: - other_partner_areas = self.other_user.partner.get_program_areas(self.program.pk) + def test_get_partner_area_limits_per_program(self) -> None: + other_partner_areas = self.other_user.partner.get_area_limits_for_program(self.program.pk) self.assertQuerysetEqual(other_partner_areas, Area.objects.filter(id=self.area_1.pk)) - unicef_partner_areas = self.unicef_user.partner.get_program_areas(self.program.pk) - self.assertQuerysetEqual(unicef_partner_areas, Area.objects.filter(id__in=[self.area_1.pk, self.area_2.pk])) + def test_has_area_access(self) -> None: + self.assertTrue(self.other_user.partner.has_area_access(self.area_1.pk, self.program.pk)) + self.assertFalse(self.other_user.partner.has_area_access(self.area_2.pk, self.program.pk)) + + self.assertTrue(self.unicef_user.partner.has_area_access(self.area_1.pk, self.program.pk)) + self.assertTrue(self.unicef_user.partner.has_area_access(self.area_2.pk, self.program.pk)) def test_partner_permissions_in_business_area(self) -> None: # two roles without program diff --git a/tests/unit/apps/core/test_backends.py b/tests/unit/apps/core/test_backends.py index 0aa2e2e8b8..e7641389a5 100644 --- a/tests/unit/apps/core/test_backends.py +++ b/tests/unit/apps/core/test_backends.py @@ -123,25 +123,34 @@ def test_role_expired(self): self.assertNotIn(self._get_permission_name_combined(self.permission), permissions) def test_get_permissions_for_program(self): + # User's Role Assignmenmts grants + program_empty = ProgramFactory(status=Program.ACTIVE, name="Test Program Empty", business_area=self.business_area) role = RoleFactory(name="Role for Partner", permissions=["PROGRAMME_FINISH"]) self.role_assignment_partner.role = role self.role_assignment_partner.program = self.program self.role_assignment_partner.save() + self.role_assignment_user.program = program_empty + self.role_assignment_user.save() + role = RoleFactory(name="Role for User", permissions=["PROGRAMME_CREATE", "PROGRAMME_UPDATE"]) self.role_assignment_user.role = role self.role_assignment_user.save() permissions_in_program = self.backend.get_all_permissions(self.user, self.program) self.assertIn("PROGRAMME_FINISH", permissions_in_program) - self.assertIn("PROGRAMME_CREATE", permissions_in_program) - self.assertIn("PROGRAMME_UPDATE", permissions_in_program) # no permissions for other program program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) self.assertEqual(set(), permissions_in_program_other) + # permissions from user's RoleAssignment in program_empty + permissions_in_program_empty = self.backend.get_all_permissions(self.user, program_empty) + self.assertIn("PROGRAMME_CREATE", permissions_in_program_empty) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program_empty) + self.assertNotIn("PROGRAMME_FINISH", permissions_in_program_other) + # partner loses permission for the program and gets permission for the other self.role_assignment_partner.program = program_other self.role_assignment_partner.save() @@ -149,6 +158,14 @@ def test_get_permissions_for_program(self): self.assertEqual(set(), permissions_in_program) permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) self.assertIn("PROGRAMME_FINISH", permissions_in_program_other) + self.assertNotIn("PROGRAMME_CREATE", permissions_in_program_other) + self.assertNotIn("PROGRAMME_UPDATE", permissions_in_program_other) + + # user loses permission for the program and gets permission for the other + self.role_assignment_user.program = program_other + self.role_assignment_user.save() + permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) + self.assertIn("PROGRAMME_FINISH", permissions_in_program_other) self.assertIn("PROGRAMME_CREATE", permissions_in_program_other) self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) @@ -157,6 +174,18 @@ def test_get_permissions_for_program(self): self.role_assignment_partner.save() permissions_in_program = self.backend.get_all_permissions(self.user, self.program) self.assertIn("PROGRAMME_FINISH", permissions_in_program) + self.assertNotIn("PROGRAMME_CREATE", permissions_in_program) + self.assertNotIn("PROGRAMME_UPDATE", permissions_in_program) + permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) + self.assertIn("PROGRAMME_FINISH", permissions_in_program_other) + self.assertIn("PROGRAMME_CREATE", permissions_in_program_other) + self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) + + # user gets access to all programs in the business area (program=None) + self.role_assignment_user.program = None + self.role_assignment_user.save() + permissions_in_program = self.backend.get_all_permissions(self.user, self.program) + self.assertIn("PROGRAMME_FINISH", permissions_in_program) self.assertIn("PROGRAMME_CREATE", permissions_in_program) self.assertIn("PROGRAMME_UPDATE", permissions_in_program) permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) @@ -165,6 +194,7 @@ def test_get_permissions_for_program(self): self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) def test_get_permissions_from_all_sources(self): + program_for_user = ProgramFactory(status=Program.ACTIVE, name="Test Program For User", business_area=self.business_area) permission1 = Permission.objects.create(codename="test_permission1", name="Test Permission 1", content_type=self.content_type) permission2 = Permission.objects.create(codename="test_permission2", name="Test Permission 2", @@ -180,6 +210,7 @@ def test_get_permissions_from_all_sources(self): group_role_assignment_user = Group.objects.create(name="TestGroupRoleAssignmentUser") group_role_assignment_user.permissions.add(permission2) self.role_assignment_user.group = group_role_assignment_user + self.role_assignment_user.program = program_for_user self.role_assignment_user.save() # permission on a RoleAssignment group for partner @@ -217,14 +248,23 @@ def test_get_permissions_from_all_sources(self): self.assertEqual(set(), permissions) # permissions for program + # only RoleAssignment for partner is connected to this Program permissions = self.backend.get_all_permissions(self.user, self.program) self.assertIn(self._get_permission_name_combined(permission1), permissions) - self.assertIn(self._get_permission_name_combined(permission2), permissions) + self.assertNotIn(self._get_permission_name_combined(permission2), permissions) self.assertIn(self._get_permission_name_combined(permission3), permissions) - self.assertIn("PROGRAMME_CREATE", permissions) self.assertIn("PROGRAMME_FINISH", permissions) + self.assertNotIn("PROGRAMME_CREATE", permissions) + + # permissions for program for user + permissions = self.backend.get_all_permissions(self.user, program_for_user) + self.assertIn(self._get_permission_name_combined(permission1), permissions) + self.assertIn(self._get_permission_name_combined(permission2), permissions) + self.assertNotIn(self._get_permission_name_combined(permission3), permissions) + self.assertIn("PROGRAMME_CREATE", permissions) + self.assertNotIn("PROGRAMME_FINISH", permissions) - # permissions for other program - empty (partner does not have access to this program) + # permissions for other program - empty (neither partner nor user has access to this program) program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) permissions = self.backend.get_all_permissions(self.user, program_other) self.assertEqual(set(), permissions) From 4a4f14e49ae59abb06f4e8790686f83f6dd65b68 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Sun, 12 Jan 2025 17:28:50 +0100 Subject: [PATCH 012/208] formatting, some fixes --- .../apps/account/authentication.py | 2 +- src/hct_mis_api/apps/account/caches.py | 6 +- src/hct_mis_api/apps/account/celery_tasks.py | 11 +- src/hct_mis_api/apps/account/filters.py | 4 +- src/hct_mis_api/apps/account/fixtures.py | 16 ++- src/hct_mis_api/apps/account/models.py | 109 ++++++++++-------- src/hct_mis_api/apps/account/schema.py | 4 +- src/hct_mis_api/apps/account/signals.py | 30 +++-- src/hct_mis_api/apps/accountability/schema.py | 2 +- src/hct_mis_api/apps/core/backends.py | 59 +++++----- .../management/commands/addunicefusers.py | 2 +- .../core/management/commands/initcypress.py | 2 +- .../apps/core/management/commands/initdemo.py | 3 +- src/hct_mis_api/apps/core/models.py | 2 +- src/hct_mis_api/apps/core/utils.py | 7 +- src/hct_mis_api/apps/grievance/documents.py | 2 +- .../apps/grievance/notifications.py | 2 +- src/hct_mis_api/apps/grievance/schema.py | 10 +- src/hct_mis_api/apps/grievance/utils.py | 4 +- src/hct_mis_api/apps/household/schema.py | 31 +++-- src/hct_mis_api/apps/payment/notifications.py | 2 +- tests/selenium/conftest.py | 2 +- tests/unit/apps/account/test_celery_tasks.py | 26 +++-- .../apps/account/test_check_permissions.py | 2 +- tests/unit/apps/account/test_models.py | 13 ++- .../apps/account/test_partner_permissions.py | 32 +++-- ...ignals_for_invalidate_permission_caches.py | 37 +++--- tests/unit/apps/account/test_user_filters.py | 2 +- .../unit/apps/account/test_user_import_csv.py | 2 +- tests/unit/apps/account/test_user_roles.py | 19 ++- tests/unit/apps/administration/test_admin.py | 2 +- tests/unit/apps/core/test_backends.py | 83 ++++++++----- tests/unit/apps/core/test_doap.py | 6 +- .../unit/apps/grievance/test_grievance_es.py | 1 + .../payment/test_finish_verification_plan.py | 6 +- ...import_export_payment_plan_payment_list.py | 2 +- .../program/test_program_cycle_rest_api.py | 2 +- tests/unit/fixtures/account.py | 2 +- 38 files changed, 324 insertions(+), 225 deletions(-) diff --git a/src/hct_mis_api/apps/account/authentication.py b/src/hct_mis_api/apps/account/authentication.py index d1277ea320..b07e514fc6 100644 --- a/src/hct_mis_api/apps/account/authentication.py +++ b/src/hct_mis_api/apps/account/authentication.py @@ -9,7 +9,7 @@ from social_core.pipeline import user as social_core_user from hct_mis_api.apps.account.microsoft_graph import MicrosoftGraphAPI -from hct_mis_api.apps.account.models import ACTIVE, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import ACTIVE, Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea logger = logging.getLogger(__name__) diff --git a/src/hct_mis_api/apps/account/caches.py b/src/hct_mis_api/apps/account/caches.py index e79273c1c7..90cf40bd50 100644 --- a/src/hct_mis_api/apps/account/caches.py +++ b/src/hct_mis_api/apps/account/caches.py @@ -1,4 +1,4 @@ -from typing import Optional, TYPE_CHECKING +from typing import Optional from hct_mis_api.apps.account.models import User from hct_mis_api.apps.core.models import BusinessArea @@ -9,5 +9,7 @@ def get_user_permissions_version_key(user: "User") -> str: return f"user:{str(user.id)}:version" -def get_user_permissions_cache_key(user: "User", user_version: int, business_area: Optional["BusinessArea"], program: Optional["Program"]) -> str: +def get_user_permissions_cache_key( + user: "User", user_version: int, business_area: Optional["BusinessArea"], program: Optional["Program"] +) -> str: return f"permissions:{str(user.id)}:{user_version}:{business_area.slug if business_area else 'None'}:{program.id if program else 'None'}" diff --git a/src/hct_mis_api/apps/account/celery_tasks.py b/src/hct_mis_api/apps/account/celery_tasks.py index 6a7c030b6c..dbf8c7935b 100644 --- a/src/hct_mis_api/apps/account/celery_tasks.py +++ b/src/hct_mis_api/apps/account/celery_tasks.py @@ -3,14 +3,10 @@ from django.db.models import Q from django.utils import timezone -from typing import Any -from django.db import transaction - -from hct_mis_api.apps.account.models import RoleAssignment, User +from hct_mis_api.apps.account.models import User from hct_mis_api.apps.account.signals import _invalidate_user_permissions_cache from hct_mis_api.apps.core.celery import app - from hct_mis_api.apps.utils.logs import log_start_and_end from hct_mis_api.apps.utils.sentry import sentry_tags @@ -20,10 +16,11 @@ @app.task(bind=True, default_retry_delay=60, max_retries=3) @log_start_and_end @sentry_tags -def invalidate_permissions_cache_for_user_if_expired_role(self) -> bool: +def invalidate_permissions_cache_for_user_if_expired_role() -> bool: # Invalidate permissions cache for users with roles that expired a day before day_ago = timezone.now() - datetime.timedelta(days=1) users = User.objects.filter( - Q(role_assignments__expiry_date=day_ago.date()) | Q(partner__role_assignments__expiry_date=day_ago.date())).distinct() + Q(role_assignments__expiry_date=day_ago.date()) | Q(partner__role_assignments__expiry_date=day_ago.date()) + ).distinct() _invalidate_user_permissions_cache(users) return True diff --git a/src/hct_mis_api/apps/account/filters.py b/src/hct_mis_api/apps/account/filters.py index c9df553680..886ab2fd48 100644 --- a/src/hct_mis_api/apps/account/filters.py +++ b/src/hct_mis_api/apps/account/filters.py @@ -67,7 +67,9 @@ def search_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User return qs.filter(q_obj) def business_area_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": - return qs.filter(Q(role_assignments__business_area__slug=value) | Q(partner__role_assignments__business_area__slug=value)) + return qs.filter( + Q(role_assignments__business_area__slug=value) | Q(partner__role_assignments__business_area__slug=value) + ) def program_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[User]": program_id = decode_id_string(value) diff --git a/src/hct_mis_api/apps/account/fixtures.py b/src/hct_mis_api/apps/account/fixtures.py index 71c0a05a96..15d44ed9ec 100644 --- a/src/hct_mis_api/apps/account/fixtures.py +++ b/src/hct_mis_api/apps/account/fixtures.py @@ -1,13 +1,19 @@ import random import time -from typing import Any +from typing import Any, List from django.contrib.auth import get_user_model import factory from factory.django import DjangoModelFactory -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment, AdminAreaLimitedTo +from hct_mis_api.apps.account.models import ( + AdminAreaLimitedTo, + Partner, + Role, + RoleAssignment, + User, +) from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.fixtures import ProgramFactory @@ -74,12 +80,12 @@ class Meta: django_get_or_create = ("user", "partner", "role") @factory.lazy_attribute - def partner(self): + def partner(self) -> Any: # Only create partner if user is not provided return None if self.user else PartnerFactory() @factory.lazy_attribute - def user(self): + def user(self) -> Any: # Only create user if partner is not provided return None if self.partner else UserFactory() @@ -92,7 +98,7 @@ class Meta: model = AdminAreaLimitedTo @factory.post_generation - def areas(self, create, extracted, **kwargs): + def areas(self, create: bool, extracted: List[Any], **kwargs: Any) -> None: if not create: return diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index e41f79aabf..909fc15fd1 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -23,10 +23,7 @@ from natural_keys import NaturalKeyModel from hct_mis_api.apps.account.fields import ChoiceArrayField -from hct_mis_api.apps.account.permissions import ( - DEFAULT_PERMISSIONS_LIST_FOR_IS_UNICEF_PARTNER, - Permissions, -) +from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.account.utils import test_conditional from hct_mis_api.apps.core.mixins import LimitBusinessAreaModelMixin from hct_mis_api.apps.core.models import BusinessArea @@ -98,10 +95,7 @@ def get_partners_for_program_as_choices(cls, business_area_id: str, program_id: role_assignments.filter(Q(program_id=program_id) | Q(program=None)) partners = cls.objects.filter(role_assignments__in=role_assignments).distinct() - return [ - (partner.id, partner.name) - for partner in partners - ] + return [(partner.id, partner.name) for partner in partners] @property def is_unicef(self) -> bool: @@ -117,22 +111,27 @@ def is_editable(self) -> bool: def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: from hct_mis_api.apps.program.models import Program + if self.role_assignments.filter(business_area_id=business_area_id, program=None).exists(): programs_ids = Program.objects.filter(business_area_id=business_area_id).values_list("id", flat=True) else: - programs_ids = self.role_assignments.filter(business_area_id=business_area_id).values_list("program_id", flat=True) - return [ - str(program_id) - for program_id in programs_ids - ] + programs_ids = self.role_assignments.filter(business_area_id=business_area_id).values_list( + "program_id", flat=True + ) + return [str(program_id) for program_id in programs_ids] def has_program_access(self, program_id: Union[str, UUID]) -> bool: from hct_mis_api.apps.program.models import Program - return RoleAssignment.objects.filter( - Q(partner=self) - & Q(business_area=Program.objects.get(id=program_id).business_area) - & (Q(program=None) | Q(program_id=program_id)) - ).exclude(expiry_date__lt=timezone.now()).exists() + + return ( + RoleAssignment.objects.filter( + Q(partner=self) + & Q(business_area=Program.objects.get(id=program_id).business_area) + & (Q(program=None) | Q(program_id=program_id)) + ) + .exclude(expiry_date__lt=timezone.now()) + .exists() + ) def has_area_access(self, area_id: Union[str, UUID], program_id: Union[str, UUID]) -> bool: return ( @@ -186,26 +185,31 @@ def save(self, *args: Any, **kwargs: Any) -> None: def has_program_access(self, program_id: Union[str, UUID]) -> bool: from hct_mis_api.apps.program.models import Program - return RoleAssignment.objects.filter( - Q(user=self) | Q(partner__user=self) - & Q(business_area=Program.objects.get(id=program_id).business_area) - & (Q(program=None) | Q(program_id=program_id)) - ).exclude(expiry_date__lt=timezone.now()).exists() + + return ( + RoleAssignment.objects.filter( + Q(user=self) + | Q(partner__user=self) + & Q(business_area=Program.objects.get(id=program_id).business_area) + & (Q(program=None) | Q(program_id=program_id)) + ) + .exclude(expiry_date__lt=timezone.now()) + .exists() + ) def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: from hct_mis_api.apps.program.models import Program + if RoleAssignment.objects.filter( Q(user=self) | Q(partner__user=self), business_area_id=business_area_id, program=None ).exists(): programs_ids = Program.objects.filter(business_area_id=business_area_id).values_list("id", flat=True) else: RoleAssignment.objects.filter( - Q(user=self) | Q(partner__user=self), business_area_id=business_area_id, + Q(user=self) | Q(partner__user=self), + business_area_id=business_area_id, ).values_list("program_id", flat=True) - return [ - str(program_id) - for program_id in programs_ids - ] + return [str(program_id) for program_id in programs_ids] def permissions_in_business_area(self, business_area_slug: str, program_id: Optional[UUID] = None) -> set: """ @@ -216,15 +220,15 @@ def permissions_in_business_area(self, business_area_slug: str, program_id: Opti if not self.has_program_access(program_id): return set() role_assignments = RoleAssignment.objects.filter( - Q(partner__user=self, business_area__slug=business_area_slug, program_id=program_id) | - Q(partner__user=self, business_area__slug=business_area_slug, program=None) | - Q(user=self, business_area__slug=business_area_slug, program_id=program_id) | - Q(user=self, business_area__slug=business_area_slug, program=None) + Q(partner__user=self, business_area__slug=business_area_slug, program_id=program_id) + | Q(partner__user=self, business_area__slug=business_area_slug, program=None) + | Q(user=self, business_area__slug=business_area_slug, program_id=program_id) + | Q(user=self, business_area__slug=business_area_slug, program=None) ).exclude(expiry_date__lt=timezone.now()) else: role_assignments = RoleAssignment.objects.filter( - Q(partner__user=self, business_area__slug=business_area_slug) | - Q(user=self, business_area__slug=business_area_slug) + Q(partner__user=self, business_area__slug=business_area_slug) + | Q(user=self, business_area__slug=business_area_slug) ).exclude(expiry_date__lt=timezone.now()) permissions_set = set() @@ -235,18 +239,22 @@ def permissions_in_business_area(self, business_area_slug: str, program_id: Opti permissions_set.update(f"{app}.{codename}" for app, codename in role_assignment_group_permissions) # permissions from role field in RoleAssignment - role_assignment_role_permissions = Role.objects.filter( - role_assignments__in=role_assignments - ).values_list("permissions", flat=True) - permissions_set.update(permission for permission_list in role_assignment_role_permissions for permission in permission_list) + role_assignment_role_permissions = Role.objects.filter(role_assignments__in=role_assignments).values_list( + "permissions", flat=True + ) + permissions_set.update( + permission for permission_list in role_assignment_role_permissions for permission in permission_list + ) return permissions_set @property def business_areas(self) -> QuerySet[BusinessArea]: - return BusinessArea.objects.filter( - Q(role_assignments__user=self) | Q(role_assignments__partner__user=self) - ).exclude(role_assignments__expiry_date__lt=timezone.now()).distinct() + return ( + BusinessArea.objects.filter(Q(role_assignments__user=self) | Q(role_assignments__partner__user=self)) + .exclude(role_assignments__expiry_date__lt=timezone.now()) + .distinct() + ) @test_conditional(lru_cache()) def cached_role_assignments(self) -> QuerySet["RoleAssignment"]: @@ -327,10 +335,18 @@ def formfield(self, form_class: Optional[Any] = ..., choices_form_class: Optiona class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): business_area = models.ForeignKey("core.BusinessArea", related_name="role_assignments", on_delete=models.CASCADE) - user = models.ForeignKey("account.User", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - partner = models.ForeignKey("account.Partner", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - role = models.ForeignKey("account.Role", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) - program = models.ForeignKey("program.Program", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True) + user = models.ForeignKey( + "account.User", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True + ) + partner = models.ForeignKey( + "account.Partner", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True + ) + role = models.ForeignKey( + "account.Role", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True + ) + program = models.ForeignKey( + "program.Program", related_name="role_assignments", on_delete=models.CASCADE, null=True, blank=True + ) expiry_date = models.DateField( blank=True, null=True, help_text="After expiry date this Role Assignment will be inactive." ) @@ -341,7 +357,7 @@ class Meta: # either user or partner should be assigned; not both models.CheckConstraint( check=Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False), - name="user_or_partner_not_both" + name="user_or_partner_not_both", ), ] @@ -371,6 +387,7 @@ class AdminAreaLimitedTo(TimeStampedUUIDModel): Model to limit the admin area access for a partner. Partners with full area access for a certain program will not have any area limits - no record in this model. """ + partner = models.ForeignKey("account.Partner", related_name="admin_area_limits", on_delete=models.CASCADE) program = models.ForeignKey("program.Program", related_name="admin_area_limits", on_delete=models.CASCADE) areas = models.ManyToManyField("geo.Area", related_name="admin_area_limits", blank=True) diff --git a/src/hct_mis_api/apps/account/schema.py b/src/hct_mis_api/apps/account/schema.py index 1798e36264..13cd881aac 100644 --- a/src/hct_mis_api/apps/account/schema.py +++ b/src/hct_mis_api/apps/account/schema.py @@ -19,8 +19,8 @@ USER_STATUS_CHOICES, Partner, Role, - User, RoleAssignment, + User, ) from hct_mis_api.apps.account.permissions import ( ALL_GRIEVANCES_CREATE_MODIFY, @@ -29,7 +29,7 @@ hopeOneOfPermissionClass, ) from hct_mis_api.apps.core.extended_connection import ExtendedConnection -from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.core.schema import ChoiceObject from hct_mis_api.apps.core.utils import decode_id_string, to_choice_object from hct_mis_api.apps.geo.models import Area diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index f2b8f83103..06d88e5e97 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -1,19 +1,18 @@ from typing import Any, Iterable from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.cache import cache from django.db.models import Q -from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save, post_delete +from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.dispatch import receiver from django.utils import timezone from hct_mis_api.api.caches import get_or_create_cache_key from hct_mis_api.apps.account.caches import get_user_permissions_version_key -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough -from django.contrib.auth.models import Group -from django.core.cache import cache - @receiver(post_save, sender=RoleAssignment) @receiver(pre_delete, sender=RoleAssignment) @@ -58,6 +57,7 @@ def allowed_business_areas_changed(sender: Any, instance: Partner, action: str, # Signals for permissions caches invalidation + def _invalidate_user_permissions_cache(users: Iterable) -> None: for user in users: version_key = get_user_permissions_version_key(user) @@ -67,7 +67,9 @@ def _invalidate_user_permissions_cache(users: Iterable) -> None: @receiver(post_save, sender=RoleAssignment) @receiver(pre_delete, sender=RoleAssignment) -def invalidate_permissions_cache_on_role_assignment_change(sender, instance, **kwargs): +def invalidate_permissions_cache_on_role_assignment_change( + sender: Any, instance: RoleAssignment, **kwargs: Any +) -> None: """ Invalidate the cache for the User/Partner's Users associated with the RoleAssignment when the RoleAssignment is created, updated, or deleted. @@ -81,17 +83,21 @@ def invalidate_permissions_cache_on_role_assignment_change(sender, instance, **k @receiver(post_save, sender=Role) @receiver(pre_delete, sender=Role) -def invalidate_permissions_cache_on_role_change(sender, instance, **kwargs): +def invalidate_permissions_cache_on_role_change(sender: Any, instance: Role, **kwargs: Any) -> None: """ Invalidate the cache for the User/Partner's Users associated with the Role through a RoleAssignment when the Role is created, updated, or deleted. """ - users = User.objects.filter(Q(role_assignments__role=instance) | Q(partner__role_assignments__role=instance)).distinct() + users = User.objects.filter( + Q(role_assignments__role=instance) | Q(partner__role_assignments__role=instance) + ).distinct() _invalidate_user_permissions_cache(users) @receiver(m2m_changed, sender=Group.permissions.through) -def invalidate_permissions_cache_on_group_permissions_change(sender, instance, action, **kwargs): +def invalidate_permissions_cache_on_group_permissions_change( + sender: Any, instance: Group, action: str, **kwargs: Any +) -> None: """ Invalidate the cache for all Users that are assigned to that Group or are assigned to this Group's RoleAssignment @@ -107,7 +113,7 @@ def invalidate_permissions_cache_on_group_permissions_change(sender, instance, a @receiver(post_save, sender=Group) @receiver(pre_delete, sender=Group) -def invalidate_permissions_cache_on_group_change(sender, instance, **kwargs): +def invalidate_permissions_cache_on_group_change(sender: Any, instance: Group, **kwargs: Any) -> None: """ Invalidate the cache for all Users that are assigned to that Group or are assigned to this Group's RoleAssignment @@ -121,7 +127,7 @@ def invalidate_permissions_cache_on_group_change(sender, instance, **kwargs): @receiver(m2m_changed, sender=User.groups.through) -def invalidate_permissions_cache_on_user_groups_change(action, instance, pk_set, **kwargs): +def invalidate_permissions_cache_on_user_groups_change(action: str, instance: User, pk_set: set, **kwargs: Any) -> None: """ Invalidate the cache for a User when their Groups are modified. """ @@ -131,7 +137,7 @@ def invalidate_permissions_cache_on_user_groups_change(action, instance, pk_set, @receiver(post_save, sender=User) @receiver(pre_delete, sender=User) -def invalidate_permissions_cache_on_user_change(sender, instance, **kwargs): +def invalidate_permissions_cache_on_user_change(sender: Any, instance: User, **kwargs: Any) -> None: """ Invalidate the cache for a User when they are updated. (For example change of partner or is_superuser flag) """ diff --git a/src/hct_mis_api/apps/accountability/schema.py b/src/hct_mis_api/apps/accountability/schema.py index c482ca77ff..1687a38f29 100644 --- a/src/hct_mis_api/apps/accountability/schema.py +++ b/src/hct_mis_api/apps/accountability/schema.py @@ -121,7 +121,7 @@ def resolve_all_feedbacks(self, info: Any, **kwargs: Any) -> QuerySet[Feedback]: queryset = Feedback.objects.filter(business_area__slug=business_area_slug).select_related("admin2") if user.partner.has_area_limits_in_program(program_id): - queryset = filter_feedback_based_on_partner_areas_2(queryset, user.partner, business_area_id, program_id) + queryset = filter_feedback_based_on_partner_areas_2(queryset, user, business_area_id, program_id) return queryset diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index 7f43878676..18032d3768 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -1,23 +1,24 @@ -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, Union from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.models import Permission, AnonymousUser +from django.contrib.auth.models import AnonymousUser, Permission from django.core.cache import cache -from django.db.models import Q, Model +from django.db.models import Model, Q from django.utils import timezone from hct_mis_api.api.caches import get_or_create_cache_key -from hct_mis_api.apps.account.caches import get_user_permissions_version_key, get_user_permissions_cache_key -from hct_mis_api.apps.account.models import RoleAssignment, User, Role +from hct_mis_api.apps.account.caches import ( + get_user_permissions_cache_key, + get_user_permissions_version_key, +) +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.models import Program -if TYPE_CHECKING: - from hct_mis_api.apps.account.models import User - class PermissionsBackend(BaseBackend): - def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str]: + def get_all_permissions(self, user: User, obj: Optional[Model] = None) -> set[str]: # type: ignore + filters: dict[str, Any] if not obj: program = None business_area = None @@ -50,18 +51,19 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str cached_permissions = cache.get(cache_key) - print(cached_permissions) - print(user_version) - if cached_permissions: return cached_permissions # If permission is checked for a Program and User does not have access to it, return empty set - if program and not RoleAssignment.objects.filter( - Q(partner=user.partner) | Q(user=user) - & Q(business_area=business_area) - & (Q(program=None) | Q(program=program)) - ).exclude(expiry_date__lt=timezone.now()).exists(): + if ( + program + and not RoleAssignment.objects.filter( + Q(partner=user.partner) + | Q(user=user) & Q(business_area=business_area) & (Q(program=None) | Q(program=program)) + ) + .exclude(expiry_date__lt=timezone.now()) + .exists() + ): return set() """ @@ -74,7 +76,7 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str # role assignments from the User or their Partner role_assignments = RoleAssignment.objects.filter( (Q(user=user) | Q(partner__user=user)) - & (Q(business_area=filters.get('business_area'), program=None) | Q(**filters)) + & (Q(business_area=filters.get("business_area"), program=None) | Q(**filters)) ).exclude(expiry_date__lt=timezone.now()) if business_area and not role_assignments.exists(): @@ -89,25 +91,24 @@ def get_all_permissions(self, user: "User", obj: "Model|None" = None) -> set[str permissions_set.update(f"{app}.{codename}" for app, codename in role_assignment_group_permissions) # permissions from RoleAssignment's Roles - role_assignment_role_permissions = Role.objects.filter( - role_assignments__in=role_assignments - ).values_list("permissions", flat=True) - permissions_set.update(permission for permission_list in role_assignment_role_permissions for permission in permission_list) + role_assignment_role_permissions = Role.objects.filter(role_assignments__in=role_assignments).values_list( + "permissions", flat=True + ) + permissions_set.update( + permission for permission_list in role_assignment_role_permissions for permission in permission_list + ) # permissions from the User's Group - user_group_permissions = Permission.objects.filter( - group__user=user - ).values_list("content_type__app_label", "codename") + user_group_permissions = Permission.objects.filter(group__user=user).values_list( + "content_type__app_label", "codename" + ) permissions_set.update(f"{app}.{codename}" for app, codename in user_group_permissions) cache.set(cache_key, permissions_set, timeout=None) - print("sd") - print(permissions_set) - return permissions_set - def has_perm(self, user_obj: "User|AnonymousUser", perm: str, obj: Optional[Model] = None) -> bool: + def has_perm(self, user_obj: Union[User, AnonymousUser], perm: str, obj: Optional[Model] = None) -> bool: # type: ignore print("sd original") print(perm) if user_obj.is_superuser: diff --git a/src/hct_mis_api/apps/core/management/commands/addunicefusers.py b/src/hct_mis_api/apps/core/management/commands/addunicefusers.py index 2880983f06..917af760a9 100644 --- a/src/hct_mis_api/apps/core/management/commands/addunicefusers.py +++ b/src/hct_mis_api/apps/core/management/commands/addunicefusers.py @@ -2,7 +2,7 @@ from django.core.management import BaseCommand -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea emails = [ diff --git a/src/hct_mis_api/apps/core/management/commands/initcypress.py b/src/hct_mis_api/apps/core/management/commands/initcypress.py index 7227ae8a69..c7c7ffe09c 100644 --- a/src/hct_mis_api/apps/core/management/commands/initcypress.py +++ b/src/hct_mis_api/apps/core/management/commands/initcypress.py @@ -4,7 +4,7 @@ from django.conf import settings from django.core.management import BaseCommand, call_command -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.core.management.commands.reset_business_area_sequences import ( reset_business_area_sequences, ) diff --git a/src/hct_mis_api/apps/core/management/commands/initdemo.py b/src/hct_mis_api/apps/core/management/commands/initdemo.py index cb3f2c516d..e7f9273b0c 100644 --- a/src/hct_mis_api/apps/core/management/commands/initdemo.py +++ b/src/hct_mis_api/apps/core/management/commands/initdemo.py @@ -59,7 +59,8 @@ from django.utils import timezone import elasticsearch -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment + +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.payment.fixtures import ( generate_delivery_mechanisms, diff --git a/src/hct_mis_api/apps/core/models.py b/src/hct_mis_api/apps/core/models.py index 205209a10c..567c8daebd 100644 --- a/src/hct_mis_api/apps/core/models.py +++ b/src/hct_mis_api/apps/core/models.py @@ -25,7 +25,7 @@ from mptt.fields import TreeForeignKey -class BusinessAreaPartnerThrough(TimeStampedUUIDModel): # TODO: remove after migration to RoleAssignment +class BusinessAreaPartnerThrough(TimeStampedUUIDModel): # TODO: remove after migration to RoleAssignment business_area = models.ForeignKey( "BusinessArea", on_delete=models.CASCADE, diff --git a/src/hct_mis_api/apps/core/utils.py b/src/hct_mis_api/apps/core/utils.py index 0a495842fb..bfc25a58d8 100644 --- a/src/hct_mis_api/apps/core/utils.py +++ b/src/hct_mis_api/apps/core/utils.py @@ -36,6 +36,8 @@ from django_filters import OrderingFilter from PIL import Image +from hct_mis_api.apps.core.models import BusinessArea +from hct_mis_api.apps.program.models import Program from hct_mis_api.apps.utils.exceptions import log_and_raise if TYPE_CHECKING: @@ -90,8 +92,9 @@ def get_program_id_from_headers(headers: Union[Dict, "HttpHeaders"]) -> Optional program_id = decode_id_string(program_id) if program_id != "all" and program_id != "undefined" else None return program_id + # TODO: change -def get_selected_program(request) -> "Program | None": +def get_selected_program(request: Any) -> Optional[Program]: if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request program = request.data.get("variables", {}).get("program") elif isinstance(request.GET, dict): # REST API Request @@ -99,7 +102,7 @@ def get_selected_program(request) -> "Program | None": return program -def get_selected_business_area(request) -> "BusinessArea | None": +def get_selected_business_area(request: Any) -> Optional[BusinessArea]: if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request program = request.data.get("variables", {}).get("business_area") elif isinstance(request.GET, dict): # REST API Request diff --git a/src/hct_mis_api/apps/grievance/documents.py b/src/hct_mis_api/apps/grievance/documents.py index 9b8d29b4be..126085b940 100644 --- a/src/hct_mis_api/apps/grievance/documents.py +++ b/src/hct_mis_api/apps/grievance/documents.py @@ -6,8 +6,8 @@ from django_elasticsearch_dsl import Document, fields from django_elasticsearch_dsl.registries import registry - from elasticsearch import Elasticsearch + from hct_mis_api.apps.account.models import User from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.models import Area diff --git a/src/hct_mis_api/apps/grievance/notifications.py b/src/hct_mis_api/apps/grievance/notifications.py index 4366f16f2e..a99b6afae7 100644 --- a/src/hct_mis_api/apps/grievance/notifications.py +++ b/src/hct_mis_api/apps/grievance/notifications.py @@ -8,7 +8,7 @@ from constance import config -from hct_mis_api.apps.account.models import User, RoleAssignment +from hct_mis_api.apps.account.models import RoleAssignment, User from hct_mis_api.apps.core.utils import encode_id_base64 from hct_mis_api.apps.grievance.models import GrievanceTicket from hct_mis_api.apps.utils.mailjet import MailjetClient diff --git a/src/hct_mis_api/apps/grievance/schema.py b/src/hct_mis_api/apps/grievance/schema.py index 063221e9df..756b2efe63 100644 --- a/src/hct_mis_api/apps/grievance/schema.py +++ b/src/hct_mis_api/apps/grievance/schema.py @@ -575,10 +575,12 @@ def resolve_all_grievance_ticket(self, info: Any, **kwargs: Any) -> QuerySet: queryset = queryset.prefetch_related(*to_prefetch) # Ignore filtering for Cross Area tickets - if not (kwargs.get("is_cross_area", False) and program_id and not user.partner.has_area_limits_in_program(program_id)): - queryset = filter_grievance_tickets_based_on_partner_areas_2( - queryset, user, business_area_id, program_id - ) + if not ( + kwargs.get("is_cross_area", False) + and program_id + and not user.partner.has_area_limits_in_program(program_id) + ): + queryset = filter_grievance_tickets_based_on_partner_areas_2(queryset, user, business_area_id, program_id) if program_id is None: queryset = queryset | ( diff --git a/src/hct_mis_api/apps/grievance/utils.py b/src/hct_mis_api/apps/grievance/utils.py index 7ac7aa6f19..0782408ebe 100644 --- a/src/hct_mis_api/apps/grievance/utils.py +++ b/src/hct_mis_api/apps/grievance/utils.py @@ -141,13 +141,13 @@ def filter_grievance_tickets_based_on_partner_areas_2( def filter_feedback_based_on_partner_areas_2( queryset: QuerySet["Feedback"], - user_partner: Partner, + user: User, business_area_id: str, program_id: Optional[str], ) -> QuerySet["Feedback"]: return filter_based_on_partner_areas_2( queryset=queryset, - user_partner=user_partner, + user=user, business_area_id=business_area_id, program_id=program_id, lookup_id="program__id__in", diff --git a/src/hct_mis_api/apps/household/schema.py b/src/hct_mis_api/apps/household/schema.py index 1534cc6abc..2b758abbe5 100644 --- a/src/hct_mis_api/apps/household/schema.py +++ b/src/hct_mis_api/apps/household/schema.py @@ -367,7 +367,6 @@ def check_node_permission(cls, info: Any, object_instance: Individual) -> None: if not area_limits.filter(id__in=areas_from_household).exists(): raise PermissionDenied("Permission Denied") - # if user can't simply view all individuals, we check if they can do it because of grievance or rdi details if not user.has_permission( Permissions.POPULATION_VIEW_INDIVIDUALS_DETAILS.value, @@ -536,7 +535,11 @@ def check_node_permission(cls, info: Any, object_instance: Household) -> None: # check if user has access to the area area_limits = user.partner.get_area_limits_for_program(program_id) if area_limits.exists(): - areas_from_household = [object_instance.admin1_id, object_instance.admin2_id, object_instance.admin3_id] + areas_from_household = [ + object_instance.admin1_id, + object_instance.admin2_id, + object_instance.admin3_id, + ] if not area_limits.filter(id__in=areas_from_household).exists(): raise PermissionDenied("Permission Denied") @@ -737,11 +740,15 @@ def resolve_all_individuals(self, info: Any, **kwargs: Any) -> QuerySet[Individu areas_null_and_program_q = program_q & Q(household__admin_area__isnull=True) # apply admin area limits if partner has restrictions area_limits = user.partner.get_area_limits_for_program(program_id) - areas_query = Q( - Q(household__admin1__in=area_limits) - | Q(household__admin2__in=area_limits) - | Q(household__admin3__in=area_limits) - ) if area_limits.exists() else Q() + areas_query = ( + Q( + Q(household__admin1__in=area_limits) + | Q(household__admin2__in=area_limits) + | Q(household__admin3__in=area_limits) + ) + if area_limits.exists() + else Q() + ) filter_q |= Q(areas_null_and_program_q | Q(program_q & areas_query)) @@ -787,11 +794,11 @@ def resolve_all_households(self, info: Any, **kwargs: Any) -> QuerySet: areas_null_and_program_q = program_q & Q(admin_area__isnull=True) # apply admin area limits if partner has restrictions area_limits = user.partner.get_area_limits_for_program(program_id) - areas_query = Q( - Q(admin1__in=area_limits) - | Q(admin2__in=area_limits) - | Q(admin3__in=area_limits) - ) if area_limits.exists() else Q() + areas_query = ( + Q(Q(admin1__in=area_limits) | Q(admin2__in=area_limits) | Q(admin3__in=area_limits)) + if area_limits.exists() + else Q() + ) filter_q |= Q(areas_null_and_program_q | Q(program_q & areas_query)) diff --git a/src/hct_mis_api/apps/payment/notifications.py b/src/hct_mis_api/apps/payment/notifications.py index 5a2038ac8c..30e97c8a07 100644 --- a/src/hct_mis_api/apps/payment/notifications.py +++ b/src/hct_mis_api/apps/payment/notifications.py @@ -7,7 +7,7 @@ from constance import config -from hct_mis_api.apps.account.models import Partner, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, RoleAssignment, User from hct_mis_api.apps.account.permissions import ( DEFAULT_PERMISSIONS_LIST_FOR_IS_UNICEF_PARTNER, Permissions, diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index 637cf65063..28e027daa2 100644 --- a/tests/selenium/conftest.py +++ b/tests/selenium/conftest.py @@ -18,7 +18,7 @@ from selenium.webdriver.chrome.options import Options from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.models import ( BusinessArea, diff --git a/tests/unit/apps/account/test_celery_tasks.py b/tests/unit/apps/account/test_celery_tasks.py index 19e322ec06..708a7c0f1c 100644 --- a/tests/unit/apps/account/test_celery_tasks.py +++ b/tests/unit/apps/account/test_celery_tasks.py @@ -1,15 +1,25 @@ from datetime import timedelta +from typing import Any from unittest.mock import patch + from django.core.cache import cache from django.utils import timezone + import pytest + from hct_mis_api.apps.account.caches import get_user_permissions_version_key -from hct_mis_api.apps.account.celery_tasks import invalidate_permissions_cache_for_user_if_expired_role -from hct_mis_api.apps.account.fixtures import BusinessAreaFactory, RoleAssignmentFactory, PartnerFactory, UserFactory, \ - RoleFactory +from hct_mis_api.apps.account.celery_tasks import ( + invalidate_permissions_cache_for_user_if_expired_role, +) +from hct_mis_api.apps.account.fixtures import ( + BusinessAreaFactory, + PartnerFactory, + RoleAssignmentFactory, + RoleFactory, + UserFactory, +) from hct_mis_api.apps.account.models import User from hct_mis_api.apps.account.signals import _invalidate_user_permissions_cache - from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -53,7 +63,7 @@ def set_up(self, afghanistan: BusinessAreaFactory) -> None: self.version_key_user2_before = self._get_cache_version(self.user2) @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") - def test_invalidate_permissions_cache_role_on_user(self, mock_invalidate_cache) -> None: + def test_invalidate_permissions_cache_role_on_user(self, mock_invalidate_cache: Any) -> None: mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache invalidate_permissions_cache_for_user_if_expired_role() mock_invalidate_cache.assert_called_once() @@ -78,7 +88,7 @@ def test_invalidate_permissions_cache_role_on_user(self, mock_invalidate_cache) assert self._get_cache_version(self.user2) == self.version_key_user2_before @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") - def test_invalidate_permissions_cache_role_on_users(self, mock_invalidate_cache) -> None: + def test_invalidate_permissions_cache_role_on_users(self, mock_invalidate_cache: Any) -> None: mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache self.role_assignment_user1.expiry_date = (timezone.now() - timedelta(days=1)).date() self.role_assignment_user1.save() @@ -100,7 +110,7 @@ def test_invalidate_permissions_cache_role_on_users(self, mock_invalidate_cache) assert self._get_cache_version(self.user2) == self.version_key_user2_before + 1 @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") - def test_invalidate_permissions_cache_role_on_partner(self, mock_invalidate_cache) -> None: + def test_invalidate_permissions_cache_role_on_partner(self, mock_invalidate_cache: Any) -> None: mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache self.role_assignment_partner.expiry_date = (timezone.now() - timedelta(days=1)).date() self.role_assignment_partner.save() @@ -118,7 +128,7 @@ def test_invalidate_permissions_cache_role_on_partner(self, mock_invalidate_cach assert self._get_cache_version(self.user2) == self.version_key_user2_before @patch("hct_mis_api.apps.account.celery_tasks._invalidate_user_permissions_cache") - def test_invalidate_permissions_cache_role_on_users_and_partner(self, mock_invalidate_cache) -> None: + def test_invalidate_permissions_cache_role_on_users_and_partner(self, mock_invalidate_cache: Any) -> None: mock_invalidate_cache.side_effect = _invalidate_user_permissions_cache self.role_assignment_partner.expiry_date = (timezone.now() - timedelta(days=1)).date() self.role_assignment_partner.save() diff --git a/tests/unit/apps/account/test_check_permissions.py b/tests/unit/apps/account/test_check_permissions.py index abe335404d..d74eca7620 100644 --- a/tests/unit/apps/account/test_check_permissions.py +++ b/tests/unit/apps/account/test_check_permissions.py @@ -2,7 +2,7 @@ from django.test import TestCase from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, UserFactory -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.account.permissions import Permissions, check_permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py index 8775f9b04c..241a265a9f 100644 --- a/tests/unit/apps/account/test_models.py +++ b/tests/unit/apps/account/test_models.py @@ -1,7 +1,12 @@ from django.core.exceptions import ValidationError from django.test import TransactionTestCase -from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory, PartnerFactory +from hct_mis_api.apps.account.fixtures import ( + PartnerFactory, + RoleAssignmentFactory, + RoleFactory, + UserFactory, +) from hct_mis_api.apps.account.models import RoleAssignment from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.program.fixtures import ProgramFactory @@ -47,7 +52,7 @@ def test_user_or_partner(self) -> None: business_area=self.business_area, ) self.assertIn( - 'Either user or partner must be set, but not both.', + "Either user or partner must be set, but not both.", str(ve_context.exception), ) @@ -62,7 +67,7 @@ def test_user_or_partner_not_both(self) -> None: program=self.program1, ) self.assertIn( - 'Either user or partner must be set, but not both.', + "Either user or partner must be set, but not both.", str(ve_context.exception), ) @@ -87,7 +92,7 @@ def test_is_available_for_partner_flag(self) -> None: business_area=self.business_area, ) self.assertIn( - 'Partner can only be assigned roles that are available for partners.', + "Partner can only be assigned roles that are available for partners.", str(ve_context.exception), ) diff --git a/tests/unit/apps/account/test_partner_permissions.py b/tests/unit/apps/account/test_partner_permissions.py index 4914796e95..54bfe99c33 100644 --- a/tests/unit/apps/account/test_partner_permissions.py +++ b/tests/unit/apps/account/test_partner_permissions.py @@ -1,13 +1,17 @@ from django.test import TestCase -from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory, AdminAreaLimitedToFactory -from hct_mis_api.apps.account.models import Role, User, RoleAssignment, AdminAreaLimitedTo +from hct_mis_api.apps.account.fixtures import ( + AdminAreaLimitedToFactory, + PartnerFactory, + UserFactory, +) +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.core.fixtures import create_afghanistan -from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.fixtures import AreaFactory from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.fixtures import ProgramFactory -from hct_mis_api.apps.program.models import Program, ProgramPartnerThrough +from hct_mis_api.apps.program.models import Program class UserPartnerTest(TestCase): @@ -147,28 +151,18 @@ def test_partner_has_permission(self) -> None: self.assertTrue(user_with_partner_role) # check user_roles and partner_roles with program_id - user_with_partner_role_and_program_access = User.has_perm( - self.other_user, "PROGRAMME_CREATE", self.program - ) + user_with_partner_role_and_program_access = User.has_perm(self.other_user, "PROGRAMME_CREATE", self.program) self.assertTrue(user_with_partner_role_and_program_access) - user_with_partner_role_and_program_access = User.has_perm( - self.other_user, "PROGRAMME_FINISH", self.program - ) + user_with_partner_role_and_program_access = User.has_perm(self.other_user, "PROGRAMME_FINISH", self.program) self.assertTrue(user_with_partner_role_and_program_access) # check perms wrong program_id - user_without_access = User.has_perm( - self.other_user, "PROGRAMME_FINISH", self.business_area - ) + user_without_access = User.has_perm(self.other_user, "PROGRAMME_FINISH", self.business_area) self.assertFalse(user_without_access) # check with program_id user partner is_unicef - unicef_user_without_perms = User.has_perm( - self.unicef_user, "PROGRAMME_FINISH", self.program - ) + unicef_user_without_perms = User.has_perm(self.unicef_user, "PROGRAMME_FINISH", self.program) self.assertFalse(unicef_user_without_perms) - unicef_user_with_perms = User.has_perm( - self.unicef_user, "PROGRAMME_CREATE", self.program - ) + unicef_user_with_perms = User.has_perm(self.unicef_user, "PROGRAMME_CREATE", self.program) self.assertTrue(unicef_user_with_perms) diff --git a/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py index 6c48f4e836..31fc9aeaa6 100644 --- a/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py +++ b/tests/unit/apps/account/test_signals_for_invalidate_permission_caches.py @@ -1,15 +1,19 @@ from datetime import timedelta -from django.utils import timezone -from django.contrib.auth.models import Permission, Group -from django.test import TestCase +from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType - from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone from hct_mis_api.apps.account.caches import get_user_permissions_version_key -from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory, RoleAssignmentFactory, UserFactory -from hct_mis_api.apps.account.models import User, RoleAssignment +from hct_mis_api.apps.account.fixtures import ( + PartnerFactory, + RoleAssignmentFactory, + RoleFactory, + UserFactory, +) +from hct_mis_api.apps.account.models import User from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.fixtures import ProgramFactory @@ -58,7 +62,9 @@ def setUp(self) -> None: # group on a user self.group1 = Group.objects.create(name="Test Group") self.content_type = ContentType.objects.get_for_model(BusinessArea) - permission = Permission.objects.create(codename="test_permission", name="Test Permission", content_type=self.content_type) + permission = Permission.objects.create( + codename="test_permission", name="Test Permission", content_type=self.content_type + ) self.group1.permissions.add(permission) self.user1_partner1.groups.add(self.group1) @@ -117,7 +123,9 @@ def test_invalidate_cache_on_role_change_for_partner(self) -> None: self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) def test_invalidate_cache_on_group_permissions_change_for_user(self) -> None: - permission = Permission.objects.create(codename="test_permission_new_1", name="Test Permission 2", content_type=self.content_type) + permission = Permission.objects.create( + codename="test_permission_new_1", name="Test Permission 2", content_type=self.content_type + ) self.group1.permissions.add(permission) # users connected with the group should have their cache invalidated @@ -140,7 +148,9 @@ def test_invalidate_cache_on_group_permissions_change_for_user(self) -> None: self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) def test_invalidate_cache_on_group_permissions_change_for_user_role_assignment(self) -> None: - permission = Permission.objects.create(codename="test_permission_new_2", name="Test Permission 2", content_type=self.content_type) + permission = Permission.objects.create( + codename="test_permission_new_2", name="Test Permission 2", content_type=self.content_type + ) self.group2.permissions.add(permission) # users with role_assignments connected with the group should have their cache invalidated @@ -163,8 +173,9 @@ def test_invalidate_cache_on_group_permissions_change_for_user_role_assignment(s self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) def test_invalidate_cache_on_group_permissions_change_for_partner_role_assignment(self) -> None: - permission = Permission.objects.create(codename="test_permission_new_3", name="Test Permission 2", - content_type=self.content_type) + permission = Permission.objects.create( + codename="test_permission_new_3", name="Test Permission 2", content_type=self.content_type + ) self.group3.permissions.add(permission) # users with partner with role_assignments connected with the group should have their cache invalidated @@ -196,7 +207,7 @@ def test_invalidate_cache_on_group_change_for_user(self) -> None: self.assertEqual(self._get_cache_version(self.user1_partner2), self.version_key_user1_partner2_before) self.assertEqual(self._get_cache_version(self.user2_partner2), self.version_key_user2_partner2_before) - def test_invalidate_cache_on_group_delete_for_user(self): + def test_invalidate_cache_on_group_delete_for_user(self) -> None: self.group1.delete() # users connected with the group should have their cache invalidated @@ -239,11 +250,9 @@ def test_invalidate_cache_on_role_assignment_change_for_user(self) -> None: self.role_assignment1.group = self.group3 self.role_assignment1.save() - self.role_assignment1.role = self.role2 self.role_assignment1.save() - # users connected to the role_assignment should have their cache invalidated # +6: 3 signals on RoleAssignment and 3 signals on User to update modify_date because of role_assignment change self.assertEqual(self._get_cache_version(self.user1_partner1), self.version_key_user1_partner1_before + 6) diff --git a/tests/unit/apps/account/test_user_filters.py b/tests/unit/apps/account/test_user_filters.py index 8eee9cdfd5..8b0590abcf 100644 --- a/tests/unit/apps/account/test_user_filters.py +++ b/tests/unit/apps/account/test_user_filters.py @@ -1,8 +1,8 @@ from hct_mis_api.apps.account.fixtures import ( PartnerFactory, + RoleAssignmentFactory, RoleFactory, UserFactory, - RoleAssignmentFactory, ) from hct_mis_api.apps.account.models import User from hct_mis_api.apps.account.permissions import Permissions diff --git a/tests/unit/apps/account/test_user_import_csv.py b/tests/unit/apps/account/test_user_import_csv.py index d0ba4c2235..59d491aeda 100644 --- a/tests/unit/apps/account/test_user_import_csv.py +++ b/tests/unit/apps/account/test_user_import_csv.py @@ -10,9 +10,9 @@ from hct_mis_api.apps.account.admin.mixins import get_valid_kobo_username from hct_mis_api.apps.account.fixtures import ( PartnerFactory, + RoleAssignmentFactory, RoleFactory, UserFactory, - RoleAssignmentFactory, ) from hct_mis_api.apps.account.models import IncompatibleRoles, Role, User from hct_mis_api.apps.core.fixtures import create_afghanistan diff --git a/tests/unit/apps/account/test_user_roles.py b/tests/unit/apps/account/test_user_roles.py index 2dc82b3955..6e854f9023 100644 --- a/tests/unit/apps/account/test_user_roles.py +++ b/tests/unit/apps/account/test_user_roles.py @@ -9,7 +9,12 @@ RoleAssignmentInlineFormSet, ) from hct_mis_api.apps.account.fixtures import PartnerFactory, UserFactory -from hct_mis_api.apps.account.models import IncompatibleRoles, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import ( + IncompatibleRoles, + Role, + RoleAssignment, + User, +) from hct_mis_api.apps.account.permissions import ( DEFAULT_PERMISSIONS_IS_UNICEF_PARTNER, Permissions, @@ -37,7 +42,9 @@ def test_user_can_be_assigned_role(self) -> None: def test_user_cannot_be_assigned_incompatible_role_in_same_business_area(self) -> None: IncompatibleRoles.objects.create(role_one=self.role_1, role_two=self.role_2) - user_role = RoleAssignment.objects.create(role=self.role_1, business_area=self.business_area_afg, user=self.user) + user_role = RoleAssignment.objects.create( + role=self.role_1, business_area=self.business_area_afg, user=self.user + ) data = {"role": self.role_2.id, "user": self.user.id, "business_area": self.business_area_afg.id} form = RoleAssignmentAdminForm(data=data) @@ -63,7 +70,9 @@ def test_assign_multiple_roles_for_user_at_the_same_time(self) -> None: "user_roles-0-business_area": self.business_area_afg.id, "user_roles-1-business_area": self.business_area_afg.id, } - RoleAssignmentFormSet = inlineformset_factory(User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet) + RoleAssignmentFormSet = inlineformset_factory( + User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet + ) formset = RoleAssignmentFormSet(instance=self.user, data=data) self.assertTrue(formset.is_valid()) @@ -78,7 +87,9 @@ def test_assign_multiple_roles_for_user_at_the_same_time_fails_for_incompatible_ "user_roles-0-business_area": self.business_area_afg.id, "user_roles-1-business_area": self.business_area_afg.id, } - RoleAssignmentFormSet = inlineformset_factory(User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet) + RoleAssignmentFormSet = inlineformset_factory( + User, RoleAssignment, fields=("__all__"), formset=RoleAssignmentInlineFormSet + ) formset = RoleAssignmentFormSet(instance=self.user, data=data) self.assertFalse(formset.is_valid()) self.assertEqual(len(formset.errors), 2) diff --git a/tests/unit/apps/administration/test_admin.py b/tests/unit/apps/administration/test_admin.py index e7c396b0be..c5f9065aa8 100644 --- a/tests/unit/apps/administration/test_admin.py +++ b/tests/unit/apps/administration/test_admin.py @@ -10,7 +10,7 @@ from parameterized import parameterized from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea diff --git a/tests/unit/apps/core/test_backends.py b/tests/unit/apps/core/test_backends.py index e7641389a5..e2b0b8a71b 100644 --- a/tests/unit/apps/core/test_backends.py +++ b/tests/unit/apps/core/test_backends.py @@ -1,25 +1,33 @@ +from typing import Any +from unittest.mock import patch + +from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from django.contrib.auth.models import Group, Permission -from unittest.mock import patch +from django.utils import timezone -from hct_mis_api.apps.account.fixtures import UserFactory, PartnerFactory, RoleFactory, RoleAssignmentFactory +from hct_mis_api.apps.account.fixtures import ( + PartnerFactory, + RoleAssignmentFactory, + RoleFactory, + UserFactory, +) +from hct_mis_api.apps.core.backends import PermissionsBackend from hct_mis_api.apps.core.fixtures import create_afghanistan, create_ukraine from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program -from hct_mis_api.apps.core.backends import PermissionsBackend -from django.utils import timezone class TestPermissionsBackend(TestCase): - def setUp(self): + def setUp(self) -> None: self.partner = PartnerFactory(name="TestPartner") self.user = UserFactory(partner=self.partner) self.content_type = ContentType.objects.get_for_model(BusinessArea) - self.permission = Permission.objects.create(codename="test_permission", name="Test Permission", - content_type=self.content_type) + self.permission = Permission.objects.create( + codename="test_permission", name="Test Permission", content_type=self.content_type + ) self.group = Group.objects.create(name="TestGroup") self.group.permissions.add(self.permission) @@ -39,7 +47,7 @@ def setUp(self): self.backend = PermissionsBackend() - def test_get_all_permissions_with_cache(self): + def test_get_all_permissions_with_cache(self) -> None: self.group.permissions.add(self.permission) self.user.groups.add(self.group) @@ -51,14 +59,14 @@ def test_get_all_permissions_with_cache(self): self.assertEqual(permissions, cached_permissions) @patch("hct_mis_api.apps.core.backends.cache.get") - def test_cache_get(self, mock_cache_get): + def test_cache_get(self, mock_cache_get: Any) -> None: mock_cache_get.return_value = {self._get_permission_name_combined(self.permission)} permissions = self.backend.get_all_permissions(self.user, self.business_area) mock_cache_get.assert_called() assert mock_cache_get.call_count == 2 self.assertIn(self._get_permission_name_combined(self.permission), permissions) - def test_user_group_permissions(self): + def test_user_group_permissions(self) -> None: self.group.permissions.add(self.permission) self.user.groups.add(self.group) @@ -66,7 +74,7 @@ def test_user_group_permissions(self): self.assertIn(self._get_permission_name_combined(self.permission), permissions) - def test_role_assignment_user_role_permissions(self): + def test_role_assignment_user_role_permissions(self) -> None: role = RoleFactory(name="Role for User", permissions=["PROGRAMME_CREATE", "PROGRAMME_UPDATE"]) self.role_assignment_user.role = role @@ -77,7 +85,7 @@ def test_role_assignment_user_role_permissions(self): self.assertIn("PROGRAMME_CREATE", permissions) self.assertIn("PROGRAMME_UPDATE", permissions) - def test_role_assignment_user_group_permissions(self): + def test_role_assignment_user_group_permissions(self) -> None: self.role_assignment_user.group = self.group self.role_assignment_user.save() @@ -85,7 +93,7 @@ def test_role_assignment_user_group_permissions(self): self.assertIn(self._get_permission_name_combined(self.permission), permissions) - def test_role_assignment_partner_role_permissions(self): + def test_role_assignment_partner_role_permissions(self) -> None: role = RoleFactory(name="Role for Partner", permissions=["PROGRAMME_FINISH"]) self.role_assignment_partner.role = role self.role_assignment_partner.save() @@ -93,8 +101,8 @@ def test_role_assignment_partner_role_permissions(self): permissions = self.backend.get_all_permissions(self.user, self.business_area) self.assertIn("PROGRAMME_FINISH", permissions) - - def test_role_assignment_partner_group_permissions(self): + + def test_role_assignment_partner_group_permissions(self) -> None: self.role_assignment_partner.group = self.group self.role_assignment_partner.save() @@ -102,14 +110,14 @@ def test_role_assignment_partner_group_permissions(self): self.assertIn(self._get_permission_name_combined(self.permission), permissions) - def test_has_perm_for_superuser(self): + def test_has_perm_for_superuser(self) -> None: self.user.is_superuser = True self.user.save() self.assertTrue(self.backend.has_perm(self.user, f"{self.content_type.app_label}.{self.permission.codename}")) self.assertTrue(self.backend.has_perm(self.user, "PROGRAMME_FINISH", self.program)) - def test_role_expired(self): + def test_role_expired(self) -> None: self.role_assignment_user.group = self.group self.role_assignment_user.save() @@ -122,9 +130,11 @@ def test_role_expired(self): permissions = self.backend.get_all_permissions(self.user, self.business_area) self.assertNotIn(self._get_permission_name_combined(self.permission), permissions) - def test_get_permissions_for_program(self): + def test_get_permissions_for_program(self) -> None: # User's Role Assignmenmts grants - program_empty = ProgramFactory(status=Program.ACTIVE, name="Test Program Empty", business_area=self.business_area) + program_empty = ProgramFactory( + status=Program.ACTIVE, name="Test Program Empty", business_area=self.business_area + ) role = RoleFactory(name="Role for Partner", permissions=["PROGRAMME_FINISH"]) self.role_assignment_partner.role = role self.role_assignment_partner.program = self.program @@ -141,7 +151,9 @@ def test_get_permissions_for_program(self): self.assertIn("PROGRAMME_FINISH", permissions_in_program) # no permissions for other program - program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) + program_other = ProgramFactory( + status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area + ) permissions_in_program_other = self.backend.get_all_permissions(self.user, program_other) self.assertEqual(set(), permissions_in_program_other) @@ -193,14 +205,19 @@ def test_get_permissions_for_program(self): self.assertIn("PROGRAMME_CREATE", permissions_in_program_other) self.assertIn("PROGRAMME_UPDATE", permissions_in_program_other) - def test_get_permissions_from_all_sources(self): - program_for_user = ProgramFactory(status=Program.ACTIVE, name="Test Program For User", business_area=self.business_area) - permission1 = Permission.objects.create(codename="test_permission1", name="Test Permission 1", - content_type=self.content_type) - permission2 = Permission.objects.create(codename="test_permission2", name="Test Permission 2", - content_type=self.content_type) - permission3 = Permission.objects.create(codename="test_permission3", name="Test Permission 3", - content_type=self.content_type) + def test_get_permissions_from_all_sources(self) -> None: + program_for_user = ProgramFactory( + status=Program.ACTIVE, name="Test Program For User", business_area=self.business_area + ) + permission1 = Permission.objects.create( + codename="test_permission1", name="Test Permission 1", content_type=self.content_type + ) + permission2 = Permission.objects.create( + codename="test_permission2", name="Test Permission 2", content_type=self.content_type + ) + permission3 = Permission.objects.create( + codename="test_permission3", name="Test Permission 3", content_type=self.content_type + ) # permission on a user group group_user = Group.objects.create(name="TestGroupUser") group_user.permissions.add(permission1) @@ -265,9 +282,11 @@ def test_get_permissions_from_all_sources(self): self.assertNotIn("PROGRAMME_FINISH", permissions) # permissions for other program - empty (neither partner nor user has access to this program) - program_other = ProgramFactory(status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area) + program_other = ProgramFactory( + status=Program.ACTIVE, name="Test Program Other", business_area=self.business_area + ) permissions = self.backend.get_all_permissions(self.user, program_other) self.assertEqual(set(), permissions) - def _get_permission_name_combined(self, permission): - return f"{self.content_type.app_label}.{permission.codename}" \ No newline at end of file + def _get_permission_name_combined(self, permission: Permission) -> str: + return f"{self.content_type.app_label}.{permission.codename}" diff --git a/tests/unit/apps/core/test_doap.py b/tests/unit/apps/core/test_doap.py index 623cf36aec..f834ba72a0 100644 --- a/tests/unit/apps/core/test_doap.py +++ b/tests/unit/apps/core/test_doap.py @@ -7,7 +7,7 @@ from django_webtest import WebTest -from hct_mis_api.apps.account.fixtures import UserFactory, RoleAssignmentFactory +from hct_mis_api.apps.account.fixtures import RoleAssignmentFactory, UserFactory from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import BusinessArea @@ -19,7 +19,9 @@ def setUpTestData(cls) -> None: create_afghanistan() cls.business_area = BusinessArea.objects.get(slug="afghanistan") cls.user = UserFactory(is_superuser=True, is_staff=True) - cls.user_role = RoleAssignmentFactory(role__name="Approver", role__subsystem="CA", business_area=cls.business_area) + cls.user_role = RoleAssignmentFactory( + role__name="Approver", role__subsystem="CA", business_area=cls.business_area + ) cls.officer = cls.user_role.user def test_get_matrix(self) -> None: diff --git a/tests/unit/apps/grievance/test_grievance_es.py b/tests/unit/apps/grievance/test_grievance_es.py index d6a8cd63da..49f90b910f 100644 --- a/tests/unit/apps/grievance/test_grievance_es.py +++ b/tests/unit/apps/grievance/test_grievance_es.py @@ -7,6 +7,7 @@ from django.core.management import call_command from elasticsearch import Elasticsearch + from hct_mis_api.apps.account.fixtures import UserFactory from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.base_test_case import APITestCase diff --git a/tests/unit/apps/payment/test_finish_verification_plan.py b/tests/unit/apps/payment/test_finish_verification_plan.py index 8ffca0a676..17cd7eca4d 100644 --- a/tests/unit/apps/payment/test_finish_verification_plan.py +++ b/tests/unit/apps/payment/test_finish_verification_plan.py @@ -6,7 +6,11 @@ from constance.test import override_config -from hct_mis_api.apps.account.fixtures import RoleFactory, UserFactory, RoleAssignmentFactory +from hct_mis_api.apps.account.fixtures import ( + RoleAssignmentFactory, + RoleFactory, + UserFactory, +) from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.grievance.models import GrievanceTicket diff --git a/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py b/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py index ad65d593b2..a20e15eb90 100644 --- a/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py +++ b/tests/unit/apps/payment/test_import_export_payment_plan_payment_list.py @@ -14,7 +14,7 @@ from graphql import GraphQLError from hct_mis_api.apps.account.fixtures import UserFactory -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.core.fixtures import create_afghanistan from hct_mis_api.apps.core.models import ( diff --git a/tests/unit/apps/program/test_program_cycle_rest_api.py b/tests/unit/apps/program/test_program_cycle_rest_api.py index 0aba0c2a23..49ba9a88ff 100644 --- a/tests/unit/apps/program/test_program_cycle_rest_api.py +++ b/tests/unit/apps/program/test_program_cycle_rest_api.py @@ -16,7 +16,7 @@ PartnerFactory, UserFactory, ) -from hct_mis_api.apps.account.models import Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Role, RoleAssignment, User from hct_mis_api.apps.account.permissions import Permissions from hct_mis_api.apps.payment.fixtures import PaymentPlanFactory from hct_mis_api.apps.payment.models import PaymentPlan diff --git a/tests/unit/fixtures/account.py b/tests/unit/fixtures/account.py index 2cd51ebf7e..e594c126dd 100644 --- a/tests/unit/fixtures/account.py +++ b/tests/unit/fixtures/account.py @@ -3,7 +3,7 @@ import pytest from hct_mis_api.apps.account.fixtures import PartnerFactory -from hct_mis_api.apps.account.models import Partner, Role, User, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.models import Program, ProgramPartnerThrough From 55e482fc7aa4ecf8e1fcc1c7c5200287200f2182 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Sun, 12 Jan 2025 22:29:14 +0100 Subject: [PATCH 013/208] fixes, apply has_perm on user for multiple places --- src/hct_mis_api/apps/account/models.py | 2 +- src/hct_mis_api/apps/account/permissions.py | 26 +++++++++++++----- src/hct_mis_api/apps/accountability/views.py | 2 +- src/hct_mis_api/apps/core/backends.py | 5 ++++ .../apps/core/permissions_views_mixins.py | 2 +- src/hct_mis_api/apps/core/utils.py | 15 +++++------ src/hct_mis_api/apps/core/views.py | 2 +- src/hct_mis_api/apps/grievance/filters.py | 3 ++- src/hct_mis_api/apps/grievance/schema.py | 18 ++++++------- src/hct_mis_api/apps/household/schema.py | 27 ++++++++----------- src/hct_mis_api/apps/payment/api/views.py | 7 +++-- src/hct_mis_api/apps/payment/schema.py | 5 ++-- src/hct_mis_api/apps/payment/views.py | 6 ++--- src/hct_mis_api/apps/program/schema.py | 9 ++++--- 14 files changed, 70 insertions(+), 59 deletions(-) diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 909fc15fd1..e3f267b0dc 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -205,7 +205,7 @@ def get_program_ids_for_business_area(self, business_area_id: str) -> List[str]: ).exists(): programs_ids = Program.objects.filter(business_area_id=business_area_id).values_list("id", flat=True) else: - RoleAssignment.objects.filter( + programs_ids = RoleAssignment.objects.filter( Q(user=self) | Q(partner__user=self), business_area_id=business_area_id, ).values_list("program_id", flat=True) diff --git a/src/hct_mis_api/apps/account/permissions.py b/src/hct_mis_api/apps/account/permissions.py index a94175c227..f969a539b3 100644 --- a/src/hct_mis_api/apps/account/permissions.py +++ b/src/hct_mis_api/apps/account/permissions.py @@ -333,6 +333,9 @@ def check_permissions(user: Any, permissions: Iterable[Permissions], **kwargs: A program = Program.objects.filter(id=get_program_id_from_headers(kwargs)).first() obj = program or business_area + print("CHECK PERMISSION HERE1122") + print([permission.name for permission in permissions]) + print(obj) return any(user.has_perm(permission.name, obj) for permission in permissions) @@ -401,13 +404,16 @@ def check_creator_or_owner_permission( is_owner: bool, owner_permission: str, ) -> None: + from hct_mis_api.apps.program.models import Program + user = info.context.user business_area = object_instance.business_area - program_id = get_program_id_from_headers(info.context.headers) + program = Program.objects.filter(id=get_program_id_from_headers(info.context.headers)).first() + scope = program or business_area if not user.is_authenticated or not ( - user.has_permission(general_permission, business_area, program_id) - or (is_creator and user.has_permission(creator_permission, business_area, program_id)) - or (is_owner and user.has_permission(owner_permission, business_area, program_id)) + user.has_perm(general_permission, scope) + or (is_creator and user.has_perm(creator_permission, scope)) + or (is_owner and user.has_perm(owner_permission, scope)) ): raise PermissionDenied("Permission Denied") @@ -538,6 +544,8 @@ def has_permission( business_area_arg: Union[str, BusinessArea], raise_error: bool = True, ) -> bool: + from hct_mis_api.apps.program.models import Program + cls.is_authenticated(info) permissions: Iterable = (permission,) if not isinstance(permission, list) else permission if isinstance(business_area_arg, BusinessArea): @@ -548,12 +556,18 @@ def has_permission( business_area = BusinessArea.objects.filter(slug=business_area_arg).first() if business_area is None: return cls.raise_permission_denied_error(raise_error=raise_error) - program_id = get_program_id_from_headers(info.context.headers) + program = Program.objects.filter(id=get_program_id_from_headers(info.context.headers)).first() + print("JHjkdhljksad") + print(program) + print(info.context.user) + print(info.context.user.instance) + + print(info.context.user.get_all_permissions(program)) if not any( [ permission.name for permission in permissions - if info.context.user.has_permission(permission.name, business_area, program_id) + if info.context.user.has_perm(permission.name, program or business_area) ] ): return cls.raise_permission_denied_error(raise_error=raise_error) diff --git a/src/hct_mis_api/apps/accountability/views.py b/src/hct_mis_api/apps/accountability/views.py index 7c4205e94d..48c107d9b7 100644 --- a/src/hct_mis_api/apps/accountability/views.py +++ b/src/hct_mis_api/apps/accountability/views.py @@ -12,7 +12,7 @@ def download_cash_plan_payment_verification(request: HttpRequest, survey_id: str) -> HttpResponse: survey = get_object_or_404(Survey, id=decode_id_string(survey_id)) - if not request.user.has_permission(Permissions.ACCOUNTABILITY_SURVEY_VIEW_DETAILS.name, survey.business_area): + if not request.user.has_perm(Permissions.ACCOUNTABILITY_SURVEY_VIEW_DETAILS.name, survey.business_area): raise PermissionDenied("Permission Denied: User does not have correct permission.") try: diff --git a/src/hct_mis_api/apps/core/backends.py b/src/hct_mis_api/apps/core/backends.py index 18032d3768..5cac28ec1b 100644 --- a/src/hct_mis_api/apps/core/backends.py +++ b/src/hct_mis_api/apps/core/backends.py @@ -18,6 +18,7 @@ class PermissionsBackend(BaseBackend): def get_all_permissions(self, user: User, obj: Optional[Model] = None) -> set[str]: # type: ignore + print("PERMISSIONS CHECK") filters: dict[str, Any] if not obj: program = None @@ -52,6 +53,8 @@ def get_all_permissions(self, user: User, obj: Optional[Model] = None) -> set[st cached_permissions = cache.get(cache_key) if cached_permissions: + print("cached") + print(cached_permissions) return cached_permissions # If permission is checked for a Program and User does not have access to it, return empty set @@ -79,6 +82,8 @@ def get_all_permissions(self, user: User, obj: Optional[Model] = None) -> set[st & (Q(business_area=filters.get("business_area"), program=None) | Q(**filters)) ).exclude(expiry_date__lt=timezone.now()) + print(role_assignments) + if business_area and not role_assignments.exists(): return set() diff --git a/src/hct_mis_api/apps/core/permissions_views_mixins.py b/src/hct_mis_api/apps/core/permissions_views_mixins.py index 0e031e855e..a50a34177c 100644 --- a/src/hct_mis_api/apps/core/permissions_views_mixins.py +++ b/src/hct_mis_api/apps/core/permissions_views_mixins.py @@ -24,5 +24,5 @@ def has_permissions(self) -> bool: roles = self.request.user.user_roles.all() return any( - self.request.user.has_permission(Permissions.UPLOAD_STORAGE_FILE.name, role.business_area) for role in roles + self.request.user.has_perm(Permissions.UPLOAD_STORAGE_FILE.name, role.business_area) for role in roles ) diff --git a/src/hct_mis_api/apps/core/utils.py b/src/hct_mis_api/apps/core/utils.py index bfc25a58d8..2dd9bc25e6 100644 --- a/src/hct_mis_api/apps/core/utils.py +++ b/src/hct_mis_api/apps/core/utils.py @@ -36,8 +36,6 @@ from django_filters import OrderingFilter from PIL import Image -from hct_mis_api.apps.core.models import BusinessArea -from hct_mis_api.apps.program.models import Program from hct_mis_api.apps.utils.exceptions import log_and_raise if TYPE_CHECKING: @@ -48,6 +46,9 @@ from openpyxl.worksheet.worksheet import Worksheet from hct_mis_api.apps.account.models import User + from hct_mis_api.apps.core.models import BusinessArea + from hct_mis_api.apps.program.models import Program + logger = logging.getLogger(__name__) @@ -94,7 +95,7 @@ def get_program_id_from_headers(headers: Union[Dict, "HttpHeaders"]) -> Optional # TODO: change -def get_selected_program(request: Any) -> Optional[Program]: +def get_selected_program(request: Any) -> Optional["Program"]: if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request program = request.data.get("variables", {}).get("program") elif isinstance(request.GET, dict): # REST API Request @@ -102,7 +103,7 @@ def get_selected_program(request: Any) -> Optional[Program]: return program -def get_selected_business_area(request: Any) -> Optional[BusinessArea]: +def get_selected_business_area(request: Any) -> Optional["BusinessArea"]: if hasattr(request, "data") and isinstance(request.data, dict): # GraphQL Request program = request.data.get("variables", {}).get("business_area") elif isinstance(request.GET, dict): # REST API Request @@ -658,10 +659,8 @@ def resolve_f(*args: Any, **kwargs: Any) -> Any: if resolve_info.context.user.is_authenticated: business_area_slug = kwargs.get("business_area_slug", "global") business_area = BusinessArea.objects.filter(slug=business_area_slug).first() - program_id = get_program_id_from_headers(resolve_info.context.headers) - if any( - resolve_info.context.user.has_permission(per.name, business_area, program_id) for per in permissions - ): + program = Program.objects.filter(id=get_program_id_from_headers(resolve_info.context.headers)).first() + if any(resolve_info.context.user.has_perm(per.name, program or business_area) for per in permissions): return chart_resolve(*args, **kwargs) log_and_raise("Permission Denied") diff --git a/src/hct_mis_api/apps/core/views.py b/src/hct_mis_api/apps/core/views.py index 3eaf10cec9..b21328f4f2 100644 --- a/src/hct_mis_api/apps/core/views.py +++ b/src/hct_mis_api/apps/core/views.py @@ -55,7 +55,7 @@ def trigger_error(request: HttpRequest) -> HttpResponse: @login_required def download_dashboard_report(request: HttpRequest, report_id: "UUID") -> Any: report = get_object_or_404(DashboardReport, id=report_id) - if not request.user.has_permission(Permissions.DASHBOARD_EXPORT.name, report.business_area): + if not request.user.has_perm(Permissions.DASHBOARD_EXPORT.name, report.business_area): logger.error("Permission Denied: You need dashboard export permission to access this file") raise PermissionDenied("Permission Denied: You need dashboard export permission to access this file") return redirect(report.file.url) diff --git a/src/hct_mis_api/apps/grievance/filters.py b/src/hct_mis_api/apps/grievance/filters.py index 4849240de2..0ab89f6892 100644 --- a/src/hct_mis_api/apps/grievance/filters.py +++ b/src/hct_mis_api/apps/grievance/filters.py @@ -307,11 +307,12 @@ def filter_is_cross_area(self, qs: QuerySet, name: str, value: bool) -> QuerySet user = self.request.user business_area = BusinessArea.objects.get(slug=self.request.headers.get("Business-Area")) program_id = get_program_id_from_headers(self.request.headers) + program = Program.objects.filter(id=program_id).first() perm = Permissions.GRIEVANCES_CROSS_AREA_FILTER.value if ( value is True - and user.has_permission(perm, business_area, program_id) + and user.has_perm(perm, program or business_area) and (not user.partner.has_area_limits_in_program(program_id) or not program_id) ): return qs.filter(needs_adjudication_ticket_details__is_cross_area=True) diff --git a/src/hct_mis_api/apps/grievance/schema.py b/src/hct_mis_api/apps/grievance/schema.py index 756b2efe63..adcfbd2255 100644 --- a/src/hct_mis_api/apps/grievance/schema.py +++ b/src/hct_mis_api/apps/grievance/schema.py @@ -127,6 +127,8 @@ def check_node_permission(cls, info: Any, object_instance: GrievanceTicket) -> N user = info.context.user # when selected All programs in GPF program_id is None program_id: Optional[str] = get_program_id_from_headers(info.context.headers) + program = Program.objects.filter(id=program_id).first() + scope = program or business_area if object_instance.category == GrievanceTicket.CATEGORY_SENSITIVE_GRIEVANCE: perm = Permissions.GRIEVANCES_VIEW_DETAILS_SENSITIVE.value @@ -137,14 +139,11 @@ def check_node_permission(cls, info: Any, object_instance: GrievanceTicket) -> N creator_perm = Permissions.GRIEVANCES_VIEW_DETAILS_EXCLUDING_SENSITIVE_AS_CREATOR.value owner_perm = Permissions.GRIEVANCES_VIEW_DETAILS_EXCLUDING_SENSITIVE_AS_OWNER.value - check_creator = object_instance.created_by == user and user.has_permission( - creator_perm, business_area, program_id - ) - check_assignee = object_instance.assigned_to == user and user.has_permission( - owner_perm, business_area, program_id - ) + check_creator = object_instance.created_by == user and user.has_perm(creator_perm, scope) + check_assignee = object_instance.assigned_to == user and user.has_perm(owner_perm, scope) partner = user.partner ticket_program_id = str(object_instance.programs.first().id) if object_instance.programs.first() else None + ticket_program = Program.objects.filter(id=ticket_program_id).first() if not object_instance.admin2 or not ticket_program_id: # admin2 is empty or non-program ticket -> no restrictions for admin area has_partner_area_access = True @@ -153,7 +152,7 @@ def check_node_permission(cls, info: Any, object_instance: GrievanceTicket) -> N area_id=object_instance.admin2.id, program_id=ticket_program_id ) if ( - user.has_perm(perm, ticket_program_id or business_area) or check_creator or check_assignee + user.has_perm(perm, ticket_program or business_area) or check_creator or check_assignee ) and has_partner_area_access: return None @@ -608,14 +607,13 @@ def resolve_cross_area_filter_available(self, info: Any, **kwargs: Any) -> bool: return False business_area = BusinessArea.objects.get(slug=info.context.headers.get("Business-Area")) program_id = get_program_id_from_headers(info.context.headers) + program = Program.objects.filter(id=get_program_id_from_headers(info.context.headers)).first() perm = Permissions.GRIEVANCES_CROSS_AREA_FILTER.value # Access to the cross-area filter, in addition to the standard permissions check, # is available only if user does not have ANY area limits in the program (has full-area-access) - return user.has_permission(perm, business_area, program_id) and not user.partner.has_area_limits_in_program( - program_id - ) + return user.has_perm(perm, program or business_area) and not user.partner.has_area_limits_in_program(program_id) def resolve_grievance_ticket_status_choices(self, info: Any, **kwargs: Any) -> List[Dict[str, Any]]: return to_choice_object(GrievanceTicket.STATUS_CHOICES) diff --git a/src/hct_mis_api/apps/household/schema.py b/src/hct_mis_api/apps/household/schema.py index 2b758abbe5..5de2948f15 100644 --- a/src/hct_mis_api/apps/household/schema.py +++ b/src/hct_mis_api/apps/household/schema.py @@ -333,11 +333,10 @@ def resolve_phone_no_alternative_valid(parent, info: Any) -> Boolean: return parent.phone_no_alternative_valid def resolve_delivery_mechanisms_data(parent, info: Any) -> QuerySet[DeliveryMechanismData]: - program_id = get_program_id_from_headers(info.context.headers) - if not info.context.user.has_permission( + program = Program.objects.filter(id=get_program_id_from_headers(info.context.headers)).first() + if not info.context.user.has_perm( Permissions.POPULATION_VIEW_INDIVIDUAL_DELIVERY_MECHANISMS_SECTION.value, - parent.business_area, - program_id, + program or parent.business_area, ): return parent.delivery_mechanisms_data.none() @@ -368,14 +367,12 @@ def check_node_permission(cls, info: Any, object_instance: Individual) -> None: raise PermissionDenied("Permission Denied") # if user can't simply view all individuals, we check if they can do it because of grievance or rdi details - if not user.has_permission( + if not user.has_perm( Permissions.POPULATION_VIEW_INDIVIDUALS_DETAILS.value, - object_instance.business_area, - object_instance.program_id, - ) and not user.has_permission( + object_instance.program or object_instance.business_area, + ) and not user.has_perm( Permissions.RDI_VIEW_DETAILS.value, - object_instance.business_area, - object_instance.program_id, + object_instance.program or object_instance.business_area, ): grievance_tickets = GrievanceTicket.objects.filter( complaint_ticket_details__in=object_instance.complaint_ticket_details.all() @@ -544,14 +541,12 @@ def check_node_permission(cls, info: Any, object_instance: Household) -> None: raise PermissionDenied("Permission Denied") # if user doesn't have permission to view all households or RDI details, we check based on their grievance tickets - if not user.has_permission( + if not user.has_perm( Permissions.POPULATION_VIEW_HOUSEHOLDS_DETAILS.value, - object_instance.business_area, - object_instance.program_id, - ) and not user.has_permission( + object_instance.program or object_instance.business_area, + ) and not user.has_perm( Permissions.RDI_VIEW_DETAILS.value, - object_instance.business_area, - object_instance.program_id, + object_instance.program or object_instance.business_area, ): grievance_tickets = GrievanceTicket.objects.filter( complaint_ticket_details__in=object_instance.complaint_ticket_details.all() diff --git a/src/hct_mis_api/apps/payment/api/views.py b/src/hct_mis_api/apps/payment/api/views.py index 71e753447f..0aecfcc76a 100644 --- a/src/hct_mis_api/apps/payment/api/views.py +++ b/src/hct_mis_api/apps/payment/api/views.py @@ -136,10 +136,9 @@ def _perform_payment_plan_status_action( payment_plan_id = decode_id_string(payment_plan_id_str) payment_plan = get_object_or_404(PaymentPlan, id=payment_plan_id) - if not self.request.user.has_permission( - self._get_action_permission(input_data["action"]), - business_area, - payment_plan.program_cycle.program_id, + if not self.request.user.has_perm( + self._get_action_permission(input_data["action"]), # type: ignore + payment_plan.program_cycle.program or business_area, ): raise PermissionDenied( f"You do not have permission to perform action {input_data['action']} " diff --git a/src/hct_mis_api/apps/payment/schema.py b/src/hct_mis_api/apps/payment/schema.py index ade4033e12..04baa71862 100644 --- a/src/hct_mis_api/apps/payment/schema.py +++ b/src/hct_mis_api/apps/payment/schema.py @@ -442,10 +442,9 @@ def _parse_pp_conflict_data(cls, conflicts_data: List) -> List[Any]: def resolve_fsp_auth_code(self, info: Any) -> str: user = info.context.user - if not user.has_permission( + if not user.has_perm( Permissions.PM_VIEW_FSP_AUTH_CODE.value, - self.business_area, - self.program_id, + self.program or self.business_area, ): return "" return self.fsp_auth_code or "" # type: ignore diff --git a/src/hct_mis_api/apps/payment/views.py b/src/hct_mis_api/apps/payment/views.py index 6f0b96c75a..de7c0668a3 100644 --- a/src/hct_mis_api/apps/payment/views.py +++ b/src/hct_mis_api/apps/payment/views.py @@ -30,7 +30,7 @@ def download_payment_verification_plan( # type: ignore ]: payment_verification_plan_id = decode_id_string(verification_id) payment_verification_plan = get_object_or_404(PaymentVerificationPlan, id=payment_verification_plan_id) - if not request.user.has_permission( + if not request.user.has_perm( Permissions.PAYMENT_VERIFICATION_EXPORT.value, payment_verification_plan.business_area ): raise PermissionDenied("Permission Denied: User does not have correct permission.") @@ -58,7 +58,7 @@ def download_payment_plan_payment_list( # type: ignore # missing return payment_plan_id_str = decode_id_string(payment_plan_id) payment_plan = get_object_or_404(PaymentPlan, id=payment_plan_id_str) - if not request.user.has_permission(Permissions.PM_VIEW_LIST.value, payment_plan.business_area): + if not request.user.has_perm(Permissions.PM_VIEW_LIST.value, payment_plan.business_area): raise PermissionDenied("Permission Denied: User does not have correct permission.") if payment_plan.status not in (PaymentPlan.Status.LOCKED, PaymentPlan.Status.ACCEPTED, PaymentPlan.Status.FINISHED): @@ -79,7 +79,7 @@ def download_payment_plan_summary_pdf( # type: ignore # missing return payment_plan_id_str = decode_id_string(payment_plan_id) payment_plan = get_object_or_404(PaymentPlan, id=payment_plan_id_str) - if not request.user.has_permission(Permissions.PM_EXPORT_PDF_SUMMARY.value, payment_plan.business_area): + if not request.user.has_perm(Permissions.PM_EXPORT_PDF_SUMMARY.value, payment_plan.business_area): raise PermissionDenied("Permission Denied: User does not have correct permission.") if payment_plan.status not in (PaymentPlan.Status.ACCEPTED, PaymentPlan.Status.FINISHED): diff --git a/src/hct_mis_api/apps/program/schema.py b/src/hct_mis_api/apps/program/schema.py index 08a4656918..6c53ab9163 100644 --- a/src/hct_mis_api/apps/program/schema.py +++ b/src/hct_mis_api/apps/program/schema.py @@ -31,7 +31,7 @@ from hct_mis_api.apps.account.schema import PartnerNode from hct_mis_api.apps.core.decorators import cached_in_django_cache from hct_mis_api.apps.core.extended_connection import ExtendedConnection -from hct_mis_api.apps.core.models import DataCollectingType +from hct_mis_api.apps.core.models import BusinessArea, DataCollectingType from hct_mis_api.apps.core.schema import ( ChoiceObject, DataCollectingTypeNode, @@ -233,13 +233,14 @@ def resolve_is_deduplication_disabled(self, info: Any, **kwargs: Any) -> bool: def resolve_all_programs(self, info: Any, **kwargs: Any) -> QuerySet[Program]: user = info.context.user + business_area = BusinessArea.objects.filter(slug=info.context.headers.get("Business-Area").lower()).first() + allowed_programs = Program.objects.filter(id__in=user.get_program_ids_for_business_area(business_area.id)) filters = { - "business_area__slug": info.context.headers.get("Business-Area").lower(), + "business_area": business_area, "data_collecting_type__deprecated": False, "data_collecting_type__isnull": False, + "id__in": allowed_programs.values_list("id", flat=True), } - if not user.partner.is_unicef: - filters.update({"id__in": user.partner.programs.values_list("id", flat=True)}) return ( Program.objects.filter(**filters) .exclude(data_collecting_type__code="unknown") From 6f0650b5393f786666e20896d4f8fdeb5886eea3 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Mon, 13 Jan 2025 00:23:24 +0100 Subject: [PATCH 014/208] update program filter, fix some frontend issues --- src/frontend/data/schema.graphql | 50 ++++++--- src/frontend/src/__generated__/graphql.tsx | 101 +++++++++++++----- .../src/apollo/queries/core/AllUsers.ts | 18 +++- src/hct_mis_api/apps/account/filters.py | 2 + 4 files changed, 124 insertions(+), 47 deletions(-) diff --git a/src/frontend/data/schema.graphql b/src/frontend/data/schema.graphql index 64a5080be7..db1eeb0001 100644 --- a/src/frontend/data/schema.graphql +++ b/src/frontend/data/schema.graphql @@ -342,11 +342,10 @@ type BusinessAreaNode implements Node { active: Boolean! enableEmailNotification: Boolean! partners: [PartnerNode!]! - businessAreaPartnerThrough: [PartnerRoleNode!]! children(offset: Int, before: String, after: String, first: Int, last: Int, id: UUID): UserBusinessAreaNodeConnection! dataCollectingTypes(offset: Int, before: String, after: String, first: Int, last: Int): DataCollectingTypeNodeConnection! partnerSet: [PartnerNode!]! - userRoles: [UserRoleNode!]! + roleAssignments: [RoleAssignmentNode!]! householdSet(offset: Int, before: String, after: String, first: Int, last: Int): HouseholdNodeConnection! individualSet(offset: Int, before: String, after: String, first: Int, last: Int): IndividualNodeConnection! registrationdataimportSet(offset: Int, before: String, after: String, first: Int, last: Int): RegistrationDataImportNodeConnection! @@ -2409,10 +2408,10 @@ type PartnerNode { rght: Int! treeId: Int! level: Int! - businessAreaPartnerThrough: [PartnerRoleNode!]! businessAreas(offset: Int, before: String, after: String, first: Int, last: Int, id: UUID): UserBusinessAreaNodeConnection! partnerSet: [PartnerNode!]! userSet(offset: Int, before: String, after: String, first: Int, last: Int): UserNodeConnection! + roleAssignments: [RoleAssignmentNode!]! individualIdentities(offset: Int, before: String, after: String, first: Int, last: Int): IndividualIdentityNodeConnection! grievanceticketSet(offset: Int, before: String, after: String, first: Int, last: Int): GrievanceTicketNodeConnection! programs(offset: Int, before: String, after: String, first: Int, last: Int, name: String): ProgramNodeConnection! @@ -2424,8 +2423,11 @@ type PartnerRoleNode { createdAt: DateTime! updatedAt: DateTime! businessArea: UserBusinessAreaNode! - partner: PartnerNode! - roles: [RoleNode!]! + partner: PartnerNode + role: RoleNode + program: ProgramNode + expiryDate: Date + user: UserNode } type PartnerType { @@ -2438,10 +2440,10 @@ type PartnerType { rght: Int! treeId: Int! level: Int! - businessAreaPartnerThrough: [PartnerRoleNode!]! businessAreas(offset: Int, before: String, after: String, first: Int, last: Int, id: UUID): UserBusinessAreaNodeConnection! partnerSet: [PartnerNode!]! userSet(offset: Int, before: String, after: String, first: Int, last: Int): UserNodeConnection! + roleAssignments: [RoleAssignmentNode!]! individualIdentities(offset: Int, before: String, after: String, first: Int, last: Int): IndividualIdentityNodeConnection! grievanceticketSet(offset: Int, before: String, after: String, first: Int, last: Int): GrievanceTicketNodeConnection! programs(offset: Int, before: String, after: String, first: Int, last: Int, name: String): ProgramNodeConnection! @@ -2510,8 +2512,8 @@ type PaymentNode implements Node { fspAuthCode: String isCashAssist: Boolean! followUps(offset: Int, before: String, after: String, first: Int, last: Int): PaymentNodeConnection! - paymentVerification: PaymentVerificationNode householdSnapshot: PaymentHouseholdSnapshotNode + paymentVerification: PaymentVerificationNode ticketComplaintDetails: TicketComplaintDetailsNode ticketSensitiveDetails: TicketSensitiveDetailsNode adminUrl: String @@ -2604,13 +2606,13 @@ type PaymentPlanNode implements Node { excludeHouseholdError: String! name: String isCashAssist: Boolean! + approvalProcess(offset: Int, before: String, after: String, first: Int, last: Int): ApprovalProcessNodeConnection! followUps(offset: Int, before: String, after: String, first: Int, last: Int): PaymentPlanNodeConnection! deliveryMechanisms: [DeliveryMechanismPerPaymentPlanNode] paymentItems(offset: Int, before: String, after: String, first: Int, last: Int): PaymentNodeConnection! + documents(offset: Int, before: String, after: String, first: Int, last: Int): PaymentPlanSupportingDocumentNodeConnection! paymentVerificationPlans(offset: Int, before: String, after: String, first: Int, last: Int): PaymentVerificationPlanNodeConnection! paymentVerificationSummary: PaymentVerificationSummaryNode - approvalProcess(offset: Int, before: String, after: String, first: Int, last: Int): ApprovalProcessNodeConnection! - documents(offset: Int, before: String, after: String, first: Int, last: Int): PaymentPlanSupportingDocumentNodeConnection! adminUrl: String currencyName: String hasPaymentListExportFile: Boolean @@ -2986,6 +2988,7 @@ type ProgramNode implements Node { biometricDeduplicationEnabled: Boolean! deduplicationSetId: UUID pduFields: [PeriodicFieldNode] + roleAssignments: [RoleAssignmentNode!]! households(offset: Int, before: String, after: String, first: Int, last: Int): HouseholdNodeConnection! householdSet(offset: Int, before: String, after: String, first: Int, last: Int): HouseholdNodeConnection! individuals(offset: Int, before: String, after: String, first: Int, last: Int): IndividualNodeConnection! @@ -3512,6 +3515,17 @@ type RevertMarkPaymentAsFailedMutation { payment: PaymentNode } +type RoleAssignmentNode { + createdAt: DateTime! + updatedAt: DateTime! + businessArea: UserBusinessAreaNode! + partner: PartnerNode + role: RoleNode + program: ProgramNode + expiryDate: Date + user: UserNode +} + type RoleChoiceObject { name: String value: String @@ -3524,8 +3538,9 @@ type RoleNode { name: String! subsystem: RoleSubsystem! permissions: [String!] - businessAreaPartnerThrough: [PartnerRoleNode!]! - userRoles: [UserRoleNode!]! + isVisibleOnUi: Boolean! + isAvailableForPartner: Boolean! + roleAssignments: [RoleAssignmentNode!]! } enum RoleSubsystem { @@ -4621,11 +4636,10 @@ type UserBusinessAreaNode implements Node { active: Boolean! enableEmailNotification: Boolean! partners: [PartnerNode!]! - businessAreaPartnerThrough: [PartnerRoleNode!]! children(offset: Int, before: String, after: String, first: Int, last: Int, id: UUID): UserBusinessAreaNodeConnection! dataCollectingTypes(offset: Int, before: String, after: String, first: Int, last: Int): DataCollectingTypeNodeConnection! partnerSet: [PartnerNode!]! - userRoles: [UserRoleNode!]! + roleAssignments: [RoleAssignmentNode!]! householdSet(offset: Int, before: String, after: String, first: Int, last: Int): HouseholdNodeConnection! individualSet(offset: Int, before: String, after: String, first: Int, last: Int): IndividualNodeConnection! registrationdataimportSet(offset: Int, before: String, after: String, first: Int, last: Int): RegistrationDataImportNodeConnection! @@ -4676,15 +4690,15 @@ type UserNode implements Node { lastModifyDate: DateTime lastDoapSync: DateTime doapHash: String! - userRoles: [UserRoleNode!]! + roleAssignments: [RoleAssignmentNode!]! documentSet(offset: Int, before: String, after: String, first: Int, last: Int): DocumentNodeConnection! + approvalSet: [ApprovalNode!]! registrationDataImports(offset: Int, before: String, after: String, first: Int, last: Int): RegistrationDataImportNodeConnection! createdPaymentPlans(offset: Int, before: String, after: String, first: Int, last: Int): PaymentPlanNodeConnection! createdFinancialServiceProviderXlsxTemplates(offset: Int, before: String, after: String, first: Int, last: Int): FinancialServiceProviderXlsxTemplateNodeConnection! createdFinancialServiceProviders(offset: Int, before: String, after: String, first: Int, last: Int): FinancialServiceProviderNodeConnection! createdDeliveryMechanisms(offset: Int, before: String, after: String, first: Int, last: Int): DeliveryMechanismPerPaymentPlanNodeConnection! sentDeliveryMechanisms(offset: Int, before: String, after: String, first: Int, last: Int): DeliveryMechanismPerPaymentPlanNodeConnection! - approvalSet: [ApprovalNode!]! createdTickets(offset: Int, before: String, after: String, first: Int, last: Int): GrievanceTicketNodeConnection! assignedTickets(offset: Int, before: String, after: String, first: Int, last: Int): GrievanceTicketNodeConnection! ticketNotes(offset: Int, before: String, after: String, first: Int, last: Int): TicketNoteNodeConnection! @@ -4699,6 +4713,7 @@ type UserNode implements Node { surveys(offset: Int, before: String, after: String, first: Int, last: Int): SurveyNodeConnection! businessAreas(offset: Int, before: String, after: String, first: Int, last: Int, id: UUID): UserBusinessAreaNodeConnection partnerRoles: [PartnerRoleNode] + userRoles: [UserRoleNode] } type UserNodeConnection { @@ -4717,8 +4732,11 @@ type UserRoleNode { createdAt: DateTime! updatedAt: DateTime! businessArea: UserBusinessAreaNode! - role: RoleNode! + partner: PartnerNode + role: RoleNode + program: ProgramNode expiryDate: Date + user: UserNode } enum UserStatus { diff --git a/src/frontend/src/__generated__/graphql.tsx b/src/frontend/src/__generated__/graphql.tsx index e0e9478798..86ee257fd0 100644 --- a/src/frontend/src/__generated__/graphql.tsx +++ b/src/frontend/src/__generated__/graphql.tsx @@ -439,7 +439,6 @@ export type BusinessAreaNode = Node & { __typename?: 'BusinessAreaNode'; active: Scalars['Boolean']['output']; biometricDeduplicationThreshold: Scalars['Float']['output']; - businessAreaPartnerThrough: Array; children: UserBusinessAreaNodeConnection; code: Scalars['String']['output']; createdAt: Scalars['DateTime']['output']; @@ -484,6 +483,7 @@ export type BusinessAreaNode = Node & { regionName: Scalars['String']['output']; registrationdataimportSet: RegistrationDataImportNodeConnection; reports: ReportNodeConnection; + roleAssignments: Array; ruleSet: SteficonRuleNodeConnection; screenBeneficiary: Scalars['Boolean']['output']; slug: Scalars['String']['output']; @@ -491,7 +491,6 @@ export type BusinessAreaNode = Node & { targetpopulationSet: TargetPopulationNodeConnection; tickets: GrievanceTicketNodeConnection; updatedAt: Scalars['DateTime']['output']; - userRoles: Array; }; @@ -3989,7 +3988,6 @@ export type PartnerNode = { allowedBusinessAreas: UserBusinessAreaNodeConnection; areaAccess?: Maybe; areas?: Maybe>>; - businessAreaPartnerThrough: Array; businessAreas: UserBusinessAreaNodeConnection; grievanceticketSet: GrievanceTicketNodeConnection; id: Scalars['ID']['output']; @@ -4002,6 +4000,7 @@ export type PartnerNode = { partnerSet: Array; programs: ProgramNodeConnection; rght: Scalars['Int']['output']; + roleAssignments: Array; treeId: Scalars['Int']['output']; userSet: UserNodeConnection; }; @@ -4067,15 +4066,17 @@ export type PartnerRoleNode = { __typename?: 'PartnerRoleNode'; businessArea: UserBusinessAreaNode; createdAt: Scalars['DateTime']['output']; - partner: PartnerNode; - roles: Array; + expiryDate?: Maybe; + partner?: Maybe; + program?: Maybe; + role?: Maybe; updatedAt: Scalars['DateTime']['output']; + user?: Maybe; }; export type PartnerType = { __typename?: 'PartnerType'; allowedBusinessAreas: UserBusinessAreaNodeConnection; - businessAreaPartnerThrough: Array; businessAreas: UserBusinessAreaNodeConnection; grievanceticketSet: GrievanceTicketNodeConnection; id: Scalars['ID']['output']; @@ -4088,6 +4089,7 @@ export type PartnerType = { partnerSet: Array; programs: ProgramNodeConnection; rght: Scalars['Int']['output']; + roleAssignments: Array; treeId: Scalars['Int']['output']; userSet: UserNodeConnection; }; @@ -4870,6 +4872,7 @@ export type ProgramNode = Node & { programmeCode?: Maybe; registrationImports: RegistrationDataImportNodeConnection; reports: ReportNodeConnection; + roleAssignments: Array; scope?: Maybe; sector: ProgramSector; startDate: Scalars['Date']['output']; @@ -6650,6 +6653,18 @@ export type RevertMarkPaymentAsFailedMutation = { payment?: Maybe; }; +export type RoleAssignmentNode = { + __typename?: 'RoleAssignmentNode'; + businessArea: UserBusinessAreaNode; + createdAt: Scalars['DateTime']['output']; + expiryDate?: Maybe; + partner?: Maybe; + program?: Maybe; + role?: Maybe; + updatedAt: Scalars['DateTime']['output']; + user?: Maybe; +}; + export type RoleChoiceObject = { __typename?: 'RoleChoiceObject'; name?: Maybe; @@ -6659,13 +6674,14 @@ export type RoleChoiceObject = { export type RoleNode = { __typename?: 'RoleNode'; - businessAreaPartnerThrough: Array; createdAt: Scalars['DateTime']['output']; + isAvailableForPartner: Scalars['Boolean']['output']; + isVisibleOnUi: Scalars['Boolean']['output']; name: Scalars['String']['output']; permissions?: Maybe>; + roleAssignments: Array; subsystem: RoleSubsystem; updatedAt: Scalars['DateTime']['output']; - userRoles: Array; }; export enum RoleSubsystem { @@ -8005,7 +8021,6 @@ export type UserBusinessAreaNode = Node & { __typename?: 'UserBusinessAreaNode'; active: Scalars['Boolean']['output']; biometricDeduplicationThreshold: Scalars['Float']['output']; - businessAreaPartnerThrough: Array; children: UserBusinessAreaNodeConnection; code: Scalars['String']['output']; createdAt: Scalars['DateTime']['output']; @@ -8051,6 +8066,7 @@ export type UserBusinessAreaNode = Node & { regionName: Scalars['String']['output']; registrationdataimportSet: RegistrationDataImportNodeConnection; reports: ReportNodeConnection; + roleAssignments: Array; ruleSet: SteficonRuleNodeConnection; screenBeneficiary: Scalars['Boolean']['output']; slug: Scalars['String']['output']; @@ -8058,7 +8074,6 @@ export type UserBusinessAreaNode = Node & { targetpopulationSet: TargetPopulationNodeConnection; tickets: GrievanceTicketNodeConnection; updatedAt: Scalars['DateTime']['output']; - userRoles: Array; }; @@ -8290,12 +8305,13 @@ export type UserNode = Node & { partnerRoles?: Maybe>>; registrationDataImports: RegistrationDataImportNodeConnection; reports: ReportNodeConnection; + roleAssignments: Array; sentDeliveryMechanisms: DeliveryMechanismPerPaymentPlanNodeConnection; status: UserStatus; surveys: SurveyNodeConnection; targetPopulations: TargetPopulationNodeConnection; ticketNotes: TicketNoteNodeConnection; - userRoles: Array; + userRoles?: Maybe>>; username: Scalars['String']['output']; }; @@ -8568,8 +8584,11 @@ export type UserRoleNode = { businessArea: UserBusinessAreaNode; createdAt: Scalars['DateTime']['output']; expiryDate?: Maybe; - role: RoleNode; + partner?: Maybe; + program?: Maybe; + role?: Maybe; updatedAt: Scalars['DateTime']['output']; + user?: Maybe; }; export enum UserStatus { @@ -9392,7 +9411,7 @@ export type AllUsersQueryVariables = Exact<{ }>; -export type AllUsersQuery = { __typename?: 'Query', allUsers?: { __typename?: 'UserNodeConnection', totalCount?: number | null, edgeCount?: number | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null, startCursor?: string | null }, edges: Array<{ __typename?: 'UserNodeEdge', cursor: string, node?: { __typename?: 'UserNode', id: string, firstName: string, lastName: string, username: string, email: string, isActive: boolean, lastLogin?: any | null, status: UserStatus, partner?: { __typename?: 'PartnerNode', name?: string | null } | null, userRoles: Array<{ __typename?: 'UserRoleNode', businessArea: { __typename?: 'UserBusinessAreaNode', name: string }, role: { __typename?: 'RoleNode', name: string, permissions?: Array | null } }>, partnerRoles?: Array<{ __typename?: 'PartnerRoleNode', businessArea: { __typename?: 'UserBusinessAreaNode', name: string }, roles: Array<{ __typename?: 'RoleNode', name: string, permissions?: Array | null }> } | null> | null } | null } | null> } | null }; +export type AllUsersQuery = { __typename?: 'Query', allUsers?: { __typename?: 'UserNodeConnection', totalCount?: number | null, edgeCount?: number | null, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null, startCursor?: string | null }, edges: Array<{ __typename?: 'UserNodeEdge', cursor: string, node?: { __typename?: 'UserNode', id: string, firstName: string, lastName: string, username: string, email: string, isActive: boolean, lastLogin?: any | null, status: UserStatus, partner?: { __typename?: 'PartnerNode', name?: string | null } | null, userRoles: Array<{ __typename?: 'UserRoleNode', businessArea: { __typename?: 'UserBusinessAreaNode', name: string }, role: { __typename?: 'RoleNode', name: string, permissions?: Array | null } }>, partnerRoles?: Array<{ __typename?: 'PartnerRoleNode', businessArea: { __typename?: 'UserBusinessAreaNode', name: string }, role: { __typename?: 'RoleNode', name: string, permissions?: Array | null } } | null> | null } | null } | null> } | null }; export type AllUsersForFiltersQueryVariables = Exact<{ businessArea: Scalars['String']['input']; @@ -15663,6 +15682,10 @@ export const AllUsersDocument = gql` businessArea { name } + program { + id + name + } role { name permissions @@ -15672,7 +15695,11 @@ export const AllUsersDocument = gql` businessArea { name } - roles { + program { + id + name + } + role { name permissions } @@ -22661,6 +22688,7 @@ export type ResolversTypes = { RestartCreateReport: ResolverTypeWrapper; RestartCreateReportInput: RestartCreateReportInput; RevertMarkPaymentAsFailedMutation: ResolverTypeWrapper; + RoleAssignmentNode: ResolverTypeWrapper; RoleChoiceObject: ResolverTypeWrapper; RoleNode: ResolverTypeWrapper; RoleSubsystem: RoleSubsystem; @@ -23127,6 +23155,7 @@ export type ResolversParentTypes = { RestartCreateReport: RestartCreateReport; RestartCreateReportInput: RestartCreateReportInput; RevertMarkPaymentAsFailedMutation: RevertMarkPaymentAsFailedMutation; + RoleAssignmentNode: RoleAssignmentNode; RoleChoiceObject: RoleChoiceObject; RoleNode: RoleNode; RuleCommitNode: RuleCommitNode; @@ -23481,7 +23510,6 @@ export type BulkUpdateGrievanceTicketsUrgencyMutationResolvers = { active?: Resolver; biometricDeduplicationThreshold?: Resolver; - businessAreaPartnerThrough?: Resolver, ParentType, ContextType>; children?: Resolver>; code?: Resolver; createdAt?: Resolver; @@ -23526,6 +23554,7 @@ export type BusinessAreaNodeResolvers; registrationdataimportSet?: Resolver>; reports?: Resolver>; + roleAssignments?: Resolver, ParentType, ContextType>; ruleSet?: Resolver>; screenBeneficiary?: Resolver; slug?: Resolver; @@ -23533,7 +23562,6 @@ export type BusinessAreaNodeResolvers>; tickets?: Resolver>; updatedAt?: Resolver; - userRoles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -25130,7 +25158,6 @@ export type PartnerNodeResolvers>; areaAccess?: Resolver, ParentType, ContextType>; areas?: Resolver>>, ParentType, ContextType>; - businessAreaPartnerThrough?: Resolver, ParentType, ContextType>; businessAreas?: Resolver>; grievanceticketSet?: Resolver>; id?: Resolver; @@ -25143,6 +25170,7 @@ export type PartnerNodeResolvers, ParentType, ContextType>; programs?: Resolver>; rght?: Resolver; + roleAssignments?: Resolver, ParentType, ContextType>; treeId?: Resolver; userSet?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -25151,15 +25179,17 @@ export type PartnerNodeResolvers = { businessArea?: Resolver; createdAt?: Resolver; - partner?: Resolver; - roles?: Resolver, ParentType, ContextType>; + expiryDate?: Resolver, ParentType, ContextType>; + partner?: Resolver, ParentType, ContextType>; + program?: Resolver, ParentType, ContextType>; + role?: Resolver, ParentType, ContextType>; updatedAt?: Resolver; + user?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; export type PartnerTypeResolvers = { allowedBusinessAreas?: Resolver>; - businessAreaPartnerThrough?: Resolver, ParentType, ContextType>; businessAreas?: Resolver>; grievanceticketSet?: Resolver>; id?: Resolver; @@ -25172,6 +25202,7 @@ export type PartnerTypeResolvers, ParentType, ContextType>; programs?: Resolver>; rght?: Resolver; + roleAssignments?: Resolver, ParentType, ContextType>; treeId?: Resolver; userSet?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; @@ -25648,6 +25679,7 @@ export type ProgramNodeResolvers, ParentType, ContextType>; registrationImports?: Resolver>; reports?: Resolver>; + roleAssignments?: Resolver, ParentType, ContextType>; scope?: Resolver, ParentType, ContextType>; sector?: Resolver; startDate?: Resolver; @@ -26072,6 +26104,18 @@ export type RevertMarkPaymentAsFailedMutationResolvers; }; +export type RoleAssignmentNodeResolvers = { + businessArea?: Resolver; + createdAt?: Resolver; + expiryDate?: Resolver, ParentType, ContextType>; + partner?: Resolver, ParentType, ContextType>; + program?: Resolver, ParentType, ContextType>; + role?: Resolver, ParentType, ContextType>; + updatedAt?: Resolver; + user?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type RoleChoiceObjectResolvers = { name?: Resolver, ParentType, ContextType>; subsystem?: Resolver, ParentType, ContextType>; @@ -26080,13 +26124,14 @@ export type RoleChoiceObjectResolvers = { - businessAreaPartnerThrough?: Resolver, ParentType, ContextType>; createdAt?: Resolver; + isAvailableForPartner?: Resolver; + isVisibleOnUi?: Resolver; name?: Resolver; permissions?: Resolver>, ParentType, ContextType>; + roleAssignments?: Resolver, ParentType, ContextType>; subsystem?: Resolver; updatedAt?: Resolver; - userRoles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -26987,7 +27032,6 @@ export type UploadImportDataXlsxFileAsyncResolvers = { active?: Resolver; biometricDeduplicationThreshold?: Resolver; - businessAreaPartnerThrough?: Resolver, ParentType, ContextType>; children?: Resolver>; code?: Resolver; createdAt?: Resolver; @@ -27033,6 +27077,7 @@ export type UserBusinessAreaNodeResolvers; registrationdataimportSet?: Resolver>; reports?: Resolver>; + roleAssignments?: Resolver, ParentType, ContextType>; ruleSet?: Resolver>; screenBeneficiary?: Resolver; slug?: Resolver; @@ -27040,7 +27085,6 @@ export type UserBusinessAreaNodeResolvers>; tickets?: Resolver>; updatedAt?: Resolver; - userRoles?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -27094,12 +27138,13 @@ export type UserNodeResolvers>>, ParentType, ContextType>; registrationDataImports?: Resolver>; reports?: Resolver>; + roleAssignments?: Resolver, ParentType, ContextType>; sentDeliveryMechanisms?: Resolver>; status?: Resolver; surveys?: Resolver>; targetPopulations?: Resolver>; ticketNotes?: Resolver>; - userRoles?: Resolver, ParentType, ContextType>; + userRoles?: Resolver>>, ParentType, ContextType>; username?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -27122,8 +27167,11 @@ export type UserRoleNodeResolvers; createdAt?: Resolver; expiryDate?: Resolver, ParentType, ContextType>; - role?: Resolver; + partner?: Resolver, ParentType, ContextType>; + program?: Resolver, ParentType, ContextType>; + role?: Resolver, ParentType, ContextType>; updatedAt?: Resolver; + user?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -27417,6 +27465,7 @@ export type Resolvers = { ReportNodeEdge?: ReportNodeEdgeResolvers; RestartCreateReport?: RestartCreateReportResolvers; RevertMarkPaymentAsFailedMutation?: RevertMarkPaymentAsFailedMutationResolvers; + RoleAssignmentNode?: RoleAssignmentNodeResolvers; RoleChoiceObject?: RoleChoiceObjectResolvers; RoleNode?: RoleNodeResolvers; RuleCommitNode?: RuleCommitNodeResolvers; diff --git a/src/frontend/src/apollo/queries/core/AllUsers.ts b/src/frontend/src/apollo/queries/core/AllUsers.ts index aefbb626e5..93294409d1 100644 --- a/src/frontend/src/apollo/queries/core/AllUsers.ts +++ b/src/frontend/src/apollo/queries/core/AllUsers.ts @@ -50,6 +50,10 @@ export const ALL_USERS_QUERY = gql` businessArea { name } + program { + id + name + } role { name permissions @@ -57,11 +61,15 @@ export const ALL_USERS_QUERY = gql` } partnerRoles { businessArea { - name - } - roles { - name - permissions + name + } + program { + id + name + } + role { + name + permissions } } } diff --git a/src/hct_mis_api/apps/account/filters.py b/src/hct_mis_api/apps/account/filters.py index 886ab2fd48..cf49424893 100644 --- a/src/hct_mis_api/apps/account/filters.py +++ b/src/hct_mis_api/apps/account/filters.py @@ -77,6 +77,8 @@ def program_filter(self, qs: "QuerySet", name: str, value: str) -> "QuerySet[Use return qs.filter( Q(partner__role_assignments__program__id=program_id) | Q(partner__role_assignments__program=None, partner__role_assignments__business_area=business_area) + | Q(role_assignments__program__id=program_id) + | Q(role_assignments__program=None, role_assignments__business_area=business_area) ) def partners_filter(self, qs: "QuerySet", name: str, values: List["UUID"]) -> "QuerySet[User]": From 97b2fa8c478e1090251f53fd682745b107dda6b5 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Mon, 13 Jan 2025 02:46:24 +0100 Subject: [PATCH 015/208] admin changes - role assignments on user and partner --- src/hct_mis_api/apps/account/admin/forms.py | 3 - src/hct_mis_api/apps/account/admin/partner.py | 160 +++++++++--------- .../apps/account/admin/user_role.py | 27 ++- src/hct_mis_api/apps/account/celery_tasks.py | 3 +- .../admin/account/parent/permissions.html | 28 ++- 5 files changed, 126 insertions(+), 95 deletions(-) diff --git a/src/hct_mis_api/apps/account/admin/forms.py b/src/hct_mis_api/apps/account/admin/forms.py index bbdbbff6e2..d0bc943f96 100644 --- a/src/hct_mis_api/apps/account/admin/forms.py +++ b/src/hct_mis_api/apps/account/admin/forms.py @@ -56,9 +56,6 @@ class RoleAssignmentInlineFormSet(forms.BaseInlineFormSet): def add_fields(self, form: "forms.Form", index: Optional[int]) -> None: super().add_fields(form, index) - form.fields["business_area"].choices = [ - (str(x.id), str(x)) for x in BusinessArea.objects.filter(is_split=False) - ] form.fields["role"].required = True def clean(self) -> None: diff --git a/src/hct_mis_api/apps/account/admin/partner.py b/src/hct_mis_api/apps/account/admin/partner.py index bd15f5c2d7..da543c4d4f 100644 --- a/src/hct_mis_api/apps/account/admin/partner.py +++ b/src/hct_mis_api/apps/account/admin/partner.py @@ -12,7 +12,8 @@ from admin_extra_buttons.decorators import button from hct_mis_api.apps.account import models as account_models -from hct_mis_api.apps.account.models import IncompatibleRoles, Role +from hct_mis_api.apps.account.admin.user_role import RoleAssignmentInline +from hct_mis_api.apps.account.models import IncompatibleRoles, Role, RoleAssignment from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.models import Program @@ -27,16 +28,19 @@ def can_add_business_area_to_partner(request: Any, *args: Any, **kwargs: Any) -> return request.user.can_add_business_area_to_partner() -def business_area_role_form_custom_query(queryset: "QuerySet") -> Any: - class BusinessAreaRoleForm(forms.Form): - business_area = forms.ModelChoiceField(queryset=BusinessArea.objects.all(), required=True) - roles = forms.ModelMultipleChoiceField(queryset=Role.objects.all(), required=True) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.fields["business_area"].queryset = queryset - - return BusinessAreaRoleForm +# TODO: perm - not needed but replace with LimitAreasForPartner +# def business_area_role_form_custom_query(queryset: "QuerySet") -> Any: +# class BusinessAreaRoleForm(forms.Form): +# business_area = forms.ModelChoiceField(queryset=BusinessArea.objects.all(), required=True) +# program = forms.ModelChoiceField(queryset=Program.objects.all(), required=False) +# role = forms.ModelChoiceField(queryset=Role.objects.filter(is_available_for_partner=True).all(), required=True) +# expiry_date = forms.DateField(required=False) +# +# def __init__(self, *args: Any, **kwargs: Any) -> None: +# super().__init__(*args, **kwargs) +# self.fields["business_area"].queryset = queryset +# +# return BusinessAreaRoleForm class ProgramAreaForm(forms.Form): @@ -56,6 +60,7 @@ class PartnerAdmin(HopeModelAdminMixin, admin.ModelAdmin): "is_un", ) filter_horizontal = ("allowed_business_areas",) + inlines = (RoleAssignmentInline,) def sub_partners(self, obj: Any) -> Optional[str]: return self.links_to_objects(obj.get_children()) if obj else None @@ -94,75 +99,64 @@ def get_form( form.base_fields["parent"].queryset = queryset return form - @button(enabled=lambda obj: obj.original.is_editable) - def permissions(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: - context = self.get_common_context(request, pk, title="Partner permissions") - partner: account_models.Partner = context["original"] - user_can_add_ba_to_partner = request.user.can_add_business_area_to_partner() - permissions_list = partner.business_area_partner_through.all() - context["can_add_business_area_to_partner"] = user_can_add_ba_to_partner - - BusinessAreaRoleFormSet = formset_factory( - business_area_role_form_custom_query(partner.allowed_business_areas.all()), - extra=0, - can_delete=True, - ) - if request.method == "GET": - business_area_role_data = [] - for permission in permissions_list: - if permission.roles: - business_area_role_data.append( - {"business_area": permission.business_area_id, "roles": permission.roles.all()} - ) - business_area_role_form_set = BusinessAreaRoleFormSet( - initial=business_area_role_data, prefix="business_area_role" - ) - else: - business_area_role_form_set = BusinessAreaRoleFormSet(request.POST or None, prefix="business_area_role") - incompatible_roles = defaultdict(list) - ba_partner_through_data = {} - ba_partner_through_to_be_deleted = [] - - business_area_role_form_set_is_valid = business_area_role_form_set.is_valid() - if user_can_add_ba_to_partner and business_area_role_form_set_is_valid: - for form in business_area_role_form_set.cleaned_data: - if form and not form["DELETE"]: - business_area_id = str(form["business_area"].id) - role_ids = list(map(lambda role: str(role.id), form["roles"])) - - if incompatible_role := IncompatibleRoles.objects.filter( - role_one__in=role_ids, role_two__in=role_ids - ).first(): - incompatible_roles[form["business_area"]].append(str(incompatible_role)) - else: - ba_partner, _ = BusinessAreaPartnerThrough.objects.get_or_create( - partner=partner, - business_area_id=business_area_id, - ) - ba_partner_through_data[ba_partner] = form["roles"] - elif form["DELETE"]: - ba_partner_through_to_be_deleted.append( - BusinessAreaPartnerThrough.objects.filter( - partner=partner, business_area=form["business_area"] - ) - .first() - .id - ) - - if incompatible_roles: - for business_area, roles in incompatible_roles.items(): - self.message_user( - request, f"Roles in {business_area} are incompatible: {', '.join(roles)}", messages.ERROR - ) - - if business_area_role_form_set_is_valid and not incompatible_roles: - if ba_partner_through_to_be_deleted: - BusinessAreaPartnerThrough.objects.filter(pk__in=ba_partner_through_to_be_deleted).delete() - for ba_partner_through, areas in ba_partner_through_data.items(): - ba_partner_through.roles.add(*areas) - - return HttpResponseRedirect(reverse("admin:account_partner_change", args=[pk])) - - context["business_area_role_formset"] = business_area_role_form_set - - return TemplateResponse(request, "admin/account/parent/permissions.html", context) + # TODO: perm - not needed but replace with LimitAreasForPartner + # @button(enabled=lambda obj: obj.original.is_editable) + # def permissions(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: + # context = self.get_common_context(request, pk, title="Partner permissions") + # partner: account_models.Partner = context["original"] + # user_can_add_ba_to_partner = request.user.can_add_business_area_to_partner() + # role_assignment_list = partner.role_assignments.all() + # context["can_add_business_area_to_partner"] = user_can_add_ba_to_partner + # + # BusinessAreaRoleFormSet = formset_factory( + # business_area_role_form_custom_query(partner.allowed_business_areas.all()), + # extra=0, + # can_delete=True, + # ) + # if request.method == "GET": + # business_area_role_data = [] + # for role_assignment in role_assignment_list: + # print("dassad") + # print(role_assignment.__dict__) + # business_area_role_data.append( + # {"business_area": role_assignment.business_area_id, "program": role_assignment.program.id if role_assignment.program else None, "role": role_assignment.role, "expiry_date": str(role_assignment.expiry_date)} + # ) + # business_area_role_form_set = BusinessAreaRoleFormSet( + # initial=business_area_role_data, prefix="business_area_role" + # ) + # else: + # business_area_role_form_set = BusinessAreaRoleFormSet(request.POST or None, prefix="business_area_role") + # role_assignment_data_be_deleted = [] + # + # business_area_role_form_set_is_valid = business_area_role_form_set.is_valid() + # if user_can_add_ba_to_partner and business_area_role_form_set_is_valid: + # for form in business_area_role_form_set.cleaned_data: + # if form and not form["DELETE"]: + # business_area_id = str(form["business_area"].id) + # program_id = str(form["program"].id) if form["program"] else None + # role_id = str(form["role"].id) + # + # RoleAssignment.objects.get_or_create( + # partner=partner, + # business_area_id=business_area_id, + # program_id=program_id, + # role_id=role_id, + # ) + # elif form["DELETE"]: + # role_assignment_data_be_deleted.append( + # RoleAssignment.objects.filter( + # partner=partner, business_area=form["business_area"], program=form["program"], role=form["role"] + # ) + # .first() + # .id + # ) + # + # if business_area_role_form_set_is_valid: + # if role_assignment_data_be_deleted: + # RoleAssignment.objects.filter(pk__in=role_assignment_data_be_deleted).delete() + # + # return HttpResponseRedirect(reverse("admin:account_partner_change", args=[pk])) + # + # context["business_area_role_formset"] = business_area_role_form_set + # + # return TemplateResponse(request, "admin/account/parent/permissions.html", context) diff --git a/src/hct_mis_api/apps/account/admin/user_role.py b/src/hct_mis_api/apps/account/admin/user_role.py index d4f409c6cf..fe95a03c40 100644 --- a/src/hct_mis_api/apps/account/admin/user_role.py +++ b/src/hct_mis_api/apps/account/admin/user_role.py @@ -14,7 +14,8 @@ RoleAssignmentAdminForm, RoleAssignmentInlineFormSet, ) -from hct_mis_api.apps.account.models import Role +from hct_mis_api.apps.account.models import Partner, Role +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.utils.admin import HOPEModelAdminBase logger = logging.getLogger(__name__) @@ -22,14 +23,31 @@ class RoleAssignmentInline(admin.TabularInline): model = account_models.RoleAssignment - fields = ["business_area", "role", "expiry_date"] + fields = ["business_area", "program", "role", "expiry_date"] extra = 0 formset = RoleAssignmentInlineFormSet + def formfield_for_foreignkey(self, db_field: Any, request=None, **kwargs: Any) -> Any: + if db_field.name == "business_area": + partner_id = request.resolver_match.kwargs.get("object_id") + + if partner_id and partner_id.isdigit(): + partner = Partner.objects.get(id=partner_id) + kwargs["queryset"] = BusinessArea.objects.filter( + id__in=partner.allowed_business_areas.all().values("id"), + is_split=False, + ) + else: + kwargs["queryset"] = BusinessArea.objects.filter(is_split=False) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def has_change_permission(self, request: HttpRequest, obj: Optional[Any] = None) -> bool: + return request.user.can_add_business_area_to_partner() + @admin.register(account_models.RoleAssignment) class RoleAssignmentAdmin(HOPEModelAdminBase): - list_display = ("user", "role", "business_area") + list_display = ("user", "partner", "role", "business_area", "program") form = RoleAssignmentAdminForm autocomplete_fields = ("role",) raw_id_fields = ("user", "business_area", "role") @@ -40,6 +58,7 @@ class RoleAssignmentAdmin(HOPEModelAdminBase): ) list_filter = ( ("business_area", AutoCompleteFilter), + ("program", AutoCompleteFilter), ("role", AutoCompleteFilter), ("role__subsystem", AllValuesComboFilter), ) @@ -50,7 +69,9 @@ def get_queryset(self, request: HttpRequest) -> QuerySet: .get_queryset(request) .select_related( "business_area", + "program", "user", + "partner", "role", ) ) diff --git a/src/hct_mis_api/apps/account/celery_tasks.py b/src/hct_mis_api/apps/account/celery_tasks.py index dbf8c7935b..916af9b3ec 100644 --- a/src/hct_mis_api/apps/account/celery_tasks.py +++ b/src/hct_mis_api/apps/account/celery_tasks.py @@ -1,5 +1,6 @@ import datetime import logging +from typing import Any from django.db.models import Q from django.utils import timezone @@ -16,7 +17,7 @@ @app.task(bind=True, default_retry_delay=60, max_retries=3) @log_start_and_end @sentry_tags -def invalidate_permissions_cache_for_user_if_expired_role() -> bool: +def invalidate_permissions_cache_for_user_if_expired_role(self: Any) -> bool: # Invalidate permissions cache for users with roles that expired a day before day_ago = timezone.now() - datetime.timedelta(days=1) users = User.objects.filter( diff --git a/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html b/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html index 4bdf639c61..5568faa848 100644 --- a/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html +++ b/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html @@ -21,7 +21,9 @@

Business Area Roles

Business area - Roles + Program + Role + Expiry date Delete? @@ -39,9 +41,17 @@

Business Area Roles

{{ form.business_area.errors.as_ul }} {{ form.business_area }} + + {{ form.program.errors.as_ul }} + {{ form.program }} + - {{ form.roles.errors.as_ul }} - {{ form.roles }} + {{ form.role.errors.as_ul }} + {{ form.role }} + + + {{ form.expiry_date.errors.as_ul }} + {{ form.expiry_date }} {{ form.DELETE }} @@ -53,9 +63,17 @@

Business Area Roles

{{ business_area_role_formset.empty_form.business_area.errors.as_ul }} {{ business_area_role_formset.empty_form.business_area }} + + {{ business_area_role_formset.empty_form.program.errors.as_ul }} + {{ business_area_role_formset.empty_form.program }} + - {{ business_area_role_formset.empty_form.roles.errors.as_ul }} - {{ business_area_role_formset.empty_form.roles }} + {{ business_area_role_formset.empty_form.role.errors.as_ul }} + {{ business_area_role_formset.empty_form.role }} + + + {{ business_area_role_formset.empty_form.expiry_date.errors.as_ul }} + {{ business_area_role_formset.empty_form.expiry_date }} {{ business_area_role_formset.empty_form.DELETE }} From 7c8d0370c9c2a436a4f33b03ab1773b41e7a237a Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Mon, 13 Jan 2025 19:12:16 +0100 Subject: [PATCH 016/208] additional validations for models, admin page for partner's role assignments with validation against allowed BAs, tests --- src/hct_mis_api/apps/account/admin/partner.py | 35 +++++- src/hct_mis_api/apps/account/models.py | 31 +++++- tests/unit/apps/account/test_models.py | 105 +++++++++++++++++- 3 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/hct_mis_api/apps/account/admin/partner.py b/src/hct_mis_api/apps/account/admin/partner.py index da543c4d4f..c4e0fe03a1 100644 --- a/src/hct_mis_api/apps/account/admin/partner.py +++ b/src/hct_mis_api/apps/account/admin/partner.py @@ -1,8 +1,9 @@ from collections import defaultdict from typing import TYPE_CHECKING, Any, Optional, Sequence, Type, Union - +from django.utils.translation import gettext_lazy as _ from django import forms from django.contrib import admin, messages +from django.core.exceptions import ValidationError from django.forms import CheckboxSelectMultiple, ModelForm, formset_factory from django.http import HttpRequest, HttpResponseRedirect from django.template.response import TemplateResponse @@ -10,10 +11,9 @@ from django.utils.html import format_html from admin_extra_buttons.decorators import button - from hct_mis_api.apps.account import models as account_models from hct_mis_api.apps.account.admin.user_role import RoleAssignmentInline -from hct_mis_api.apps.account.models import IncompatibleRoles, Role, RoleAssignment +from hct_mis_api.apps.account.models import IncompatibleRoles, Role, RoleAssignment, Partner from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.models import Program @@ -49,8 +49,37 @@ class ProgramAreaForm(forms.Form): areas = TreeNodeMultipleChoiceField(queryset=Area.objects.all(), widget=CheckboxSelectMultiple(), required=False) +class PartnerAdminForm(forms.ModelForm): + class Meta: + model = Partner + fields = ['name', 'allowed_business_areas', 'is_un', 'parent'] + + def clean_allowed_business_areas(self): + # Get the original allowed business areas for the partner + partner = self.instance + previous_allowed_ba = set(partner.allowed_business_areas.all()) + + # Get the allowed business from form submission + current_allowed_ba = set(self.cleaned_data.get('allowed_business_areas')) + + # Identify which business areas were removed + removed_ba = previous_allowed_ba - current_allowed_ba + + # Check if there are any removed business areas with existing role assignments + for ba in removed_ba: + if RoleAssignment.objects.filter(partner=partner, business_area=ba).exists(): + # Add a form error instead of raising a ValidationError + self.add_error( + 'allowed_business_areas', + f"You cannot remove {ba} because there are existing role assignments for this business area." + ) + + return self.cleaned_data.get('allowed_business_areas', []) + + @admin.register(account_models.Partner) class PartnerAdmin(HopeModelAdminMixin, admin.ModelAdmin): + form = PartnerAdminForm list_filter = ("is_un", "parent") search_fields = ("name",) readonly_fields = ("sub_partners",) diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index e3f267b0dc..1a0922c114 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -354,11 +354,35 @@ class RoleAssignment(NaturalKeyModel, TimeStampedUUIDModel): class Meta: constraints = [ - # either user or partner should be assigned; not both + # Either user or partner should be assigned; not both models.CheckConstraint( check=Q(user__isnull=False, partner__isnull=True) | Q(user__isnull=True, partner__isnull=False), name="user_or_partner_not_both", ), + # Unique constraint for user + role + business_area + program when program is NOT NULL + models.UniqueConstraint( + fields=["user", "role", "business_area", "program"], + name="unique_user_role_business_area_program", + condition=Q(user__isnull=False), + ), + # Unique constraint for user + role + business_area when program is NULL + models.UniqueConstraint( + fields=["user", "role", "business_area"], + name="unique_user_role_business_area_no_program", + condition=Q(user__isnull=False, program__isnull=True), + ), + # Unique constraint for partner + role + business_area + program when program is NOT NULL + models.UniqueConstraint( + fields=["partner", "role", "business_area", "program"], + name="unique_partner_role_business_area_program", + condition=Q(partner__isnull=False), + ), + # Unique constraint for partner + role + business_area when program is NULL + models.UniqueConstraint( + fields=["partner", "role", "business_area"], + name="unique_partner_role_business_area_no_program", + condition=Q(partner__isnull=False, program__isnull=True), + ), ] def clean(self) -> None: @@ -370,6 +394,11 @@ def clean(self) -> None: # Ensure partner can only be assigned roles that have flag is_available_for_partner as True if self.partner and self.role and not self.role.is_available_for_partner: errors.append("Partner can only be assigned roles that are available for partners.") + # Validate that business_area is within the partner's allowed_business_areas + if self.partner: + if self.business_area not in self.partner.allowed_business_areas.all(): + errors.append(f"{self.business_area} is not within the allowed business areas for {self.partner}.") + if errors: raise ValidationError(errors) diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py index 241a265a9f..42d0f36737 100644 --- a/tests/unit/apps/account/test_models.py +++ b/tests/unit/apps/account/test_models.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from django.db import IntegrityError from django.test import TransactionTestCase from hct_mis_api.apps.account.fixtures import ( @@ -8,7 +9,7 @@ UserFactory, ) from hct_mis_api.apps.account.models import RoleAssignment -from hct_mis_api.apps.core.fixtures import create_afghanistan +from hct_mis_api.apps.core.fixtures import create_afghanistan, create_ukraine from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -26,6 +27,7 @@ def setUp(self) -> None: ) self.user = UserFactory(first_name="Test", last_name="User") self.partner = PartnerFactory(name="Partner") + self.partner.allowed_business_areas.add(self.business_area) self.program1 = ProgramFactory( business_area=self.business_area, name="Program 1", @@ -104,3 +106,104 @@ def test_is_available_for_partner_flag(self) -> None: business_area=self.business_area, ) self.assertIsNotNone(role_assignment_user.id) + + def test_partner_role_in_business_area_vs_allowed_business_areas(self) -> None: + # Possible to create RoleAssignment for a business area that is allowed for the partner + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=self.role, + business_area=self.business_area, + ) + + # Partner with a different business area should raise a validation error + not_allowed_ba = create_ukraine() + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=self.role, + business_area=not_allowed_ba, + ) + self.assertIn( + f"{not_allowed_ba} is not within the allowed business areas for {self.partner}.", + str(ve_context.exception), + ) + + # Validation not relevant for user + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=self.role, + business_area=not_allowed_ba, + ) + + def test_unique_user_role_business_area_program_constraint(self) -> None: + # Creating a second role assignment with the same user, role, business area, and program should raise an error + role_new = RoleFactory(name="Test Role Duplicate") + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=role_new, + business_area=self.business_area, + program=self.program1, + ) + with self.assertRaises(IntegrityError): + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=role_new, + business_area=self.business_area, + program=self.program1, + ) + + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=role_new, + business_area=self.business_area, + program=None, + ) + with self.assertRaises(IntegrityError): + RoleAssignment.objects.create( + user=self.user, + partner=None, + role=role_new, + business_area=self.business_area, + program=None, + ) + + def test_unique_partner_role_business_area_program_constraint(self) -> None: + # Creating a second role assignment with the same partner, role, business area, and program should raise an error + role_new = RoleFactory(name="Test Role Duplicate") + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=role_new, + business_area=self.business_area, + program=self.program1, + ) + with self.assertRaises(IntegrityError): + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=role_new, + business_area=self.business_area, + program=self.program1, + ) + + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=role_new, + business_area=self.business_area, + program=None, + ) + with self.assertRaises(IntegrityError): + RoleAssignment.objects.create( + user=None, + partner=self.partner, + role=role_new, + business_area=self.business_area, + program=None, + ) From c523cdd84a8f9624084e2888910efb3e6de2f134 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Wed, 15 Jan 2025 11:53:28 +0100 Subject: [PATCH 017/208] allowed_partners in ba admin, additional validations for area limits, role_assignments and partners, tests --- src/hct_mis_api/apps/account/admin/partner.py | 129 +----------------- .../apps/account/migrations/0005_migration.py | 21 +++ .../apps/account/migrations/0006_migration.py | 21 +++ src/hct_mis_api/apps/account/models.py | 32 +++-- src/hct_mis_api/apps/account/signals.py | 6 +- .../admin/account/parent/permissions.html | 127 ----------------- src/hct_mis_api/apps/core/admin.py | 61 ++++++++- .../core/admin/allowed_partners.html | 19 +++ tests/unit/apps/account/test_models.py | 94 ++++++++++++- 9 files changed, 242 insertions(+), 268 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0005_migration.py create mode 100644 src/hct_mis_api/apps/account/migrations/0006_migration.py delete mode 100644 src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html create mode 100644 src/hct_mis_api/apps/core/templates/core/admin/allowed_partners.html diff --git a/src/hct_mis_api/apps/account/admin/partner.py b/src/hct_mis_api/apps/account/admin/partner.py index c4e0fe03a1..2caaed9395 100644 --- a/src/hct_mis_api/apps/account/admin/partner.py +++ b/src/hct_mis_api/apps/account/admin/partner.py @@ -1,85 +1,32 @@ -from collections import defaultdict -from typing import TYPE_CHECKING, Any, Optional, Sequence, Type, Union -from django.utils.translation import gettext_lazy as _ +from typing import Any, Optional, Sequence, Type from django import forms -from django.contrib import admin, messages -from django.core.exceptions import ValidationError -from django.forms import CheckboxSelectMultiple, ModelForm, formset_factory -from django.http import HttpRequest, HttpResponseRedirect -from django.template.response import TemplateResponse +from django.contrib import admin +from django.forms import CheckboxSelectMultiple, ModelForm +from django.http import HttpRequest from django.urls import reverse from django.utils.html import format_html -from admin_extra_buttons.decorators import button from hct_mis_api.apps.account import models as account_models from hct_mis_api.apps.account.admin.user_role import RoleAssignmentInline -from hct_mis_api.apps.account.models import IncompatibleRoles, Role, RoleAssignment, Partner -from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from hct_mis_api.apps.core.models import BusinessArea from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.program.models import Program from hct_mis_api.apps.utils.admin import HopeModelAdminMixin from mptt.forms import TreeNodeMultipleChoiceField -if TYPE_CHECKING: - from django.db.models.query import QuerySet - def can_add_business_area_to_partner(request: Any, *args: Any, **kwargs: Any) -> bool: return request.user.can_add_business_area_to_partner() -# TODO: perm - not needed but replace with LimitAreasForPartner -# def business_area_role_form_custom_query(queryset: "QuerySet") -> Any: -# class BusinessAreaRoleForm(forms.Form): -# business_area = forms.ModelChoiceField(queryset=BusinessArea.objects.all(), required=True) -# program = forms.ModelChoiceField(queryset=Program.objects.all(), required=False) -# role = forms.ModelChoiceField(queryset=Role.objects.filter(is_available_for_partner=True).all(), required=True) -# expiry_date = forms.DateField(required=False) -# -# def __init__(self, *args: Any, **kwargs: Any) -> None: -# super().__init__(*args, **kwargs) -# self.fields["business_area"].queryset = queryset -# -# return BusinessAreaRoleForm - - class ProgramAreaForm(forms.Form): business_area = forms.ModelChoiceField(queryset=BusinessArea.objects.all(), required=True) program = forms.ModelChoiceField(queryset=Program.objects.all(), required=True) areas = TreeNodeMultipleChoiceField(queryset=Area.objects.all(), widget=CheckboxSelectMultiple(), required=False) -class PartnerAdminForm(forms.ModelForm): - class Meta: - model = Partner - fields = ['name', 'allowed_business_areas', 'is_un', 'parent'] - - def clean_allowed_business_areas(self): - # Get the original allowed business areas for the partner - partner = self.instance - previous_allowed_ba = set(partner.allowed_business_areas.all()) - - # Get the allowed business from form submission - current_allowed_ba = set(self.cleaned_data.get('allowed_business_areas')) - - # Identify which business areas were removed - removed_ba = previous_allowed_ba - current_allowed_ba - - # Check if there are any removed business areas with existing role assignments - for ba in removed_ba: - if RoleAssignment.objects.filter(partner=partner, business_area=ba).exists(): - # Add a form error instead of raising a ValidationError - self.add_error( - 'allowed_business_areas', - f"You cannot remove {ba} because there are existing role assignments for this business area." - ) - - return self.cleaned_data.get('allowed_business_areas', []) - - @admin.register(account_models.Partner) class PartnerAdmin(HopeModelAdminMixin, admin.ModelAdmin): - form = PartnerAdminForm list_filter = ("is_un", "parent") search_fields = ("name",) readonly_fields = ("sub_partners",) @@ -88,7 +35,7 @@ class PartnerAdmin(HopeModelAdminMixin, admin.ModelAdmin): "sub_partners", "is_un", ) - filter_horizontal = ("allowed_business_areas",) + exclude = ("allowed_business_areas",) inlines = (RoleAssignmentInline,) def sub_partners(self, obj: Any) -> Optional[str]: @@ -109,8 +56,6 @@ def get_readonly_fields(self, request: HttpRequest, obj: Optional[account_models additional_fields = [] if obj and obj.is_unicef: additional_fields.append("name") - if not request.user.has_perm("account.can_change_allowed_business_areas"): - additional_fields.append("allowed_business_areas") return list(super().get_readonly_fields(request, obj)) + additional_fields def get_form( @@ -127,65 +72,3 @@ def get_form( form.base_fields["parent"].queryset = queryset return form - - # TODO: perm - not needed but replace with LimitAreasForPartner - # @button(enabled=lambda obj: obj.original.is_editable) - # def permissions(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: - # context = self.get_common_context(request, pk, title="Partner permissions") - # partner: account_models.Partner = context["original"] - # user_can_add_ba_to_partner = request.user.can_add_business_area_to_partner() - # role_assignment_list = partner.role_assignments.all() - # context["can_add_business_area_to_partner"] = user_can_add_ba_to_partner - # - # BusinessAreaRoleFormSet = formset_factory( - # business_area_role_form_custom_query(partner.allowed_business_areas.all()), - # extra=0, - # can_delete=True, - # ) - # if request.method == "GET": - # business_area_role_data = [] - # for role_assignment in role_assignment_list: - # print("dassad") - # print(role_assignment.__dict__) - # business_area_role_data.append( - # {"business_area": role_assignment.business_area_id, "program": role_assignment.program.id if role_assignment.program else None, "role": role_assignment.role, "expiry_date": str(role_assignment.expiry_date)} - # ) - # business_area_role_form_set = BusinessAreaRoleFormSet( - # initial=business_area_role_data, prefix="business_area_role" - # ) - # else: - # business_area_role_form_set = BusinessAreaRoleFormSet(request.POST or None, prefix="business_area_role") - # role_assignment_data_be_deleted = [] - # - # business_area_role_form_set_is_valid = business_area_role_form_set.is_valid() - # if user_can_add_ba_to_partner and business_area_role_form_set_is_valid: - # for form in business_area_role_form_set.cleaned_data: - # if form and not form["DELETE"]: - # business_area_id = str(form["business_area"].id) - # program_id = str(form["program"].id) if form["program"] else None - # role_id = str(form["role"].id) - # - # RoleAssignment.objects.get_or_create( - # partner=partner, - # business_area_id=business_area_id, - # program_id=program_id, - # role_id=role_id, - # ) - # elif form["DELETE"]: - # role_assignment_data_be_deleted.append( - # RoleAssignment.objects.filter( - # partner=partner, business_area=form["business_area"], program=form["program"], role=form["role"] - # ) - # .first() - # .id - # ) - # - # if business_area_role_form_set_is_valid: - # if role_assignment_data_be_deleted: - # RoleAssignment.objects.filter(pk__in=role_assignment_data_be_deleted).delete() - # - # return HttpResponseRedirect(reverse("admin:account_partner_change", args=[pk])) - # - # context["business_area_role_formset"] = business_area_role_form_set - # - # return TemplateResponse(request, "admin/account/parent/permissions.html", context) diff --git a/src/hct_mis_api/apps/account/migrations/0005_migration.py b/src/hct_mis_api/apps/account/migrations/0005_migration.py new file mode 100644 index 0000000000..e0fba356a6 --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0005_migration.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2025-01-13 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0004_migration'), + ] + + operations = [ + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'role', 'business_area', 'program'), name='unique_user_role_business_area_program'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('partner__isnull', False)), fields=('partner', 'role', 'business_area', 'program'), name='unique_partner_role_business_area_program'), + ), + ] diff --git a/src/hct_mis_api/apps/account/migrations/0006_migration.py b/src/hct_mis_api/apps/account/migrations/0006_migration.py new file mode 100644 index 0000000000..fc60274b56 --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0006_migration.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2025-01-13 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_migration'), + ] + + operations = [ + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('program__isnull', True), ('user__isnull', False)), fields=('user', 'role', 'business_area'), name='unique_user_role_business_area_no_program'), + ), + migrations.AddConstraint( + model_name='roleassignment', + constraint=models.UniqueConstraint(condition=models.Q(('partner__isnull', False), ('program__isnull', True)), fields=('partner', 'role', 'business_area'), name='unique_partner_role_business_area_no_program'), + ), + ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index 1a0922c114..fddaf8966b 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -4,6 +4,7 @@ from uuid import UUID from django import forms +from django.apps import apps from django.conf import settings from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.auth.models import AbstractUser, Group, Permission @@ -59,19 +60,17 @@ class Partner(LimitBusinessAreaModelMixin, MPTTModel): verbose_name=_("Parent"), ) is_un = models.BooleanField(verbose_name="U.N.", default=False) - """ - permissions structure - { - "business_area_id": { - "roles": ["role_id_1", "role_id_2"], - "programs": {"program_id":["admin_id"]} - } - } - """ def __str__(self) -> str: return f"{self.name} [Sub-Partner of {self.parent.name}]" if self.parent else self.name + def save(self, *args, **kwargs): + # Partner cannot be a parent if it has RoleAssignments + if self.parent: + if RoleAssignment.objects.filter(partner=self.parent).exists(): + raise ValidationError(f"{self.parent} cannot become a parent as it has RoleAssignments.") + super().save(*args, **kwargs) + @property def is_child(self) -> bool: return self.parent is None @@ -318,6 +317,7 @@ class Meta: ("restrict_help_desk", "Limit fields to be editable for help desk"), ("can_reindex_programs", "Can reindex programs"), ("can_add_business_area_to_partner", "Can add business area to partner"), + ("can_change_allowed_partners", "Can change allowed partners"), ) @@ -394,10 +394,13 @@ def clean(self) -> None: # Ensure partner can only be assigned roles that have flag is_available_for_partner as True if self.partner and self.role and not self.role.is_available_for_partner: errors.append("Partner can only be assigned roles that are available for partners.") - # Validate that business_area is within the partner's allowed_business_areas if self.partner: + # Validate that business_area is within the partner's allowed_business_areas if self.business_area not in self.partner.allowed_business_areas.all(): errors.append(f"{self.business_area} is not within the allowed business areas for {self.partner}.") + # Only partners that are not parents can have role assignments + if self.partner.is_parent: + errors.append(f"{self.partner} is a parent partner and cannot have role assignments.") if errors: raise ValidationError(errors) @@ -421,6 +424,15 @@ class AdminAreaLimitedTo(TimeStampedUUIDModel): program = models.ForeignKey("program.Program", related_name="admin_area_limits", on_delete=models.CASCADE) areas = models.ManyToManyField("geo.Area", related_name="admin_area_limits", blank=True) + def clean(self) -> None: + if self.program.partner_access != self.program.SELECTED_PARTNERS_ACCESS: + raise ValidationError( + f"Area limits cannot be set for programs with {self.program.partner_access} access." + ) + + def save(self, *args: Any, **kwargs: Any) -> None: + self.clean() + super().save(*args, **kwargs) class UserGroup(NaturalKeyModel, models.Model): business_area = models.ForeignKey("core.BusinessArea", related_name="user_groups", on_delete=models.CASCADE) diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 06d88e5e97..8858762b26 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -11,7 +11,7 @@ from hct_mis_api.api.caches import get_or_create_cache_key from hct_mis_api.apps.account.caches import get_user_permissions_version_key from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User -from hct_mis_api.apps.core.models import BusinessArea, BusinessAreaPartnerThrough +from hct_mis_api.apps.core.models import BusinessArea @receiver(post_save, sender=RoleAssignment) @@ -43,7 +43,7 @@ def post_save_user(sender: Any, instance: User, created: bool, *args: Any, **kwa def allowed_business_areas_changed(sender: Any, instance: Partner, action: str, pk_set: set, **kwargs: Any) -> None: if action == "post_remove": removed_business_areas_ids = pk_set - BusinessAreaPartnerThrough.objects.filter( + RoleAssignment.objects.filter( partner=instance, business_area_id__in=removed_business_areas_ids ).delete() @@ -52,7 +52,7 @@ def allowed_business_areas_changed(sender: Any, instance: Partner, action: str, elif action == "post_clear": removed_business_areas = getattr(instance, "_removed_business_areas", []) - BusinessAreaPartnerThrough.objects.filter(partner=instance, business_area__in=removed_business_areas).delete() + RoleAssignment.objects.filter(partner=instance, business_area__in=removed_business_areas).delete() # Signals for permissions caches invalidation diff --git a/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html b/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html deleted file mode 100644 index 5568faa848..0000000000 --- a/src/hct_mis_api/apps/account/templates/admin/account/parent/permissions.html +++ /dev/null @@ -1,127 +0,0 @@ -{% extends "admin_extra_buttons/action_page.html" %} -{% load i18n admin_urls static admin_modify mptt_tags engine %} - -{% block extrahead %}{{ block.super }} - - - - {{ media }} - {{ business_area_role_formset.media }} -{% endblock %} -{% block action-content %} -
- {% csrf_token %} - {% if can_add_business_area_to_partner %} -
- -
- {% endif %} - -
-{% endblock %} -{% block admin_change_form_document_ready %}{{ block.super }} - - -{% endblock %} diff --git a/src/hct_mis_api/apps/core/admin.py b/src/hct_mis_api/apps/core/admin.py index 91fd4c590e..d477b8678e 100644 --- a/src/hct_mis_api/apps/core/admin.py +++ b/src/hct_mis_api/apps/core/admin.py @@ -1,7 +1,11 @@ import csv import logging from io import StringIO -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union, Sequence + +from django.contrib.admin.widgets import FilteredSelectMultiple + +from hct_mis_api.apps.account import models as account_models from django import forms from django.contrib import admin, messages @@ -22,7 +26,7 @@ HttpResponsePermanentRedirect, HttpResponseRedirect, ) -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.template.defaultfilters import slugify from django.template.response import TemplateResponse from django.urls import reverse @@ -42,7 +46,7 @@ from jsoneditor.forms import JSONEditor from xlrd import XLRDError -from hct_mis_api.apps.account.models import Role, User +from hct_mis_api.apps.account.models import Role, User, Partner, RoleAssignment from hct_mis_api.apps.administration.widgets import JsonWidget from hct_mis_api.apps.core.celery_tasks import ( upload_new_kobo_template_and_update_flex_fields_task, @@ -265,6 +269,57 @@ def split_business_area(self, request: HttpRequest, pk: "UUID") -> Union[HttpRes return TemplateResponse(request, "core/admin/split_ba.html", context) + @button(label="Partners", permission="account.can_change_allowed_partners") + def allowed_partners(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: + business_area = get_object_or_404(BusinessArea, pk=pk) + + class AllowedPartnersForm(forms.Form): + partners = forms.ModelMultipleChoiceField( + queryset=Partner.objects.all(), + required=False, + widget=FilteredSelectMultiple("Partners", is_stacked=False) + ) + + if request.method == "POST": + form = AllowedPartnersForm(request.POST) + if form.is_valid(): + selected_partners = form.cleaned_data["partners"] + # Get the current allowed partners for the business area + previous_allowed_partners = set(Partner.objects.filter(allowed_business_areas=business_area)) + + # Identify which partners were removed + removed_partners = previous_allowed_partners - set(selected_partners) + # Check if there are any removed partners with existing role assignments in this business area + for partner in removed_partners: + if RoleAssignment.objects.filter(partner=partner, business_area=business_area).exists(): + self.message_user( + request, + f"You cannot remove {partner.name} because it has existing role assignments in this business area.", + messages.ERROR, + ) + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + + for partner in Partner.objects.all(): + if partner in selected_partners: + partner.allowed_business_areas.add(business_area) + else: + partner.allowed_business_areas.remove(business_area) + messages.success(request, "Allowed partners successfully updated.") + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + + else: + form = AllowedPartnersForm(initial={ + "partners": Partner.objects.filter(allowed_business_areas=business_area) + }) + + context = self.get_common_context(request, pk) + context.update({ + "business_area": business_area, + "form": form, + }) + + return TemplateResponse(request, "core/admin/allowed_partners.html", context) + def _get_doap_matrix(self, obj: Any) -> List[Any]: matrix = [] ca_roles = Role.objects.filter(subsystem=Role.CA).order_by("name").values_list("name", flat=True) diff --git a/src/hct_mis_api/apps/core/templates/core/admin/allowed_partners.html b/src/hct_mis_api/apps/core/templates/core/admin/allowed_partners.html new file mode 100644 index 0000000000..d769f3ff2d --- /dev/null +++ b/src/hct_mis_api/apps/core/templates/core/admin/allowed_partners.html @@ -0,0 +1,19 @@ +{% extends "admin_extra_buttons/action_page.html" %} +{% load i18n admin_urls static admin_modify mptt_tags engine %} + +{% block action-content %} +
+ +


+

Allowed Partners for {{ business_area.name }}

+ +
+ {% csrf_token %} + {{ form.media }} + {{ form.partners }} +
+ +
+
+ +{% endblock %} \ No newline at end of file diff --git a/tests/unit/apps/account/test_models.py b/tests/unit/apps/account/test_models.py index 42d0f36737..40725745ee 100644 --- a/tests/unit/apps/account/test_models.py +++ b/tests/unit/apps/account/test_models.py @@ -6,10 +6,11 @@ PartnerFactory, RoleAssignmentFactory, RoleFactory, - UserFactory, + UserFactory, AdminAreaLimitedToFactory, ) -from hct_mis_api.apps.account.models import RoleAssignment +from hct_mis_api.apps.account.models import RoleAssignment, AdminAreaLimitedTo from hct_mis_api.apps.core.fixtures import create_afghanistan, create_ukraine +from hct_mis_api.apps.geo.fixtures import AreaFactory from hct_mis_api.apps.program.fixtures import ProgramFactory from hct_mis_api.apps.program.models import Program @@ -44,6 +45,8 @@ def setUp(self) -> None: business_area=self.business_area, ) + self.area_1 = AreaFactory(name="Area 1", p_code="AREA1") + def test_user_or_partner(self) -> None: # Either user or partner must be set with self.assertRaises(ValidationError) as ve_context: @@ -207,3 +210,90 @@ def test_unique_partner_role_business_area_program_constraint(self) -> None: business_area=self.business_area, program=None, ) + + def test_role_assignment_for_parent_partner(self) -> None: + parent_partner = PartnerFactory(name="Parent Partner") + child_partner = PartnerFactory(name="Child Partner", parent=parent_partner) + parent_partner.allowed_business_areas.add(self.business_area) + child_partner.allowed_business_areas.add(self.business_area) + + # Can create RoleAssignment for child partner + RoleAssignment.objects.create( + user=None, + partner=child_partner, + role=self.role, + business_area=self.business_area, + ) + + with self.assertRaises(ValidationError) as ve_context: + RoleAssignment.objects.create( + user=None, + partner=parent_partner, + role=self.role, + business_area=self.business_area, + ) + self.assertIn( + f"{parent_partner} is a parent partner and cannot have role assignments.", + str(ve_context.exception), + ) + + def test_parent_partner_with_role_assignment(self) -> None: + parent_partner = PartnerFactory(name="Parent Partner") + parent_partner.allowed_business_areas.add(self.business_area) + + # Role for the Partner + RoleAssignment.objects.create( + user=None, + partner=parent_partner, + role=self.role, + business_area=self.business_area, + ) + + with self.assertRaises(ValidationError) as ve_context: + PartnerFactory(name="Child Partner", parent=parent_partner) + + self.assertIn( + f"{parent_partner} cannot become a parent as it has RoleAssignments.", + str(ve_context.exception), + ) + + def test_area_limits_for_program_with_selected_partner_access(self) -> None: + # Possible to have area limits for a program with selected partner access + self.program1.partner_access = Program.SELECTED_PARTNERS_ACCESS + self.program1.save() + + AdminAreaLimitedToFactory( + partner=self.partner, + program=self.program1, + areas=[self.area_1] + ) + + def test_area_limits_for_program_with_all_partner_access(self) -> None: + # Not possible to have area limits for a program with ALL_PARTNERS_ACCESS + self.program1.partner_access = Program.ALL_PARTNERS_ACCESS + self.program1.save() + with self.assertRaises(ValidationError) as ve_context: + AdminAreaLimitedToFactory( + partner=self.partner, + program=self.program1, + areas=[self.area_1] + ) + self.assertIn( + f"Area limits cannot be set for programs with {self.program1.partner_access} access.", + str(ve_context.exception), + ) + + def test_area_limits_for_program_with_none_partner_access(self) -> None: + # Not possible to have area limits for a program with NONE_PARTNERS_ACCESS + self.program1.partner_access = Program.NONE_PARTNERS_ACCESS + self.program1.save() + with self.assertRaises(ValidationError) as ve_context: + AdminAreaLimitedToFactory( + partner=self.partner, + program=self.program1, + areas=[self.area_1] + ) + self.assertIn( + f"Area limits cannot be set for programs with {self.program1.partner_access} access.", + str(ve_context.exception), + ) From 97f08c7b5be01757aa97e246a578af5a7e72fd52 Mon Sep 17 00:00:00 2001 From: Paulina Kujawa Date: Fri, 17 Jan 2025 00:24:04 +0100 Subject: [PATCH 018/208] admin changes - area limits on Program, Allowed BAs, new validations --- src/hct_mis_api/apps/account/admin/partner.py | 1 + .../apps/account/admin/user_role.py | 18 ++++++- .../apps/account/migrations/0007_migration.py | 17 ++++++ src/hct_mis_api/apps/account/models.py | 9 ++-- src/hct_mis_api/apps/account/signals.py | 4 +- src/hct_mis_api/apps/core/admin.py | 37 ++++++------- src/hct_mis_api/apps/program/admin.py | 54 +++++++++---------- ...r_access.html => program_area_limits.html} | 4 +- ...html => program_area_limits_readonly.html} | 2 +- tests/unit/apps/account/test_models.py | 23 +++----- 10 files changed, 92 insertions(+), 77 deletions(-) create mode 100644 src/hct_mis_api/apps/account/migrations/0007_migration.py rename src/hct_mis_api/apps/program/templates/admin/program/program/{program_partner_access.html => program_area_limits.html} (98%) rename src/hct_mis_api/apps/program/templates/admin/program/program/{program_partner_access_readonly.html => program_area_limits_readonly.html} (99%) diff --git a/src/hct_mis_api/apps/account/admin/partner.py b/src/hct_mis_api/apps/account/admin/partner.py index 2caaed9395..4acd6c9cfa 100644 --- a/src/hct_mis_api/apps/account/admin/partner.py +++ b/src/hct_mis_api/apps/account/admin/partner.py @@ -1,4 +1,5 @@ from typing import Any, Optional, Sequence, Type + from django import forms from django.contrib import admin from django.forms import CheckboxSelectMultiple, ModelForm diff --git a/src/hct_mis_api/apps/account/admin/user_role.py b/src/hct_mis_api/apps/account/admin/user_role.py index fe95a03c40..608df8f6fb 100644 --- a/src/hct_mis_api/apps/account/admin/user_role.py +++ b/src/hct_mis_api/apps/account/admin/user_role.py @@ -27,7 +27,7 @@ class RoleAssignmentInline(admin.TabularInline): extra = 0 formset = RoleAssignmentInlineFormSet - def formfield_for_foreignkey(self, db_field: Any, request=None, **kwargs: Any) -> Any: + def formfield_for_foreignkey(self, db_field: Any, request: Any = None, **kwargs: Any) -> Any: if db_field.name == "business_area": partner_id = request.resolver_match.kwargs.get("object_id") @@ -41,8 +41,22 @@ def formfield_for_foreignkey(self, db_field: Any, request=None, **kwargs: Any) - kwargs["queryset"] = BusinessArea.objects.filter(is_split=False) return super().formfield_for_foreignkey(db_field, request, **kwargs) + def has_add_permission(self, request: HttpRequest, obj: Optional[Any] = None) -> bool: + if isinstance(obj, Partner): + if obj.is_parent: + return False # Disable adding if Partner is a parent + return request.user.can_add_business_area_to_partner() + return True + def has_change_permission(self, request: HttpRequest, obj: Optional[Any] = None) -> bool: - return request.user.can_add_business_area_to_partner() + if isinstance(obj, Partner): + return request.user.can_add_business_area_to_partner() + return True + + def has_delete_permission(self, request: HttpRequest, obj: Optional[Any] = None) -> bool: + if isinstance(obj, Partner): + return request.user.can_add_business_area_to_partner() + return True @admin.register(account_models.RoleAssignment) diff --git a/src/hct_mis_api/apps/account/migrations/0007_migration.py b/src/hct_mis_api/apps/account/migrations/0007_migration.py new file mode 100644 index 0000000000..70af0e25f8 --- /dev/null +++ b/src/hct_mis_api/apps/account/migrations/0007_migration.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2025-01-16 23:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0006_migration'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'permissions': (('can_load_from_ad', 'Can load users from ActiveDirectory'), ('can_sync_with_ad', 'Can synchronise user with ActiveDirectory'), ('can_create_kobo_user', 'Can create users in Kobo'), ('can_import_from_kobo', 'Can import and sync users from Kobo'), ('can_upload_to_kobo', 'Can upload CSV file to Kobo'), ('can_debug', 'Can access debug informations'), ('can_inspect', 'Can inspect objects'), ('quick_links', 'Can see quick links in admin'), ('restrict_help_desk', 'Limit fields to be editable for help desk'), ('can_reindex_programs', 'Can reindex programs'), ('can_add_business_area_to_partner', 'Can add business area to partner'), ('can_change_allowed_partners', 'Can change allowed partners'), ('can_change_area_limits', 'Can change area limits'))}, + ), + ] diff --git a/src/hct_mis_api/apps/account/models.py b/src/hct_mis_api/apps/account/models.py index fddaf8966b..8d159f4206 100644 --- a/src/hct_mis_api/apps/account/models.py +++ b/src/hct_mis_api/apps/account/models.py @@ -4,7 +4,6 @@ from uuid import UUID from django import forms -from django.apps import apps from django.conf import settings from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.auth.models import AbstractUser, Group, Permission @@ -64,7 +63,7 @@ class Partner(LimitBusinessAreaModelMixin, MPTTModel): def __str__(self) -> str: return f"{self.name} [Sub-Partner of {self.parent.name}]" if self.parent else self.name - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: # Partner cannot be a parent if it has RoleAssignments if self.parent: if RoleAssignment.objects.filter(partner=self.parent).exists(): @@ -318,6 +317,7 @@ class Meta: ("can_reindex_programs", "Can reindex programs"), ("can_add_business_area_to_partner", "Can add business area to partner"), ("can_change_allowed_partners", "Can change allowed partners"), + ("can_change_area_limits", "Can change area limits"), ) @@ -426,14 +426,13 @@ class AdminAreaLimitedTo(TimeStampedUUIDModel): def clean(self) -> None: if self.program.partner_access != self.program.SELECTED_PARTNERS_ACCESS: - raise ValidationError( - f"Area limits cannot be set for programs with {self.program.partner_access} access." - ) + raise ValidationError(f"Area limits cannot be set for programs with {self.program.partner_access} access.") def save(self, *args: Any, **kwargs: Any) -> None: self.clean() super().save(*args, **kwargs) + class UserGroup(NaturalKeyModel, models.Model): business_area = models.ForeignKey("core.BusinessArea", related_name="user_groups", on_delete=models.CASCADE) user = models.ForeignKey("account.User", related_name="user_groups", on_delete=models.CASCADE) diff --git a/src/hct_mis_api/apps/account/signals.py b/src/hct_mis_api/apps/account/signals.py index 8858762b26..63ef981546 100644 --- a/src/hct_mis_api/apps/account/signals.py +++ b/src/hct_mis_api/apps/account/signals.py @@ -43,9 +43,7 @@ def post_save_user(sender: Any, instance: User, created: bool, *args: Any, **kwa def allowed_business_areas_changed(sender: Any, instance: Partner, action: str, pk_set: set, **kwargs: Any) -> None: if action == "post_remove": removed_business_areas_ids = pk_set - RoleAssignment.objects.filter( - partner=instance, business_area_id__in=removed_business_areas_ids - ).delete() + RoleAssignment.objects.filter(partner=instance, business_area_id__in=removed_business_areas_ids).delete() elif action == "pre_clear": instance._removed_business_areas = list(instance.allowed_business_areas.all()) diff --git a/src/hct_mis_api/apps/core/admin.py b/src/hct_mis_api/apps/core/admin.py index d477b8678e..30c6dd2e19 100644 --- a/src/hct_mis_api/apps/core/admin.py +++ b/src/hct_mis_api/apps/core/admin.py @@ -1,16 +1,13 @@ import csv import logging from io import StringIO -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union, Sequence - -from django.contrib.admin.widgets import FilteredSelectMultiple - -from hct_mis_api.apps.account import models as account_models +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union from django import forms from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter, TabularInline from django.contrib.admin.templatetags.admin_urls import add_preserved_filters +from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.messages import ERROR from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import JSONField @@ -26,7 +23,7 @@ HttpResponsePermanentRedirect, HttpResponseRedirect, ) -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import get_object_or_404, redirect from django.template.defaultfilters import slugify from django.template.response import TemplateResponse from django.urls import reverse @@ -46,7 +43,7 @@ from jsoneditor.forms import JSONEditor from xlrd import XLRDError -from hct_mis_api.apps.account.models import Role, User, Partner, RoleAssignment +from hct_mis_api.apps.account.models import Partner, Role, RoleAssignment, User from hct_mis_api.apps.administration.widgets import JsonWidget from hct_mis_api.apps.core.celery_tasks import ( upload_new_kobo_template_and_update_flex_fields_task, @@ -275,9 +272,11 @@ def allowed_partners(self, request: HttpRequest, pk: int) -> Union[TemplateRespo class AllowedPartnersForm(forms.Form): partners = forms.ModelMultipleChoiceField( - queryset=Partner.objects.all(), + queryset=Partner.objects.exclude( + id__in=Partner.objects.filter(parent__isnull=False).values_list("parent_id", flat=True) + ), required=False, - widget=FilteredSelectMultiple("Partners", is_stacked=False) + widget=FilteredSelectMultiple("Partners", is_stacked=False), ) if request.method == "POST": @@ -297,7 +296,7 @@ class AllowedPartnersForm(forms.Form): f"You cannot remove {partner.name} because it has existing role assignments in this business area.", messages.ERROR, ) - return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + return HttpResponseRedirect(request.get_full_path()) for partner in Partner.objects.all(): if partner in selected_partners: @@ -305,18 +304,20 @@ class AllowedPartnersForm(forms.Form): else: partner.allowed_business_areas.remove(business_area) messages.success(request, "Allowed partners successfully updated.") - return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + return HttpResponseRedirect(request.get_full_path()) else: - form = AllowedPartnersForm(initial={ - "partners": Partner.objects.filter(allowed_business_areas=business_area) - }) + form = AllowedPartnersForm( + initial={"partners": Partner.objects.filter(allowed_business_areas=business_area)} + ) context = self.get_common_context(request, pk) - context.update({ - "business_area": business_area, - "form": form, - }) + context.update( + { + "business_area": business_area, + "form": form, + } + ) return TemplateResponse(request, "core/admin/allowed_partners.html", context) diff --git a/src/hct_mis_api/apps/program/admin.py b/src/hct_mis_api/apps/program/admin.py index 98a4596296..d21c3db506 100644 --- a/src/hct_mis_api/apps/program/admin.py +++ b/src/hct_mis_api/apps/program/admin.py @@ -14,12 +14,12 @@ from adminfilters.filters import ChoicesFieldComboFilter from adminfilters.mixin import AdminAutoCompleteSearchMixin -from hct_mis_api.apps.account.models import Partner +from hct_mis_api.apps.account.models import AdminAreaLimitedTo, Partner from hct_mis_api.apps.geo.models import Area from hct_mis_api.apps.household.documents import HouseholdDocument, get_individual_doc from hct_mis_api.apps.household.forms import CreateTargetPopulationTextForm from hct_mis_api.apps.household.models import Household, Individual -from hct_mis_api.apps.program.models import Program, ProgramCycle, ProgramPartnerThrough +from hct_mis_api.apps.program.models import Program, ProgramCycle from hct_mis_api.apps.registration_datahub.services.biometric_deduplication import ( BiometricDeduplicationService, ) @@ -57,10 +57,10 @@ class ProgramCycleAdminInline(admin.TabularInline): ordering = ["-start_date"] -class PartnerAreaForm(forms.Form): +class PartnerAreaLimitForm(forms.Form): partner = forms.ModelChoiceField(queryset=Partner.objects.all(), required=True) areas = TreeNodeMultipleChoiceField( - queryset=Area.objects.filter(area_type__area_level__lte=3), widget=CheckboxSelectMultiple(), required=False + queryset=Area.objects.filter(area_type__area_level__lte=3), widget=CheckboxSelectMultiple(), required=True ) @@ -127,63 +127,59 @@ def create_target_population_from_list(self, request: HttpRequest, pk: str) -> O context["form"] = form return TemplateResponse(request, "admin/program/program/create_target_population_from_text.html", context) - @button() - def partners(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: - context = self.get_common_context(request, pk, title="Partner access") + @button(permission="account.can_change_area_limits") + def area_limits(self, request: HttpRequest, pk: int) -> Union[TemplateResponse, HttpResponseRedirect]: + context = self.get_common_context(request, pk, title="Admin Area Limits") program: Program = context["original"] - PartnerAreaFormSet = formset_factory(PartnerAreaForm, extra=0, can_delete=True) + PartnerAreaLimitFormSet = formset_factory(PartnerAreaLimitForm, extra=0, can_delete=True) is_editable = program.partner_access == Program.SELECTED_PARTNERS_ACCESS if request.method == "GET" or not is_editable: partner_area_data = [] - for partner_program_through in program.program_partner_through.all(): + for area_limits in program.admin_area_limits.all(): partner_area_data.append( { - "partner": partner_program_through.partner, - "areas": [ - str(area_id) for area_id in partner_program_through.areas.values_list("id", flat=True) - ], + "partner": area_limits.partner, + "areas": [str(area_id) for area_id in area_limits.areas.values_list("id", flat=True)], } ) - partner_area_form_set = PartnerAreaFormSet(initial=partner_area_data, prefix="program_areas") + partner_area_form_set = PartnerAreaLimitFormSet(initial=partner_area_data, prefix="program_areas") elif request.method == "POST": - partner_area_form_set = PartnerAreaFormSet(request.POST or None, prefix="program_areas") + partner_area_form_set = PartnerAreaLimitFormSet(request.POST or None, prefix="program_areas") if partner_area_form_set.is_valid(): for partner_area_form in partner_area_form_set: form = partner_area_form.cleaned_data if form and not form["DELETE"]: areas_ids = list(map(lambda area: str(area.id), form["areas"])) - program_partner, _ = ProgramPartnerThrough.objects.update_or_create( + program_partner, _ = AdminAreaLimitedTo.objects.update_or_create( partner=form["partner"], program=program, ) - if not areas_ids: - program_partner.full_area_access = True - program_partner.save(update_fields=["full_area_access"]) - else: - program_partner.full_area_access = False - program_partner.save(update_fields=["full_area_access"]) - program_partner.areas.set(areas_ids) + program_partner.areas.set(areas_ids) elif form and form["DELETE"]: - ProgramPartnerThrough.objects.filter(partner=form["partner"], program=program).delete() - return HttpResponseRedirect(reverse("admin:program_program_partners", args=[pk])) + AdminAreaLimitedTo.objects.filter(partner=form["partner"], program=program).delete() + return HttpResponseRedirect(reverse("admin:program_program_area_limits", args=[pk])) context["program_area_formset"] = partner_area_form_set context["business_area"] = program.business_area context["areas"] = Area.objects.filter(area_type__country__business_areas__id=program.business_area.id) + # it's only possible to create area limits for partners that have a role in this program context["partners"] = ( - Partner.objects.filter(Q(allowed_business_areas=program.business_area)) - .exclude(name="UNICEF") + Partner.objects.filter( + Q(role_assignments__program=program) + | (Q(role_assignments__business_area=program.business_area) & Q(role_assignments__program__isnull=True)) + ) + .exclude(parent__name="UNICEF") .order_by("name") ) context["program"] = program context["unicef_partner_id"] = Partner.objects.get(name="UNICEF").id if is_editable: - return TemplateResponse(request, "admin/program/program/program_partner_access.html", context) + return TemplateResponse(request, "admin/program/program/program_area_limits.html", context) else: - return TemplateResponse(request, "admin/program/program/program_partner_access_readonly.html", context) + return TemplateResponse(request, "admin/program/program/program_area_limits_readonly.html", context) @button(permission="account.can_reindex_programs") def reindex_program(self, request: HttpRequest, pk: int) -> HttpResponseRedirect: diff --git a/src/hct_mis_api/apps/program/templates/admin/program/program/program_partner_access.html b/src/hct_mis_api/apps/program/templates/admin/program/program/program_area_limits.html similarity index 98% rename from src/hct_mis_api/apps/program/templates/admin/program/program/program_partner_access.html rename to src/hct_mis_api/apps/program/templates/admin/program/program/program_area_limits.html index 00051da3c6..e878f9c858 100644 --- a/src/hct_mis_api/apps/program/templates/admin/program/program/program_partner_access.html +++ b/src/hct_mis_api/apps/program/templates/admin/program/program/program_area_limits.html @@ -27,7 +27,7 @@