Skip to content

Commit

Permalink
feat(organizations): transfer projects to organization owner when the…
Browse files Browse the repository at this point in the history
… recipient belongs to a MMO TASK-1338 (#5335)

### 📣 Summary
Added logic to transfer project ownership to the organization when the
recipient belongs to a Multi-Member Organization (MMO).

### 📖 Description
This PR introduces functionality to automatically transfer project
ownership to the organization when the recipient of the transfer is a
member of a Multi-Member Organization (MMO).
  • Loading branch information
noliveleger authored Dec 10, 2024
1 parent b8d91c4 commit c55a349
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 24 deletions.
8 changes: 8 additions & 0 deletions kobo/apps/organizations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.utils import timezone
from zoneinfo import ZoneInfo

from kobo.apps.kobo_auth.shortcuts import User
from kobo.apps.organizations.models import Organization
from kpi.models.object_permission import ObjectPermission

Expand Down Expand Up @@ -69,6 +70,13 @@ def get_monthly_billing_dates(organization: Union['Organization', None]):
return period_start, period_end


def get_real_owner(user: User) -> User:
organization = user.organization
if organization.is_mmo:
return organization.owner_user_object
return user


def get_yearly_billing_dates(organization: Union['Organization', None]):
"""Returns start and end dates of an organization's annual billing cycle"""
now = timezone.now().replace(tzinfo=ZoneInfo('UTC'))
Expand Down
10 changes: 8 additions & 2 deletions kobo/apps/project_ownership/models/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.db import models
from django.utils.translation import gettext as t

from kobo.apps.organizations.utils import get_real_owner
from kpi.fields import KpiUidField
from kpi.models.abstract_models import AbstractTimeStampedModel
from kpi.utils.mailer import EmailMessage, Mailer
Expand Down Expand Up @@ -104,6 +105,11 @@ def send_acceptance_email(self):

def send_invite_email(self):

real_next_owner = get_real_owner(self.recipient)
template_suffix = ''
if real_next_owner != self.recipient:
template_suffix = '_org'

template_variables = {
'username': self.recipient.username,
'sender_username': self.sender.username,
Expand All @@ -125,9 +131,9 @@ def send_invite_email(self):
subject=t(
'Action required: KoboToolbox project ownership transfer request'
),
plain_text_content_or_template='emails/new_invite.txt',
plain_text_content_or_template=f'emails/new_invite{template_suffix}.txt',
template_variables=template_variables,
html_content_or_template='emails/new_invite.html',
html_content_or_template=f'emails/new_invite{template_suffix}.html',
language=self.recipient.extra_details.data.get('last_ui_language'),
)

Expand Down
17 changes: 12 additions & 5 deletions kobo/apps/project_ownership/models/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.utils.translation import gettext_lazy as t

from kobo.apps.help.models import InAppMessage, InAppMessageUsers
from kobo.apps.organizations.utils import get_real_owner
from kpi.constants import PERM_MANAGE_ASSET
from kpi.deployment_backends.kc_access.utils import (
assign_applicable_kc_permissions,
Expand Down Expand Up @@ -111,7 +112,7 @@ def transfer_project(self):
raise TransferAlreadyProcessedException()

self.status = TransferStatusChoices.IN_PROGRESS
new_owner = self.invite.recipient
new_owner = get_real_owner(self.invite.recipient)
success = False
try:
if not self.asset.has_deployment:
Expand Down Expand Up @@ -169,9 +170,9 @@ def _init_statuses(self):
)

def _reassign_project_permissions(self, update_deployment: bool = False):
new_owner = self.invite.recipient
new_owner = get_real_owner(self.invite.recipient)

# Delete existing new owner's permissions on project if any
# Delete existing new owner's permissions on the project if any
self.asset.permissions.filter(user=new_owner).delete()
old_owner = self.asset.owner
self.asset.owner = new_owner
Expand All @@ -194,11 +195,11 @@ def _reassign_project_permissions(self, update_deployment: bool = False):
xform.xls.move(target_folder)

xform.save(update_fields=['user_id', 'xls'])
# Kobocat adds 3 more permissions that are ignored by KPI:
# Kobocat adds 3 more permissions that KPI ignores:
# - add_xform
# - transfer_xform
# - move_xform
# There are not transferred since they are not used anymore by Kobocat
# There are not transferred since they are not used anymore by KoboCAT,
# and it does not break anything.
assign_applicable_kc_permissions(self.asset, new_owner, owner_perms)
reset_kc_permissions(self.asset, old_owner)
Expand All @@ -217,6 +218,12 @@ def _reassign_project_permissions(self, update_deployment: bool = False):
self.asset.assign_perm(
self.invite.sender, PERM_MANAGE_ASSET
)
# If the new owner differs from the user who accepted the invite,
# let's give them 'manage_asset' permission as well.
if new_owner != self.invite.recipient:
self.asset.assign_perm(
self.invite.recipient, PERM_MANAGE_ASSET
)

def _sent_in_app_messages(self):

Expand Down
49 changes: 49 additions & 0 deletions kobo/apps/project_ownership/templates/emails/new_invite_org.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{% load i18n %}
{% load strings %}
{% trans "Projects:" as projects_label %}

<p>{% trans "Dear" %} {{ username }},</p>

{% if transfers|length == 1 %}
<p>{% blocktrans with asset_uid=transfers.0.asset_uid asset_name=transfers.0.asset_name %}{{ sender_username }} ({{ sender_email }}) has requested to transfer ownership of the project <a href="{{ base_url }}/#/forms/{{ asset_uid }}/landing">{{ asset_name }}</a> to you.{% endblocktrans %}</p>

<p>{% trans "Because you are part of a team, this project will be owned by the team, but you will retain the ability to manage project permissions. If you do not want the team to own this project, then don’t click on the link below." %}</p>

<p>{% blocktrans %}
If you accept the transfer:
<ul>
<li>All submissions, data storage, and transcription/translation usage for this project will count toward the team’s plan limits</li>
<li>If you leave the team in the future, your account will still retain manage project permissions for this project</li>
</ul>
{% endblocktrans %}
</p>
{% else %}
<p>{% blocktrans %}{{ sender_username }} ({{ sender_email }}) has requested to transfer ownership of the following projects to you:{% endblocktrans %}
<ul>
{% for transfer in transfers %}
<li><a href="{{ base_url }}/#/forms/{{ transfer.asset_uid }}/landing">{{ transfer.asset_name }}</a></li>
{% endfor %}
</ul>
</p>

<p>{% trans "Because you are part of a team, this project will be owned by the team, but you will retain the ability to manage project permissions. If you do not want the team to own this project, then don’t click on the link below." %}</p>

<p>{% blocktrans %}
If you accept the transfer:
<ul>
<li>All submissions, data storage, and transcription/translation usage for these projects will count toward the team’s plan limits</li>
<li>If you leave the team in the future, your account will still retain manage project permissions for these projects</li>
</ul>
{% endblocktrans %}
</p>
{% endif %}

<p>{% trans "If you are unsure, please contact the current owner." %}</p>

<p>{% blocktrans %}This transfer request will expire in {{ invite_expiry }} days.{% endblocktrans %}</p>

<p>{% blocktrans %}To respond to this transfer request, please use the following link: {{ base_url }}/#/projects/home?invite={{ invite_uid }}{% endblocktrans %}</p>

<p>
&nbsp;-&nbsp;KoboToolbox
</p>
39 changes: 39 additions & 0 deletions kobo/apps/project_ownership/templates/emails/new_invite_org.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{% load i18n %}
{% load strings %}
{% trans "Projects:" as projects_label %}

{% trans "Dear" %} {{ username }},
{% if transfers|length == 1 %}
{% blocktrans with asset_uid=transfers.0.asset_uid asset_name=transfers.0.asset_name %}{{ sender_username }} ({{ sender_email }}) has requested to transfer ownership of the project {{ asset_name }} ({{ base_url }}/#/forms/{{ asset_uid }}/landing) to you.{% endblocktrans %}

{% trans "Because you are part of a team, this project will be owned by the team, but you will retain the ability to manage project permissions. If you do not want the team to own this project, then don’t click on the link below." %}

{% blocktrans %}
If you accept the transfer:
- All submissions, data storage, and transcription/translation usage for this project will count toward the team’s plan limits
- If you leave the team in the future, your account will still retain manage project permissions for this project
{% endblocktrans %}

{% else %}
{% blocktrans %}{{ sender_username }} ({{ sender_email }}) has requested to transfer ownership of the following projects to you:{% endblocktrans %}
{% for transfer in transfers %}
* {{ transfer.asset_name }} - [{{ base_url }}/#/forms/{{ asset_uid }}/landing]
{% endfor %}

{% trans "Because you are part of a team, these projects will be owned by the team, but you will retain the ability to manage project permissions. If you do not want the team to own these projects, then don’t click on the link below." %}

{% blocktrans %}
If you accept the transfer:
- All submissions, data storage, and transcription/translation usage for these projects will count toward the team’s plan limits
- If you leave the team in the future, your account will still retain manage project permissions for these projects
{% endblocktrans %}

{% endif %}

{% trans "If you are unsure, please contact the current owner." %}

{% blocktrans %}This transfer request will expire in {{ invite_expiry }} days.{% endblocktrans %}

{% blocktrans %}To respond to this transfer request, please use the following link: {{ base_url }}/#/projects/home?invite={{ invite_uid }}{% endblocktrans %}

- KoboToolbox
4 changes: 1 addition & 3 deletions kobo/apps/project_ownership/tests/api/v2/test_api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# flake8: noqa: E501
import uuid
from unittest.mock import MagicMock, patch

from constance.test import override_config
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.reverse import reverse

Expand Down Expand Up @@ -262,7 +262,6 @@ def test_invite_set_as_cancelled_on_project_deletion(self):


class ProjectOwnershipTransferDataAPITestCase(BaseAssetTestCase):

"""
Tests in this class use the mock library a lot because transferring a
deployed project implies accessing KoboCAT tables. However, KPI does not
Expand Down Expand Up @@ -364,7 +363,6 @@ def __add_submissions(self):
)
@override_config(PROJECT_OWNERSHIP_AUTO_ACCEPT_INVITES=True)
def test_account_usage_transferred_to_new_user(self):
today = timezone.now()
expected_data = {
'total_nlp_usage': {
'asr_seconds_current_year': 120,
Expand Down
28 changes: 26 additions & 2 deletions kobo/apps/project_ownership/tests/test_mail.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from constance.test import override_config
from django.contrib.auth import get_user_model
from django.core import mail
from rest_framework.reverse import reverse

from kobo.apps.kobo_auth.shortcuts import User
from kpi.models import Asset
from kpi.tests.kpi_test_case import KpiTestCase
from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE
Expand All @@ -16,7 +16,6 @@ class ProjectOwnershipMailTestCase(KpiTestCase):

def setUp(self) -> None:
super().setUp()
User = get_user_model() # noqa
self.someuser = User.objects.get(username='someuser')
self.anotheruser = User.objects.get(username='anotheruser')

Expand All @@ -36,6 +35,7 @@ def test_recipient_receives_invite(self):
invite_uid = Invite.objects.first().uid
self.assertEqual(mail.outbox[0].to[0], self.anotheruser.email)
self.assertIn(invite_uid, mail.outbox[0].body)
self.assertNotIn('Because you are part of a team', mail.outbox[0].body)

def test_sender_receives_new_owner_acceptance(self):
invite = Invite.objects.create(sender=self.someuser, recipient=self.anotheruser)
Expand Down Expand Up @@ -90,3 +90,27 @@ def test_admins_receive_failure_report(self):
mail.outbox[0].subject,
'KoboToolbox Notifications: Project ownership transfer failure',
)

def test_recipient_as_org_member_receives_invite(self):
alice = User.objects.create_user(
username='alice', password='alice', email='[email protected]'
)
# Add alice to anotheruser's organization
organization = self.anotheruser.organization
organization.mmo_override = True
organization.save()
organization.add_user(alice)

self.client.login(username='someuser', password='someuser')
payload = {
'recipient': self.absolute_reverse(
self._get_endpoint('user-kpi-detail'), args=[alice.username]
),
'assets': [self.asset.uid],
}
self.client.post(self.invite_url, data=payload, format='json')

invite_uid = Invite.objects.first().uid
self.assertEqual(mail.outbox[0].to[0], alice.email)
self.assertIn(invite_uid, mail.outbox[0].body)
self.assertIn('Because you are part of a team', mail.outbox[0].body)
74 changes: 74 additions & 0 deletions kobo/apps/project_ownership/tests/test_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from constance.test import override_config
from django.test import TestCase

from kobo.apps.kobo_auth.shortcuts import User
from kpi.constants import PERM_MANAGE_ASSET
from kpi.models import Asset
from kpi.tests.utils.transaction import immediate_on_commit
from ..utils import create_invite


@override_config(PROJECT_OWNERSHIP_AUTO_ACCEPT_INVITES=True)
class ProjectOwnershipPermissionTestCase(TestCase):
"""
The purpose of this test suite is solely to verify permission assignment.
To achieve this, PROJECT_OWNERSHIP_AUTO_ACCEPT_INVITES is set to True, allowing
the email invitation system to be bypassed and eliminating the need to process it
during testing.
"""

fixtures = ['test_data']

def setUp(self):

self.someuser = User.objects.get(username='someuser')
self.anotheruser = User.objects.get(username='anotheruser')
self.thirduser = User.objects.create_user(
username='thirduser',
password='thirduser',
email='[email protected]',
)
self.asset = Asset.objects.get(pk=1)

def test_recipient_as_regular_user_is_owner(self):
assert self.asset.owner == self.someuser
with immediate_on_commit():
create_invite(
sender=self.someuser,
recipient=self.anotheruser,
assets=[self.asset],
invite_class_name='Invite',
)

self.asset.refresh_from_db()
# New owner should anotheruser
assert self.asset.owner == self.anotheruser
# The previous owner should have received "manage_asset" permission
assert self.asset.has_perm(self.someuser, PERM_MANAGE_ASSET)

def test_recipient_as_org_member_is_owner(self):
# Make anotheruser's organization a MMO…
organization = self.anotheruser.organization
organization.mmo_override = True
organization.save()
# … and add thirduser to it
organization.add_user(self.thirduser)
assert self.asset.owner == self.someuser
# send the invite to thirduser
with immediate_on_commit():
create_invite(
sender=self.someuser,
recipient=self.thirduser,
assets=[self.asset],
invite_class_name='Invite',
)

self.asset.refresh_from_db()
# anotheruser should be the owner now (because they are the owner of
# thirduser's organization
assert self.asset.owner == self.anotheruser

# The previous owner should have received "manage_asset" permission
assert self.asset.has_perm(self.someuser, PERM_MANAGE_ASSET)
# The invite recipient should have received "manage_asset" permission too
assert self.asset.has_perm(self.thirduser, PERM_MANAGE_ASSET)
14 changes: 2 additions & 12 deletions kpi/serializers/v2/asset.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# coding: utf-8
from __future__ import annotations

import json
Expand All @@ -19,6 +18,7 @@
from rest_framework.utils.serializer_helpers import ReturnList

from kobo.apps.organizations.constants import ORG_ADMIN_ROLE
from kobo.apps.organizations.utils import get_real_owner
from kobo.apps.reports.constants import FUZZY_VERSION_PATTERN
from kobo.apps.reports.report_data import build_formpack
from kobo.apps.subsequences.utils.deprecation import WritableAdvancedFeaturesField
Expand Down Expand Up @@ -421,7 +421,7 @@ class Meta:

def create(self, validated_data):
current_owner = validated_data['owner']
real_owner = self._get_real_owner(current_owner)
real_owner = get_real_owner(current_owner)
if real_owner != current_owner:
with transaction.atomic():
validated_data['owner'] = real_owner
Expand Down Expand Up @@ -944,16 +944,6 @@ def _content(self, obj):
# FIXME: Is this dead code?
return json.dumps(obj.content)

def _get_real_owner(self, current_owner: 'User') -> 'User':

if current_owner.is_org_owner:
return current_owner

# If `owner` is not the owner of the organization they belong to,
# they must belong to a multi-member organization. Thus, the asset
# must be owned by the organization('s owner).
return current_owner.organization.owner_user_object

def _get_status(self, perm_assignments):
"""
Returns asset status.
Expand Down

0 comments on commit c55a349

Please sign in to comment.