Skip to content

Commit

Permalink
Merge pull request #4509 from kobotoolbox/feature/org-frontend
Browse files Browse the repository at this point in the history
Organizations API
  • Loading branch information
LMNTL authored Jun 29, 2023
2 parents a1fa14e + b200359 commit f692299
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 27 deletions.
21 changes: 13 additions & 8 deletions kobo/apps/organizations/admin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from django.contrib import admin

from organizations.base_admin import (BaseOrganizationAdmin,
BaseOrganizationOwnerAdmin,
BaseOrganizationUserAdmin,
BaseOwnerInline)

from .models import (Organization, OrganizationInvitation, OrganizationOwner,
OrganizationUser)
from organizations.base_admin import (
BaseOrganizationAdmin,
BaseOrganizationOwnerAdmin,
BaseOrganizationUserAdmin,
BaseOwnerInline,
)

from .models import (
Organization,
OrganizationInvitation,
OrganizationOwner,
OrganizationUser,
)


class OwnerInline(BaseOwnerInline):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Generated by Django 3.2.15 on 2023-06-22 20:25

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import kpi.fields.kpi_uid
import organizations.fields


class Migration(migrations.Migration):

replaces = [('organizations', '0001_initial'), ('organizations', '0002_alter_organization_id_to_kpiuidfield'), ('organizations', '0003_copy_organization_uid_to_id'), ('organizations', '0004_remove_organization_uid')]

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='The name of the organization', max_length=200)),
('is_active', models.BooleanField(default=True)),
('created', organizations.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', organizations.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('slug', organizations.fields.SlugField(blank=True, editable=False, help_text='The name in all lowercase, suitable for URL identification', max_length=200, populate_from='name', unique=True)),
('uid', kpi.fields.kpi_uid.KpiUidField(_null=False, uid_prefix='org')),
],
options={
'verbose_name': 'organization',
'verbose_name_plural': 'organizations',
'ordering': ['name'],
'abstract': False,
},
),
migrations.CreateModel(
name='OrganizationUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', organizations.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', organizations.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('is_admin', models.BooleanField(default=False)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_users', to='organizations.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations_organizationuser', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'organization user',
'verbose_name_plural': 'organization users',
'ordering': ['organization', 'user'],
'abstract': False,
'unique_together': {('user', 'organization')},
},
),
migrations.CreateModel(
name='OrganizationOwner',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', organizations.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', organizations.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='organizations.organization')),
('organization_user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organizations.organizationuser')),
],
options={
'verbose_name': 'organization owner',
'verbose_name_plural': 'organization owners',
'abstract': False,
},
),
migrations.CreateModel(
name='OrganizationInvitation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('guid', models.UUIDField(editable=False)),
('invitee_identifier', models.CharField(help_text='The contact identifier for the invitee, email, phone number, social media handle, etc.', max_length=1000)),
('created', organizations.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False)),
('modified', organizations.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False)),
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations_organizationinvitation_sent_invitations', to=settings.AUTH_USER_MODEL)),
('invitee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizations_organizationinvitation_invitations', to=settings.AUTH_USER_MODEL)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_invites', to='organizations.organization')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='organization',
name='users',
field=models.ManyToManyField(related_name='organizations_organization', through='organizations.OrganizationUser', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='id',
field=kpi.fields.kpi_uid.KpiUidField(_null=False, primary_key=True, uid_prefix='org'),
),
migrations.RemoveField(
model_name='organization',
name='uid',
),
]
18 changes: 13 additions & 5 deletions kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import uuid
from functools import partial

from django.db import models
from kpi.fields import KpiUidField
from django.forms.fields import EmailField
from organizations.abstract import (
AbstractOrganization,
AbstractOrganizationInvitation,
AbstractOrganizationOwner,
AbstractOrganizationUser,
)
from organizations.utils import create_organization as create_organization_base

from organizations.abstract import (AbstractOrganization,
AbstractOrganizationInvitation,
AbstractOrganizationOwner,
AbstractOrganizationUser)
from kpi.fields import KpiUidField


