Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Organization Users API #4511

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dependencies/pip/dev_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ django-mptt==0.13.4
# via -r dependencies/pip/requirements.in
django-oauth-toolkit==2.0.0
# via -r dependencies/pip/requirements.in
django-organizations==2.0.2
django-organizations==2.1.0
# via -r dependencies/pip/requirements.in
django-picklefield==3.0.1
# via django-constance
Expand Down
2 changes: 1 addition & 1 deletion dependencies/pip/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ django-mptt==0.13.4
# via -r dependencies/pip/requirements.in
django-oauth-toolkit==2.0.0
# via -r dependencies/pip/requirements.in
django-organizations==2.0.2
django-organizations==2.1.0
# via -r dependencies/pip/requirements.in
django-picklefield==3.0.1
# via django-constance
Expand Down
6 changes: 0 additions & 6 deletions kobo/apps/organizations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from .models import (
Organization,
OrganizationInvitation,
OrganizationOwner,
OrganizationUser,
)
Expand All @@ -32,8 +31,3 @@ class OrgUserAdmin(BaseOrganizationUserAdmin):
@admin.register(OrganizationOwner)
class OrgOwnerAdmin(BaseOrganizationOwnerAdmin):
pass


@admin.register(OrganizationInvitation)
class OrgInvitationAdmin(admin.ModelAdmin):
pass
27 changes: 27 additions & 0 deletions kobo/apps/organizations/invitation_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from organizations.backends.defaults import (
InvitationBackend as BaseInvitationBackend,
)

from .models import Organization


class InvitationTokenGenerator(PasswordResetTokenGenerator):
def _make_hash_value(self, user, timestamp):
return str(user.pk) + str(timestamp)


class InvitationBackend(BaseInvitationBackend):
"""
Based on django-organizations InvitationBackend but for org user instead of user
"""

def __init__(self, org_model=None, namespace=None):
self.user_model = None
self.org_model = Organization
self.namespace = namespace

# TODO def get_urls(self):

def get_token(self, org_user, **kwargs):
return InvitationTokenGenerator().make_token(org_user)
26 changes: 26 additions & 0 deletions kobo/apps/organizations/migrations/0002_auto_20230630_1710.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.15 on 2023-06-30 17:10

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('organizations', '0001_squashed_0004_remove_organization_uid'),
]

operations = [
migrations.AddField(
model_name='organizationuser',
name='email',
field=models.EmailField(blank=True, help_text='Email for pending invite', max_length=254, null=True),
),
migrations.AlterField(
model_name='organizationuser',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
23 changes: 22 additions & 1 deletion kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from functools import partial

from django.conf import settings
from django.db import models
from django.forms.fields import EmailField
from organizations.abstract import (
Expand All @@ -16,6 +17,7 @@

class Organization(AbstractOrganization):
id = KpiUidField(uid_prefix='org', primary_key=True)
is_org_admin = AbstractOrganization.is_admin

@property
def email(self):
Expand All @@ -27,7 +29,26 @@ def email(self):


class OrganizationUser(AbstractOrganizationUser):
pass
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
blank=True,
null=True,
on_delete=models.CASCADE,
)
email = models.EmailField(
blank=True, null=True, help_text='Email for pending invite'
)

def __str__(self):
name = str(self.user) if self.user else self.email
return f"{name} {self.organization}"

def is_org_admin(self, user):
return self.organization.is_admin(user)

@property
def is_active(self):
return self.user_id is not None


class OrganizationOwner(AbstractOrganizationOwner):
Expand Down
4 changes: 2 additions & 2 deletions kobo/apps/organizations/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 8 additions & 1 deletion kobo/apps/organizations/serializers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -12,3 +12,10 @@ 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", "email")
read_only_fields = ("user",)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}.

Follow this link to create your user account.

http://{{ domain.domain }}{% url "invitations_register" user.pk token %}

If you are unsure about this link please contact the sender.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% spaceless %}You've been invited!{% endspaceless %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}.

Follow this link to create your user account.

http://{{ domain.domain }}{{ invitation }}

If you are unsure about this link please contact the sender.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% spaceless %}You've been invited!{% endspaceless %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
You've been added to the organization {{ organization|safe }} on {{ domain.name }} by {{ sender.full_name|safe }}.`

Follow the link below to view this organization.

http://{{ domain.domain }}{% url "organization_detail" organization.pk %}

