From d89e7fafe6711520fe3bc5dfbaf1806f51b07b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 3 Dec 2024 09:13:40 -0500 Subject: [PATCH] feat(organization): restrict user management to organization form (#5314) Limit user management (add/edit) exclusively to the organization form in (Django) admin interface --- kobo/apps/organizations/admin/__init__.py | 3 +- kobo/apps/organizations/admin/organization.py | 32 +++++++++++++++++-- .../admin/organization_invite.py | 8 ----- .../organizations/admin/organization_user.py | 29 +++++++++++++---- kobo/apps/organizations/models.py | 5 +-- 5 files changed, 56 insertions(+), 21 deletions(-) delete mode 100644 kobo/apps/organizations/admin/organization_invite.py diff --git a/kobo/apps/organizations/admin/__init__.py b/kobo/apps/organizations/admin/__init__.py index 5a2df4ebad..fe3509bf1f 100644 --- a/kobo/apps/organizations/admin/__init__.py +++ b/kobo/apps/organizations/admin/__init__.py @@ -1,6 +1,5 @@ from .organization import OrgAdmin -from .organization_invite import OrgInvitationAdmin from .organization_owner import OrgOwnerAdmin from .organization_user import OrgUserAdmin -__all__ = ['OrgAdmin', 'OrgOwnerAdmin', 'OrgInvitationAdmin', 'OrgUserAdmin'] +__all__ = ['OrgAdmin', 'OrgOwnerAdmin', 'OrgUserAdmin'] diff --git a/kobo/apps/organizations/admin/organization.py b/kobo/apps/organizations/admin/organization.py index bd0f64a856..d8c8f354e8 100644 --- a/kobo/apps/organizations/admin/organization.py +++ b/kobo/apps/organizations/admin/organization.py @@ -1,5 +1,6 @@ from django.contrib import admin, messages from django.db.models import Count +from django.urls import reverse from django.utils.safestring import mark_safe from organizations.base_admin import BaseOrganizationAdmin @@ -9,7 +10,7 @@ from ..tasks import transfer_user_ownership_to_org from ..utils import revoke_org_asset_perms from .organization_owner import OwnerInline -from .organization_user import OrgUserInline +from .organization_user import OrgUserInline, max_users_for_edit_mode @admin.register(Organization) @@ -17,7 +18,34 @@ class OrgAdmin(BaseOrganizationAdmin): inlines = [OwnerInline, OrgUserInline] view_on_site = False readonly_fields = ['id'] - fields = ['id', 'name', 'slug', 'is_active', 'mmo_override'] + fields = ['id', 'name', 'mmo_override'] + search_fields = ['name'] + + # parent overrides + list_display = ['name'] + list_filter = () + prepopulated_fields = {} + + def change_view(self, request, object_id, form_url='', extra_context=None): + organization = self.get_object(request, object_id) + if ( + organization + and organization.organization_users.count() > max_users_for_edit_mode() + and request.method == 'GET' + ): + link = reverse('admin:organizations_organizationuser_changelist') + message = ( + f'Note: Adding/Editing/Removing users is disabled on this page due ' + f'to the size of the organization. Please use the Import/Export ' + f'feature available in the Organization Users ' + f'section instead.' + ) + self.message_user( + request, + mark_safe(message), + level=messages.WARNING, + ) + return super().change_view(request, object_id, form_url, extra_context) def save_related(self, request, form, formsets, change): super().save_related(request, form, formsets, change) diff --git a/kobo/apps/organizations/admin/organization_invite.py b/kobo/apps/organizations/admin/organization_invite.py deleted file mode 100644 index 87751c72ba..0000000000 --- a/kobo/apps/organizations/admin/organization_invite.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.contrib import admin - -from ..models import OrganizationInvitation - - -@admin.register(OrganizationInvitation) -class OrgInvitationAdmin(admin.ModelAdmin): - pass diff --git a/kobo/apps/organizations/admin/organization_user.py b/kobo/apps/organizations/admin/organization_user.py index 99a556312f..e4c106c61e 100644 --- a/kobo/apps/organizations/admin/organization_user.py +++ b/kobo/apps/organizations/admin/organization_user.py @@ -18,13 +18,13 @@ from ..utils import revoke_org_asset_perms -def _max_users_for_edit_mode(): +def max_users_for_edit_mode(): """ This function represents an arbitrary limit to prevent the form's POST request from exceeding `settings.DATA_UPLOAD_MAX_NUMBER_FIELDS`. """ - return settings.DATA_UPLOAD_MAX_NUMBER_FIELDS // 3 + return int(settings.DATA_UPLOAD_MAX_NUMBER_FIELDS * 0.4) class OrgUserInlineFormSet(forms.models.BaseInlineFormSet): @@ -32,7 +32,7 @@ def clean(self): if self.is_valid(): members = 0 users = [] - if len(self.forms) >= _max_users_for_edit_mode(): + if len(self.forms) > max_users_for_edit_mode(): return for form in self.forms: @@ -63,9 +63,17 @@ def clean(self): ) +class OrgUserInlineForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields['user'].disabled = True + + class OrgUserInline(admin.StackedInline): model = OrganizationUser formset = OrgUserInlineFormSet + form = OrgUserInlineForm raw_id_fields = ('user',) view_on_site = False extra = 0 @@ -85,8 +93,8 @@ def get_readonly_fields(self, request, obj=None): if not obj: return [] - if obj.organization_users.count() >= _max_users_for_edit_mode(): - return ['user', 'is_admin'] + if obj.organization_users.count() > max_users_for_edit_mode(): + return ['is_admin'] return [] @@ -94,13 +102,13 @@ def has_add_permission(self, request, obj=None): if not obj: return True - return obj.organization_users.count() < _max_users_for_edit_mode() + return obj.organization_users.count() <= max_users_for_edit_mode() def has_delete_permission(self, request, obj=None): if not obj: return True - return obj.organization_users.count() < _max_users_for_edit_mode() + return obj.organization_users.count() <= max_users_for_edit_mode() class OrgUserResource(resources.ModelResource): @@ -156,6 +164,13 @@ class OrgUserAdmin(ImportExportModelAdmin, BaseOrganizationUserAdmin): search_fields = ('user__username',) autocomplete_fields = ['user', 'organization'] form = OrgUserAdminForm + view_on_site = False + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False def get_search_results(self, request, queryset, search_term): auto_complete = request.path == '/admin/autocomplete/' diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index 062f42e1ae..9b9277ed61 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -49,7 +49,8 @@ class OrganizationType(models.TextChoices): class Organization(AbstractOrganization): id = KpiUidField(uid_prefix='org', primary_key=True) mmo_override = models.BooleanField( - default=False, verbose_name='Multi-members override' + default=False, + verbose_name='Make organization multi-member (necessary for adding users)' ) website = models.CharField(default='', max_length=255) organization_type = models.CharField( @@ -235,7 +236,7 @@ def owner_user_object(self) -> 'User': class OrganizationUser(AbstractOrganizationUser): def __str__(self): - return f'' + return f'{self.user.username} (#{self.pk})' @property def active_subscription_statuses(self):