From f07c031de7147aed9c098b21edd2227f424c74ef Mon Sep 17 00:00:00 2001 From: Anji Tong Date: Tue, 3 Dec 2024 00:14:00 +0000 Subject: [PATCH 1/4] feat(organizations): add badge to profile dropdown TASK-979 (#5253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🗒ī¸ Checklist 1. [x] run linter locally 2. [x] update all related docs (API, README, inline, etc.), if any 3. [x] draft PR with a title `(): TASK-1234` 4. [x] tag PR: at least `frontend` or `backend` unless it's global 5. [x] fill in the template below and delete template comments 6. [x] review thyself: read the diff and repro the preview as written 7. [x] open PR & confirm that CI passes 8. [x] request reviewers, if needed 9. [ ] delete this section before merging ### đŸ“Ŗ Summary <!-- Delete this section if changes are internal only. --> <!-- One sentence summary for the public changelog, worded for non-technical seasoned Kobo users. --> Add the organization badge to the profile dropdown. ### 📖 Description <!-- Delete this section if summary already said everything. --> <!-- Full description for the public changelog, worded for non-technical seasoned Kobo users. --> Tiny reshuffle of elements to make it match the designs. ### 👀 Preview steps <!-- Delete this section if behavior can't change. --> <!-- If behavior changes or merely may change, add a preview of a minimal happy path. --> 1. Have an MMO with some users in it 2. Ensure the feature flag `mmosEnabled` is true 3. While logged in with a user in an MMO, clicking the profile icon should show the organization badge (in blue) under the user's email --- jsapp/js/components/common/badge.module.scss | 5 +++++ jsapp/js/components/common/badge.tsx | 3 ++- jsapp/js/components/header/accountMenu.tsx | 3 +++ jsapp/js/components/header/mainHeader.component.tsx | 4 +++- jsapp/js/components/header/mainHeader.module.scss | 5 +++++ .../header/organizationBadge.component.tsx | 12 +++++++++--- .../components/header/organizationBadge.module.scss | 5 +---- jsapp/scss/components/_kobo.navigation.scss | 1 + 8 files changed, 29 insertions(+), 9 deletions(-) diff --git a/jsapp/js/components/common/badge.module.scss b/jsapp/js/components/common/badge.module.scss index 7c2943213b..c65c58eb7c 100644 --- a/jsapp/js/components/common/badge.module.scss +++ b/jsapp/js/components/common/badge.module.scss @@ -55,6 +55,11 @@ $badge-font-l: 14px; background-color: colors.$kobo-light-green; } +.color-dark-gray { + color: colors.$kobo-white; + background-color: colors.$kobo-gray-700; +} + @mixin badge-size($size, $font) { // NOTE: icon size is already handled by badge.tsx file rendering proper <Icon> component height: $size; diff --git a/jsapp/js/components/common/badge.tsx b/jsapp/js/components/common/badge.tsx index 5706ffb49d..0dcd85887b 100644 --- a/jsapp/js/components/common/badge.tsx +++ b/jsapp/js/components/common/badge.tsx @@ -11,7 +11,8 @@ export type BadgeColor = | 'light-blue' | 'light-red' | 'light-teal' - | 'light-green'; + | 'light-green' + | 'dark-gray'; export type BadgeSize = 'l' | 'm' | 's'; export const BadgeToIconMap: Map<BadgeSize, IconSize> = new Map(); diff --git a/jsapp/js/components/header/accountMenu.tsx b/jsapp/js/components/header/accountMenu.tsx index 66c94c6dc9..4532873a3b 100644 --- a/jsapp/js/components/header/accountMenu.tsx +++ b/jsapp/js/components/header/accountMenu.tsx @@ -12,6 +12,7 @@ import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; import {isAnyRouteBlockerActive} from 'js/router/routerUtils'; import Button from 'js/components/common/button'; import Avatar from 'js/components/common/avatar'; +import OrganizationBadge from './organizationBadge.component'; /** * UI element that display things only for logged-in user. An avatar that gives @@ -96,6 +97,8 @@ export default function AccountMenu() { /> </bem.AccountBox__menuItem> + <OrganizationBadge color='light-blue'/> + {/* There is no UI we can show to a user who sees a router blocker, so we don't allow any in-app navigation. diff --git a/jsapp/js/components/header/mainHeader.component.tsx b/jsapp/js/components/header/mainHeader.component.tsx index 1e27caae90..3c504ebdbd 100644 --- a/jsapp/js/components/header/mainHeader.component.tsx +++ b/jsapp/js/components/header/mainHeader.component.tsx @@ -161,7 +161,9 @@ const MainHeader = class MainHeader extends React.Component<MainHeaderProps> { )} <div className={styles.accountSection}> - <OrganizationBadge /> + <div className={styles.badgeWrapper}> + <OrganizationBadge color='dark-gray' /> + </div> <AccountMenu /> </div> diff --git a/jsapp/js/components/header/mainHeader.module.scss b/jsapp/js/components/header/mainHeader.module.scss index 18b8800d8d..e4b4813df5 100644 --- a/jsapp/js/components/header/mainHeader.module.scss +++ b/jsapp/js/components/header/mainHeader.module.scss @@ -23,3 +23,8 @@ margin-left: auto; position: relative; } + +.badgeWrapper { + padding-top: 2px; + margin-right: 20px; +} diff --git a/jsapp/js/components/header/organizationBadge.component.tsx b/jsapp/js/components/header/organizationBadge.component.tsx index 534de9c5c3..98c2401a8f 100644 --- a/jsapp/js/components/header/organizationBadge.component.tsx +++ b/jsapp/js/components/header/organizationBadge.component.tsx @@ -1,8 +1,12 @@ import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; - +import Badge, {BadgeColor} from 'js/components/common/badge'; import styles from './organizationBadge.module.scss'; -export default function OrganizationBadge() { +interface OrganizationBadgeProps { + color: BadgeColor; +} + +export default function OrganizationBadge(props: OrganizationBadgeProps) { // TODO: move this logic to the parent component when we refactor it // into a functional component. OrganizationBadge should just be a // purely presentational component. @@ -10,7 +14,9 @@ export default function OrganizationBadge() { if (orgQuery.data?.is_mmo) { return ( - <div className={styles.root}>{orgQuery.data.name.toUpperCase()}</div> + <div className={styles.root}> + <Badge color={props.color} size='m' label={orgQuery.data.name.toUpperCase()} /> + </div> ); } else { return null; diff --git a/jsapp/js/components/header/organizationBadge.module.scss b/jsapp/js/components/header/organizationBadge.module.scss index 56948eec48..1e5dbb4c09 100644 --- a/jsapp/js/components/header/organizationBadge.module.scss +++ b/jsapp/js/components/header/organizationBadge.module.scss @@ -1,11 +1,8 @@ @use 'scss/colors'; .root { - color: colors.$kobo-white; - background-color: colors.$kobo-gray-700; - padding: 6px 10px; border-radius: 48px; font-weight: 600; font-size: .85em; - margin-right: 20px; + width: fit-content; } diff --git a/jsapp/scss/components/_kobo.navigation.scss b/jsapp/scss/components/_kobo.navigation.scss index ba7fb22344..6cb45b0725 100644 --- a/jsapp/scss/components/_kobo.navigation.scss +++ b/jsapp/scss/components/_kobo.navigation.scss @@ -145,6 +145,7 @@ .account-box__menu-item--settings { text-align: right; + margin-top: 16px; } .account-box__menu-li { From 7acd5aac4cd5a420610994863f90231bbda4bde4 Mon Sep 17 00:00:00 2001 From: Rebecca Graber <becca.graber@kobotoolbox.org> Date: Tue, 3 Dec 2024 08:54:33 -0500 Subject: [PATCH 2/4] feat(projectHistoryLogs): record logs for cloned permissions TASK-944 (#5302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### đŸ“Ŗ Summary Record logs when permissions are cloned from one project to another. ### 👀 Preview steps Feature/no-change template: 1. ℹī¸ have an account and at least 2 projects 2. From a terminal, run ``` curl -X PATCH -H 'Authorization: Token <your token>' -H 'Content-type: application/json' localhost/api/v2/assets/<project_1_uid>/permission_assignments/clone/ -d '{"clone_from": "<project_2_uid>"}' ``` 4. Go to `localhost/api/v2/audit-logs/?](http://localhost/api/v2/audit-logs/?q=log_type:project-history AND metadata__asset_uid:<project_1_uid>&format=json 5. đŸŸĸ There should be a new project history log that looks like ``` app_label: "kpi", model_name: "asset", user: "http://localhost/api/v2/users/<user>/?format=json", user_uid: "<user_uid>", username: "<username>", action: "clone-permissions", metadata: { source: "curl (Other)" asset_uid: "<project_1_uid>" ip_address: "172.18.0.1" cloned_from: "<project_2_uid>" log_subtype: "permission" }, date_created: "2024-12-02T15:21:20Z", log_type: "project-history", ``` ### 💭 Notes Uses the AuditLoggedModelViewSet base class --- kobo/apps/audit_log/audit_actions.py | 1 + kobo/apps/audit_log/models.py | 22 +++++++++++++++++++ .../tests/test_project_history_logs.py | 15 +++++++++++++ kpi/views/v2/asset_permission_assignment.py | 1 + 4 files changed, 39 insertions(+) diff --git a/kobo/apps/audit_log/audit_actions.py b/kobo/apps/audit_log/audit_actions.py index 45c8f52426..9fabce4186 100644 --- a/kobo/apps/audit_log/audit_actions.py +++ b/kobo/apps/audit_log/audit_actions.py @@ -6,6 +6,7 @@ class AuditAction(models.TextChoices): ALLOW_ANONYMOUS_SUBMISSIONS = 'allow-anonymous-submissions' ARCHIVE = 'archive' AUTH = 'auth' + CLONE_PERMISSIONS = 'clone-permissions' CONNECT_PROJECT = 'connect-project' CREATE = 'create' DELETE = 'delete' diff --git a/kobo/apps/audit_log/models.py b/kobo/apps/audit_log/models.py index e415571f3e..09caa86a74 100644 --- a/kobo/apps/audit_log/models.py +++ b/kobo/apps/audit_log/models.py @@ -21,6 +21,7 @@ ACCESS_LOG_SUBMISSION_AUTH_TYPE, ACCESS_LOG_SUBMISSION_GROUP_AUTH_TYPE, ACCESS_LOG_UNKNOWN_AUTH_TYPE, + CLONE_ARG_NAME, PERM_ADD_SUBMISSIONS, PERM_VIEW_ASSET, PERM_VIEW_SUBMISSIONS, @@ -354,6 +355,7 @@ def create_from_request(cls, request): 'asset-permission-assignment-bulk-assignments': cls.create_from_permissions_request, # noqa 'asset-permission-assignment-detail': cls.create_from_permissions_request, 'asset-permission-assignment-list': cls.create_from_permissions_request, + 'asset-permission-assignment-clone': cls.handle_cloned_permissions, } url_name = request.resolver_match.url_name method = url_name_to_action.get(url_name, None) @@ -801,3 +803,23 @@ def handle_anonymous_user_permissions( action=AuditAction.MODIFY_USER_PERMISSIONS, ) ) + + @classmethod + def handle_cloned_permissions(cls, request): + initial_data = getattr(request, 'initial_data', None) + if initial_data is None: + return + asset_uid = request.resolver_match.kwargs['parent_lookup_asset'] + asset_id = initial_data['asset.id'] + ProjectHistoryLog.objects.create( + object_id=asset_id, + action=AuditAction.CLONE_PERMISSIONS, + user=request.user, + metadata={ + 'asset_uid': asset_uid, + 'log_subtype': PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + 'ip_address': get_client_ip(request), + 'source': get_human_readable_client_user_agent(request), + 'cloned_from': request._data[CLONE_ARG_NAME], + }, + ) diff --git a/kobo/apps/audit_log/tests/test_project_history_logs.py b/kobo/apps/audit_log/tests/test_project_history_logs.py index f45becfe76..8ff52c11b0 100644 --- a/kobo/apps/audit_log/tests/test_project_history_logs.py +++ b/kobo/apps/audit_log/tests/test_project_history_logs.py @@ -17,6 +17,7 @@ from kobo.apps.hook.models import Hook from kobo.apps.kobo_auth.shortcuts import User from kpi.constants import ( + CLONE_ARG_NAME, PERM_ADD_SUBMISSIONS, PERM_CHANGE_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS, @@ -1287,3 +1288,17 @@ def test_no_logs_if_bulk_request_fails(self): format='json', ) self.assertEqual(ProjectHistoryLog.objects.count(), 0) + + def test_clone_permissions_creates_logs(self): + second_asset = Asset.objects.get(pk=2) + log_metadata = self._base_project_history_log_test( + method=self.client.patch, + url=reverse( + 'api_v2:asset-permission-assignment-clone', + kwargs={'parent_lookup_asset': self.asset.uid}, + ), + request_data={CLONE_ARG_NAME: second_asset.uid}, + expected_action=AuditAction.CLONE_PERMISSIONS, + expected_subtype=PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + ) + self.assertEqual(log_metadata['cloned_from'], second_asset.uid) diff --git a/kpi/views/v2/asset_permission_assignment.py b/kpi/views/v2/asset_permission_assignment.py index e9f8b756bb..b5ed0d9a5e 100644 --- a/kpi/views/v2/asset_permission_assignment.py +++ b/kpi/views/v2/asset_permission_assignment.py @@ -199,6 +199,7 @@ def clone(self, request, *args, **kwargs): source_asset_uid = self.request.data[CLONE_ARG_NAME] source_asset = get_object_or_404(Asset, uid=source_asset_uid) user = request.user + request._request.initial_data = {'asset.id': self.asset.id} if user.has_perm(PERM_MANAGE_ASSET, self.asset) and user.has_perm( PERM_VIEW_ASSET, source_asset From 4040ecc3eb1abea54f463a54d5d6d5746c5adaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= <olivierleger@gmail.com> Date: Tue, 3 Dec 2024 09:13:40 -0500 Subject: [PATCH 3/4] 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 <a href="{link}">Organization Users</a> ' + 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'<OrganizationUser #{self.pk}: {self.user.username}>' + return f'{self.user.username} (#{self.pk})' @property def active_subscription_statuses(self): From 028afe5b8879f0ee8295351940072663fac9611e Mon Sep 17 00:00:00 2001 From: Raj Patel <51355159+rajpatel24@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:27:05 +0530 Subject: [PATCH 4/4] Restrict email updates to organization owners and admins only (#5317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### đŸ“Ŗ Summary Restrict email updates to organization owners and admins only to ensure proper access control within multi-member organizations. ### 📖 Description Previously, any member of an organization could update their email address, regardless of their role. This update enforces a restriction where only organization owners and admins can update their email addresses. Members without these roles are now prevented from making such changes. ### 👷 Description for instance maintainers This update introduces a new restriction to the email update endpoint. It checks the role of the user within the organization and ensures that only users with the `owner` or `admin` role in organizations can update their email. This ensures better security and role-based access control. No changes are required for single-member organizations. --- kobo/apps/accounts/serializers.py | 14 +++++ kobo/apps/accounts/tests/test_email.py | 75 +++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/kobo/apps/accounts/serializers.py b/kobo/apps/accounts/serializers.py index c86fc26149..f417a47167 100644 --- a/kobo/apps/accounts/serializers.py +++ b/kobo/apps/accounts/serializers.py @@ -1,5 +1,6 @@ from allauth.account.models import EmailAddress from allauth.socialaccount.models import SocialAccount +from django.utils.translation import gettext as t from rest_framework import serializers @@ -22,6 +23,19 @@ def create(self, validated_data): confirm=True, ) + def validate(self, attrs): + """ + Validates that only owners or admins of the organization can update + their email + """ + user = self.context['request'].user + organization = user.organization + if organization.is_owner(user) or organization.is_admin(user): + return attrs + raise serializers.ValidationError( + {'email': t('This action is not allowed.')} + ) + # https://github.com/iMerica/dj-rest-auth/blob/6b394d9d6bb1f2979ea2d31e5a1199368d5616c1/dj_rest_auth/registration/serializers.py#L22 # https://gitlab.com/glitchtip/glitchtip-backend/-/blob/master/users/serializers.py#L40 diff --git a/kobo/apps/accounts/tests/test_email.py b/kobo/apps/accounts/tests/test_email.py index 255bafd688..48244f09e4 100644 --- a/kobo/apps/accounts/tests/test_email.py +++ b/kobo/apps/accounts/tests/test_email.py @@ -2,6 +2,7 @@ from django.core import mail from django.urls import reverse from model_bakery import baker +from rest_framework import status from rest_framework.test import APITestCase from kpi.utils.fuzzy_int import FuzzyInt @@ -48,7 +49,7 @@ def test_new_email(self): # Add second unconfirmed email, overrides the first data = {'email': 'morenew@example.com'} # Auth, Select, Delete (many), Get or Create - queries = FuzzyInt(11, 15) + queries = FuzzyInt(11, 20) with self.assertNumQueries(queries): res = self.client.post(self.url_list, data, format='json') self.assertContains(res, data['email'], status_code=201) @@ -98,3 +99,75 @@ def test_new_confirm_email(self): 1, 'Expect only 1 email after confirm', ) + + +class EmailUpdateRestrictionTestCase(APITestCase): + """ + Test that only organization owners and admins can update their email. + """ + def setUp(self): + self.owner = baker.make(settings.AUTH_USER_MODEL) + self.admin = baker.make(settings.AUTH_USER_MODEL) + self.member = baker.make(settings.AUTH_USER_MODEL) + self.non_mmo_user = baker.make(settings.AUTH_USER_MODEL) + + self.organization = self.owner.organization + self.organization.mmo_override = True + self.organization.save(update_fields=['mmo_override']) + + self.organization.add_user(self.admin, is_admin=True) + self.organization.add_user(self.member) + + self.url_list = reverse('emailaddress-list') + + def test_that_mmo_owner_can_update_email(self): + """ + Test that the owner of the organization can update their email + """ + data = {'email': 'owner@example.com'} + self.client.force_login(self.owner) + res = self.client.post(self.url_list, data, format='json') + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertEqual( + self.owner.emailaddress_set.filter(email=data['email']).count(), 1 + ) + + def test_that_mmo_admin_can_update_email(self): + """ + Test that the admin of the organization can update their email + """ + data = {'email': 'admin@example.com'} + self.client.force_login(self.admin) + res = self.client.post(self.url_list, data, format='json') + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertEqual( + self.admin.emailaddress_set.filter(email=data['email']).count(), 1 + ) + + def test_that_mmo_member_cannot_update_email(self): + """ + Test that the member of the organization cannot update their email + """ + data = {'email': 'member@example.com'} + self.client.force_login(self.member) + res = self.client.post(self.url_list, data, format='json') + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + self.member.emailaddress_set.filter(email=data['email']).count(), 0 + ) + + def test_that_non_mmo_user_can_update_email(self): + """ + Test that a user who is not part of MMO can update their email + """ + data = {'email': 'nonmmo@example.com'} + self.client.force_login(self.non_mmo_user) + res = self.client.post(self.url_list, data, format='json') + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertEqual( + self.non_mmo_user.emailaddress_set.filter( + email=data['email'] + ).count(), + 1 + )