If you are unsure about this link please contact the sender.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% spaceless %}You've been added to an organization{% endspaceless %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
You've been invited to join {{ organization|safe }} on {{ domain.name }} by {{ sender.first_name|safe }} {{ sender.last_name|safe }}.

Follow this link to create your user account.

http://{{ domain.domain }}{% url "invitations_register" user.pk token %}

If you are unsure about this link please contact the sender.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Just a reminder
53 changes: 53 additions & 0 deletions kobo/apps/organizations/tests/test_organization_users_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django.contrib.auth.models import User
from django.core import mail
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
from ..models import OrganizationUser


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,
_fill_optional=["user"],
)
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": "[email protected]"}
res = self.client.post(self.url_list, data)
self.assertContains(res, data["email"], status_code=201)
self.assertTrue(
OrganizationUser.objects.get(email=data["email"], user=None)
)
self.assertEqual(len(mail.outbox), 1)

def test_invite_accept(self):
data = {"is_admin": False, "email": "[email protected]"}
res = self.client.post(self.url_list, data)
body = mail.outbox[0].body
token = body[body.find("invitations/") :].split("/")[1]

17 changes: 17 additions & 0 deletions kobo/apps/organizations/urls.py
Original file line number Diff line number Diff line change
@@ -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<organization_id>[-\w]+)/users',
OrganizationUserViewSet,
basename='organization-users',
)
32 changes: 29 additions & 3 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
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, OrganizationUserSerializer


class OrganizationViewSet(viewsets.ModelViewSet):
Expand All @@ -31,3 +31,29 @@ 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_queryset(self):
return (
super()
.get_queryset()
.filter(
organization__users=self.request.user,
organization_id=self.kwargs.get("organization_id"),
)
)

def perform_create(self, serializer):
try:
organization = self.request.user.organizations_organization.get(
pk=self.kwargs.get("organization_id")
)
except ObjectDoesNotExist:
raise Http404
org_user = serializer.save(organization=organization)
invitation_backend().send_invitation(org_user, sender=self.request.user)
1 change: 1 addition & 0 deletions kobo/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ def dj_stripe_request_callback_method():
UNSAFE_SSO_REGISTRATION_EMAIL_DISABLE = env.bool(
"UNSAFE_SSO_REGISTRATION_EMAIL_DISABLE", False
)
INVITATION_BACKEND = "kobo.apps.organizations.invitation_backend.InvitationBackend"

# See https://django-allauth.readthedocs.io/en/latest/configuration.html
# Map env vars to upstream dict values, include exact case. Underscores for delimiter.
Expand Down
25 changes: 14 additions & 11 deletions kpi/urls/__init__.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
# coding: utf-8
import private_storage.urls
from django.conf import settings
from django.urls import include, re_path, path
from django.urls import include, path, re_path
from django.views.i18n import JavaScriptCatalog
from organizations.backends import invitation_backend

from hub.models import ConfigurationFile
from kobo.apps.accounts.mfa.views import MfaLoginView, MfaTokenView
from kobo.apps.superuser_stats.views import (
user_report,
user_details_report,
country_report,
retrieve_reports,
user_details_report,
user_report,
)
from kobo.apps.accounts.mfa.views import (
MfaLoginView,
MfaTokenView,
from kpi.views import (
authorized_application_authenticate_user,
browser_tests,
design_system,
home,
modern_browsers,
)
from kpi.views import authorized_application_authenticate_user
from kpi.views import home, browser_tests, design_system, modern_browsers
from kpi.views.environment import EnvironmentView
from kpi.views.current_user import CurrentUserViewSet
from kpi.views.environment import EnvironmentView
from kpi.views.token import TokenView

from .router_api_v1 import router_api_v1
from .router_api_v2 import router_api_v2, URL_NAMESPACE
from .router_api_v2 import URL_NAMESPACE, router_api_v2

# TODO: Give other apps their own `urls.py` files instead of importing their
# views directly! See
Expand All @@ -41,6 +43,7 @@
re_path(r'^api/v2/', include((router_api_v2.urls, URL_NAMESPACE))),
re_path(r'^api/v2/', include('kobo.apps.languages.urls')),
re_path(r'^api/v2/', include('kobo.apps.audit_log.urls')),
path('invitations/', include(invitation_backend().get_urls())),
path('', include('kobo.apps.accounts.urls')),
path('', include('kobo.apps.service_health.urls')),
re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
Expand Down
Loading