Skip to content

Commit

Permalink
Add organizations invite
Browse files Browse the repository at this point in the history
  • Loading branch information
bufke committed Jun 30, 2023
1 parent ae2a968 commit f83f22e
Show file tree
Hide file tree
Showing 21 changed files with 149 additions and 39 deletions.
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),
),
]
19 changes: 19 additions & 0 deletions 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 Down Expand Up @@ -28,9 +29,27 @@ def email(self):


class OrganizationUser(AbstractOrganizationUser):
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):
pass
Expand Down
8 changes: 2 additions & 6 deletions kobo/apps/organizations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,5 @@ def create(self, validated_data):
class OrganizationUserSerializer(serializers.ModelSerializer):
class Meta:
model = OrganizationUser
fields = ("user", "is_admin")


class OrganizationUserInvitationSerializer(serializers.Serializer):
email = serializers.EmailField()
is_admin = serializers.BooleanField()
fields = ("user", "is_admin", "email")
read_only_fields = ("user",)
Empty file.
Empty file.
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
20 changes: 17 additions & 3 deletions kobo/apps/organizations/tests/test_organization_users_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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):
Expand All @@ -24,7 +26,9 @@ def setUp(self):

def test_list(self):
org_user = baker.make(
"organizations.OrganizationUser", organization=self.organization
"organizations.OrganizationUser",
organization=self.organization,
_fill_optional=["user"],
)
bad_org_user = baker.make("organizations.OrganizationUser")
with self.assertNumQueries(3):
Expand All @@ -34,6 +38,16 @@ def test_list(self):

def test_create(self):
data = {"is_admin": False, "email": "[email protected]"}
with self.assertNumQueries(3):
res = self.client.post(self.url_list, data)
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]

20 changes: 9 additions & 11 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@

from .models import Organization, OrganizationUser, create_organization
from .permissions import IsOrgAdminOrReadOnly
from .serializers import (
OrganizationSerializer,
OrganizationUserInvitationSerializer,
OrganizationUserSerializer,
)
from .serializers import OrganizationSerializer, OrganizationUserSerializer


class OrganizationViewSet(viewsets.ModelViewSet):
Expand Down Expand Up @@ -42,11 +38,6 @@ class OrganizationUserViewSet(viewsets.ModelViewSet):
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()
Expand All @@ -58,4 +49,11 @@ def get_queryset(self):
)

def perform_create(self, serializer):
invitation_backend().send_invitation(org_user)
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')),
re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
re_path(
Expand Down

0 comments on commit f83f22e

Please sign in to comment.