From ddf653b77e55afff52b65b88efb7f66e5feaa846 Mon Sep 17 00:00:00 2001 From: David Burke Date: Fri, 23 Jun 2023 15:34:30 -0400 Subject: [PATCH 1/2] Draft --- kobo/apps/organizations/models.py | 4 +- kobo/apps/organizations/permissions.py | 4 +- kobo/apps/organizations/serializers.py | 13 ++++++- .../tests/test_organization_users_api.py | 39 +++++++++++++++++++ kobo/apps/organizations/urls.py | 17 ++++++++ kobo/apps/organizations/views.py | 34 ++++++++++++++-- kpi/urls/router_api_v2.py | 5 +-- 7 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 kobo/apps/organizations/tests/test_organization_users_api.py create mode 100644 kobo/apps/organizations/urls.py 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) From 35404603e70c79e604372ed319f21e849cdfb848 Mon Sep 17 00:00:00 2001 From: David Burke Date: Fri, 30 Jun 2023 14:53:44 -0400 Subject: [PATCH 2/2] Add organizations invite --- dependencies/pip/dev_requirements.txt | 2 +- dependencies/pip/requirements.txt | 2 +- kobo/apps/organizations/admin.py | 6 ----- kobo/apps/organizations/invitation_backend.py | 27 +++++++++++++++++++ .../migrations/0002_auto_20230630_1710.py | 26 ++++++++++++++++++ kobo/apps/organizations/models.py | 19 +++++++++++++ kobo/apps/organizations/serializers.py | 8 ++---- .../organizations/email/activation_body.html | 0 .../email/activation_subject.txt | 0 .../organizations/email/invitation_body.html | 7 +++++ .../email/invitation_subject.txt | 1 + .../email/modeled_invitation_body.html | 7 +++++ .../email/modeled_invitation_subject.txt | 1 + .../email/notification_body.html | 7 +++++ .../email/notification_subject.txt | 1 + .../organizations/email/reminder_body.html | 7 +++++ .../organizations/email/reminder_subject.txt | 1 + .../tests/test_organization_users_api.py | 20 +++++++++++--- kobo/apps/organizations/views.py | 20 +++++++------- kobo/settings/base.py | 1 + kpi/urls/__init__.py | 25 +++++++++-------- 21 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 kobo/apps/organizations/invitation_backend.py create mode 100644 kobo/apps/organizations/migrations/0002_auto_20230630_1710.py create mode 100644 kobo/apps/organizations/templates/organizations/email/activation_body.html create mode 100644 kobo/apps/organizations/templates/organizations/email/activation_subject.txt create mode 100644 kobo/apps/organizations/templates/organizations/email/invitation_body.html create mode 100644 kobo/apps/organizations/templates/organizations/email/invitation_subject.txt create mode 100644 kobo/apps/organizations/templates/organizations/email/modeled_invitation_body.html create mode 100644 kobo/apps/organizations/templates/organizations/email/modeled_invitation_subject.txt create mode 100644 kobo/apps/organizations/templates/organizations/email/notification_body.html create mode 100644 kobo/apps/organizations/templates/organizations/email/notification_subject.txt create mode 100644 kobo/apps/organizations/templates/organizations/email/reminder_body.html create mode 100644 kobo/apps/organizations/templates/organizations/email/reminder_subject.txt diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index 23ca8618a5..9e508c5326 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -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 diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index 0f53257131..61df6aea03 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -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 diff --git a/kobo/apps/organizations/admin.py b/kobo/apps/organizations/admin.py index 0221892897..5161d509fa 100644 --- a/kobo/apps/organizations/admin.py +++ b/kobo/apps/organizations/admin.py @@ -8,7 +8,6 @@ from .models import ( Organization, - OrganizationInvitation, OrganizationOwner, OrganizationUser, ) @@ -32,8 +31,3 @@ class OrgUserAdmin(BaseOrganizationUserAdmin): @admin.register(OrganizationOwner) class OrgOwnerAdmin(BaseOrganizationOwnerAdmin): pass - - -@admin.register(OrganizationInvitation) -class OrgInvitationAdmin(admin.ModelAdmin): - pass diff --git a/kobo/apps/organizations/invitation_backend.py b/kobo/apps/organizations/invitation_backend.py new file mode 100644 index 0000000000..613f02d781 --- /dev/null +++ b/kobo/apps/organizations/invitation_backend.py @@ -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) diff --git a/kobo/apps/organizations/migrations/0002_auto_20230630_1710.py b/kobo/apps/organizations/migrations/0002_auto_20230630_1710.py new file mode 100644 index 0000000000..f4e622ea9f --- /dev/null +++ b/kobo/apps/organizations/migrations/0002_auto_20230630_1710.py @@ -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), + ), + ] diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index f473017f47..b2a995a39f 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -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 ( @@ -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 diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 466b620127..deccd33cd9 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -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",) diff --git a/kobo/apps/organizations/templates/organizations/email/activation_body.html b/kobo/apps/organizations/templates/organizations/email/activation_body.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kobo/apps/organizations/templates/organizations/email/activation_subject.txt b/kobo/apps/organizations/templates/organizations/email/activation_subject.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kobo/apps/organizations/templates/organizations/email/invitation_body.html b/kobo/apps/organizations/templates/organizations/email/invitation_body.html new file mode 100644 index 0000000000..8acff6f7bc --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/invitation_body.html @@ -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. diff --git a/kobo/apps/organizations/templates/organizations/email/invitation_subject.txt b/kobo/apps/organizations/templates/organizations/email/invitation_subject.txt new file mode 100644 index 0000000000..70a8deb368 --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/invitation_subject.txt @@ -0,0 +1 @@ +{% spaceless %}You've been invited!{% endspaceless %} diff --git a/kobo/apps/organizations/templates/organizations/email/modeled_invitation_body.html b/kobo/apps/organizations/templates/organizations/email/modeled_invitation_body.html new file mode 100644 index 0000000000..6ca450cc28 --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/modeled_invitation_body.html @@ -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. diff --git a/kobo/apps/organizations/templates/organizations/email/modeled_invitation_subject.txt b/kobo/apps/organizations/templates/organizations/email/modeled_invitation_subject.txt new file mode 100644 index 0000000000..70a8deb368 --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/modeled_invitation_subject.txt @@ -0,0 +1 @@ +{% spaceless %}You've been invited!{% endspaceless %} diff --git a/kobo/apps/organizations/templates/organizations/email/notification_body.html b/kobo/apps/organizations/templates/organizations/email/notification_body.html new file mode 100644 index 0000000000..c14281c754 --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/notification_body.html @@ -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. diff --git a/kobo/apps/organizations/templates/organizations/email/notification_subject.txt b/kobo/apps/organizations/templates/organizations/email/notification_subject.txt new file mode 100644 index 0000000000..f97d2f8ee5 --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/notification_subject.txt @@ -0,0 +1 @@ +{% spaceless %}You've been added to an organization{% endspaceless %} diff --git a/kobo/apps/organizations/templates/organizations/email/reminder_body.html b/kobo/apps/organizations/templates/organizations/email/reminder_body.html new file mode 100644 index 0000000000..8acff6f7bc --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/reminder_body.html @@ -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. diff --git a/kobo/apps/organizations/templates/organizations/email/reminder_subject.txt b/kobo/apps/organizations/templates/organizations/email/reminder_subject.txt new file mode 100644 index 0000000000..6a1ccf038d --- /dev/null +++ b/kobo/apps/organizations/templates/organizations/email/reminder_subject.txt @@ -0,0 +1 @@ +Just a reminder diff --git a/kobo/apps/organizations/tests/test_organization_users_api.py b/kobo/apps/organizations/tests/test_organization_users_api.py index 7853d44846..ab951069f2 100644 --- a/kobo/apps/organizations/tests/test_organization_users_api.py +++ b/kobo/apps/organizations/tests/test_organization_users_api.py @@ -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): @@ -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): @@ -34,6 +38,16 @@ def test_list(self): def test_create(self): data = {"is_admin": False, "email": "test@example.com"} - 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": "test@example.com"} + res = self.client.post(self.url_list, data) + body = mail.outbox[0].body + token = body[body.find("invitations/") :].split("/")[1] + \ No newline at end of file diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index d99c21e7c3..d8ffcce7e2 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -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): @@ -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() @@ -58,4 +49,11 @@ def get_queryset(self): ) def perform_create(self, serializer): - invitation_backend().send_invitation(org_user) \ No newline at end of file + 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) diff --git a/kobo/settings/base.py b/kobo/settings/base.py index a963c40660..d2954d17ff 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -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. diff --git a/kpi/urls/__init__.py b/kpi/urls/__init__.py index 406dd301c8..1b48bf013a 100644 --- a/kpi/urls/__init__.py +++ b/kpi/urls/__init__.py @@ -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 @@ -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')),