class Organization(AbstractOrganization):
Expand All @@ -21,6 +25,7 @@ def email(self):
"""
return self.owner.organization_user.user.email


class OrganizationUser(AbstractOrganizationUser):
pass

Expand All @@ -31,3 +36,6 @@ class OrganizationOwner(AbstractOrganizationOwner):

class OrganizationInvitation(AbstractOrganizationInvitation):
pass


create_organization = partial(create_organization_base, model=Organization)
17 changes: 17 additions & 0 deletions kobo/apps/organizations/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from rest_framework import permissions


class IsOrgAdminOrReadOnly(permissions.BasePermission):
"""
Object-level permission to only allow admin members of an object to edit it.
Assumes the model instance has an `is_admin` attribute.
"""

def has_object_permission(self, request, view, obj):
# Read permissions are allowed to any request,
# so we'll always allow GET, HEAD or OPTIONS requests.
if request.method in permissions.SAFE_METHODS:
return True

# Instance must have an attribute named `owner`.
return obj.is_admin(request.user)
7 changes: 6 additions & 1 deletion kobo/apps/organizations/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from rest_framework import serializers

from kobo.apps.organizations.models import Organization
from kobo.apps.organizations.models import Organization, create_organization


class OrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = ['id', 'name', 'is_active', 'created', 'modified', 'slug']
read_only_fields = ["id", "slug"]

def create(self, validated_data):
user = self.context['request'].user
return create_organization(user, validated_data['name'])
30 changes: 27 additions & 3 deletions kobo/apps/organizations/tests/test_organizations_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from django.contrib.auth.models import User
from django.urls import reverse

from model_bakery import baker
from rest_framework import status

Expand All @@ -10,7 +9,6 @@


class OrganizationTestCase(BaseTestCase):

fixtures = ['test_data']
URL_NAMESPACE = URL_NAMESPACE

Expand All @@ -35,7 +33,20 @@ def test_anonymous_user(self):
response_detail = self.client.get(self.url_detail)
assert response_detail.status_code == status.HTTP_403_FORBIDDEN

def test_api_creates_org(self):
def test_create(self):
data = {'name': 'my org'}
res = self.client.post(self.url_list, data)
self.assertContains(res, data['name'], status_code=201)

def test_list(self):
self._insert_data()
organization2 = baker.make(Organization, id='org_abcd123')
organization2.add_user(user=self.user, is_admin=True)
with self.assertNumQueries(2):
res = self.client.get(self.url_list)
self.assertContains(res, organization2.name)

def test_list_creates_org(self):
self.assertFalse(self.user.organizations_organization.all())
self.client.get(self.url_list)
self.assertTrue(self.user.organizations_organization.all())
Expand All @@ -46,3 +57,16 @@ def test_api_returns_org_data(self):
self.assertContains(response, self.organization.slug)
self.assertContains(response, self.organization.id)
self.assertContains(response, self.organization.name)

def test_update(self):
self._insert_data()
data = {'name': 'edit'}
with self.assertNumQueries(4):
res = self.client.patch(self.url_detail, data)
self.assertContains(res, data['name'])

user = baker.make(User)
self.client.force_login(user)
org_user = self.organization.add_user(user=user)
res = self.client.patch(self.url_detail, data)
self.assertEqual(res.status_code, 403)
22 changes: 12 additions & 10 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
from django.contrib.auth.models import User
from django.db.models import QuerySet

from organizations.utils import create_organization
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated

from kobo.apps.organizations.models import Organization
from kobo.apps.organizations.serializers import OrganizationSerializer
from .models import Organization, create_organization
from .permissions import IsOrgAdminOrReadOnly
from .serializers import OrganizationSerializer


class OrganizationViewSet(viewsets.ReadOnlyModelViewSet):
class OrganizationViewSet(viewsets.ModelViewSet):
"""
todo: create documentation
Organizations are groups of users with assigned permissions and configurations
- Organization admins can manage the organization and it's membership
- Connect to authentication mechanisms and enforce policy
- Create teams and projects under the organization
"""

queryset = Organization.objects.all()
serializer_class = OrganizationSerializer
lookup_field = 'id'
permission_classes = (IsAuthenticated,)
extra_context = None
permission_classes = (IsAuthenticated, IsOrgAdminOrReadOnly)

def get_queryset(self) -> QuerySet:
user = self.request.user
queryset = super().get_queryset().filter(users=user)
if not queryset:
if self.action == "list" and not queryset:
# Very inefficient get or create queryset.
# It's temporary and should be removed later.
create_organization(user, f"{user.username}'s organization", model=Organization)
create_organization(user, f"{user.username}'s organization")
queryset = queryset.all() # refresh
return queryset

0 comments on commit f692299

Please sign in to comment.