diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index eed6d0d248..f473017f47 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -16,6 +16,7 @@ class Organization(AbstractOrganization): id = KpiUidField(uid_prefix='org', primary_key=True) + is_org_admin = AbstractOrganization.is_admin @property def email(self): @@ -27,7 +28,8 @@ def email(self): class OrganizationUser(AbstractOrganizationUser): - pass + def is_org_admin(self, user): + return self.organization.is_admin(user) class OrganizationOwner(AbstractOrganizationOwner): diff --git a/kobo/apps/organizations/permissions.py b/kobo/apps/organizations/permissions.py index bc0e924d19..4c769e8ad8 100644 --- a/kobo/apps/organizations/permissions.py +++ b/kobo/apps/organizations/permissions.py @@ -13,5 +13,5 @@ def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return True - # Instance must have an attribute named `owner`. - return obj.is_admin(request.user) + # Instance must have an attribute named `is_org_admin`. + return obj.is_org_admin(request.user) diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 5fd261e037..466b620127 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from kobo.apps.organizations.models import Organization, create_organization +from .models import Organization, OrganizationUser, create_organization class OrganizationSerializer(serializers.ModelSerializer): @@ -12,3 +12,14 @@ class Meta: def create(self, validated_data): user = self.context['request'].user return create_organization(user, validated_data['name']) + + +class OrganizationUserSerializer(serializers.ModelSerializer): + class Meta: + model = OrganizationUser + fields = ("user", "is_admin") + + +class OrganizationUserInvitationSerializer(serializers.Serializer): + email = serializers.EmailField() + is_admin = serializers.BooleanField() diff --git a/kobo/apps/organizations/tests/test_organization_users_api.py b/kobo/apps/organizations/tests/test_organization_users_api.py new file mode 100644 index 0000000000..7853d44846 --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_users_api.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import User +from django.urls import reverse +from model_bakery import baker + +from kpi.tests.kpi_test_case import BaseTestCase +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +class OrganizationUserTestCase(BaseTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + self.user = User.objects.get(username='someuser') + self.organization = baker.make( + "organizations.Organization", id='org_abcd1234' + ) + self.client.force_login(self.user) + self.organization.add_user(self.user) + self.url_list = reverse( + self._get_endpoint('organization-users-list'), + kwargs={"organization_id": self.organization.pk}, + ) + + def test_list(self): + org_user = baker.make( + "organizations.OrganizationUser", organization=self.organization + ) + bad_org_user = baker.make("organizations.OrganizationUser") + with self.assertNumQueries(3): + res = self.client.get(self.url_list) + self.assertContains(res, org_user.user_id) + self.assertNotContains(res, bad_org_user.user_id) + + def test_create(self): + data = {"is_admin": False, "email": "test@example.com"} + with self.assertNumQueries(3): + res = self.client.post(self.url_list, data) + self.assertContains(res, data["email"], status_code=201) diff --git a/kobo/apps/organizations/urls.py b/kobo/apps/organizations/urls.py new file mode 100644 index 0000000000..10d28a279e --- /dev/null +++ b/kobo/apps/organizations/urls.py @@ -0,0 +1,17 @@ +from rest_framework import routers + + +from .views import OrganizationViewSet, OrganizationUserViewSet + + +router = routers.SimpleRouter() +router.register( + r'organizations', + OrganizationViewSet, + basename='organizations', +) +router.register( + r'organizations/(?P[-\w]+)/users', + OrganizationUserViewSet, + basename='organization-users', +) diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 51b53eab2a..d99c21e7c3 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,11 +1,15 @@ -from django.contrib.auth.models import User from django.db.models import QuerySet +from organizations.backends import invitation_backend from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from .models import Organization, create_organization +from .models import Organization, OrganizationUser, create_organization from .permissions import IsOrgAdminOrReadOnly -from .serializers import OrganizationSerializer +from .serializers import ( + OrganizationSerializer, + OrganizationUserInvitationSerializer, + OrganizationUserSerializer, +) class OrganizationViewSet(viewsets.ModelViewSet): @@ -31,3 +35,27 @@ def get_queryset(self) -> QuerySet: create_organization(user, f"{user.username}'s organization") queryset = queryset.all() # refresh return queryset + + +class OrganizationUserViewSet(viewsets.ModelViewSet): + queryset = OrganizationUser.objects.all() + serializer_class = OrganizationUserSerializer + permission_classes = (IsAuthenticated, IsOrgAdminOrReadOnly) + + def get_serializer_class(self): + if self.action in ["create"]: + return OrganizationUserInvitationSerializer + return super().get_serializer_class() + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + organization__users=self.request.user, + organization_id=self.kwargs.get("organization_id"), + ) + ) + + def perform_create(self, serializer): + invitation_backend().send_invitation(org_user) \ No newline at end of file diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index bd5a7daad3..3df2311e87 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -5,7 +5,7 @@ from kobo.apps.hook.views.v2.hook import HookViewSet from kobo.apps.hook.views.v2.hook_log import HookLogViewSet from kobo.apps.hook.views.v2.hook_signal import HookSignalViewSet -from kobo.apps.organizations.views import OrganizationViewSet +from kobo.apps.organizations.urls import router as organizations_router from kobo.apps.project_views.views import ProjectViewViewSet from kpi.views.v2.asset import AssetViewSet from kpi.views.v2.asset_counts import AssetCountsViewSet @@ -142,13 +142,12 @@ def get_urls(self, *args, **kwargs): UserAssetSubscriptionViewSet) router_api_v2.register(r'asset_usage', AssetUsageViewSet, basename='asset-usage') router_api_v2.register(r'imports', ImportTaskViewSet) -router_api_v2.register(r'organizations', - OrganizationViewSet, basename='organizations',) router_api_v2.register(r'permissions', PermissionViewSet) router_api_v2.register(r'project-views', ProjectViewViewSet) router_api_v2.register(r'service_usage', ServiceUsageViewSet, basename='service-usage') router_api_v2.register(r'users', UserViewSet) +router_api_v2.registry.extend(organizations_router.registry) # TODO migrate ViewSet below # router_api_v2.register(r'sitewide_messages', SitewideMessageViewSet)