From 57cf32c56df391fb50927c09bc7f9da9e9638318 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 21 Jan 2016 12:10:14 +0100 Subject: [PATCH] Adding transfer_token to project model --- taiga/base/management/commands/test_emails.py | 35 ++ taiga/export_import/api.py | 2 +- taiga/export_import/dump_service.py | 2 +- taiga/front/urls.py | 2 + taiga/permissions/service.py | 4 - taiga/projects/api.py | 68 +++ .../migrations/0036_project_transfer_token.py | 19 + taiga/projects/models.py | 3 + taiga/projects/permissions.py | 13 + taiga/projects/serializers.py | 3 +- taiga/projects/services/__init__.py | 4 + taiga/projects/services/transfer.py | 107 ++++ .../emails/transfer_accept-body-html.jinja | 19 + .../emails/transfer_accept-body-text.jinja | 17 + .../emails/transfer_accept-subject.jinja | 3 + .../emails/transfer_reject-body-html.jinja | 27 + .../emails/transfer_reject-body-text.jinja | 20 + .../emails/transfer_reject-subject.jinja | 3 + .../emails/transfer_request-body-html.jinja | 17 + .../emails/transfer_request-body-text.jinja | 15 + .../emails/transfer_request-subject.jinja | 3 + .../emails/transfer_start-body-html.jinja | 25 + .../emails/transfer_start-body-text.jinja | 19 + .../emails/transfer_start-subject.jinja | 3 + tests/integration/test_projects.py | 529 ++++++++++++++++++ 25 files changed, 955 insertions(+), 7 deletions(-) create mode 100644 taiga/projects/migrations/0036_project_transfer_token.py create mode 100644 taiga/projects/services/transfer.py create mode 100644 taiga/projects/templates/emails/transfer_accept-body-html.jinja create mode 100644 taiga/projects/templates/emails/transfer_accept-body-text.jinja create mode 100644 taiga/projects/templates/emails/transfer_accept-subject.jinja create mode 100644 taiga/projects/templates/emails/transfer_reject-body-html.jinja create mode 100644 taiga/projects/templates/emails/transfer_reject-body-text.jinja create mode 100644 taiga/projects/templates/emails/transfer_reject-subject.jinja create mode 100644 taiga/projects/templates/emails/transfer_request-body-html.jinja create mode 100644 taiga/projects/templates/emails/transfer_request-body-text.jinja create mode 100644 taiga/projects/templates/emails/transfer_request-subject.jinja create mode 100644 taiga/projects/templates/emails/transfer_start-body-html.jinja create mode 100644 taiga/projects/templates/emails/transfer_start-body-text.jinja create mode 100644 taiga/projects/templates/emails/transfer_start-subject.jinja diff --git a/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py index c0f1a490..76d9bc2f 100644 --- a/taiga/base/management/commands/test_emails.py +++ b/taiga/base/management/commands/test_emails.py @@ -187,3 +187,38 @@ class Command(BaseCommand): cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]}) email = cls() email.send(test_email, context) + + + # Transfer Emails + context = { + "project": Project.objects.all().order_by("?").first(), + "requester": User.objects.all().order_by("?").first(), + } + email = mail_builder.transfer_request(test_email, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "receiver": User.objects.all().order_by("?").first(), + "token": "test-token", + "reason": "Test reason" + } + email = mail_builder.transfer_start(test_email, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "old_owner": User.objects.all().order_by("?").first(), + "new_owner": User.objects.all().order_by("?").first(), + "reason": "Test reason" + } + email = mail_builder.transfer_accept(test_email, context) + email.send() + + context = { + "project": Project.objects.all().order_by("?").first(), + "rejecter": User.objects.all().order_by("?").first(), + "reason": "Test reason" + } + email = mail_builder.transfer_reject(test_email, context) + email.send() diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index af472e3d..bbb812f7 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -219,7 +219,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi try: dump = json.load(reader(dump)) - is_private = dump["is_private"] + is_private = dump.get("is_private", False) except Exception: raise exc.WrongArguments(_("Invalid dump format")) diff --git a/taiga/export_import/dump_service.py b/taiga/export_import/dump_service.py index adbaa6fe..6020217f 100644 --- a/taiga/export_import/dump_service.py +++ b/taiga/export_import/dump_service.py @@ -94,7 +94,7 @@ def dict_to_project(data, owner=None): members = len(data.get("memberships", [])) (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( owner, - project=Project(is_private=data["is_private"], id=None), + project=Project(is_private=data.get("is_private", False), id=None), members=members ) if not enough_slots: diff --git a/taiga/front/urls.py b/taiga/front/urls.py index 76709327..499328f9 100644 --- a/taiga/front/urls.py +++ b/taiga/front/urls.py @@ -46,6 +46,8 @@ urls = { "team": "/project/{0}/team/", # project.slug + "project-transfer": "/project/{0}/transfer/{1}", # project.slug, project.transfer_token + "project-admin": "/project/{0}/admin/project-profile/details", # project.slug } diff --git a/taiga/permissions/service.py b/taiga/permissions/service.py index 2242c3ee..5a40ff7e 100644 --- a/taiga/permissions/service.py +++ b/taiga/permissions/service.py @@ -38,10 +38,6 @@ def _get_object_project(obj): def is_project_owner(user, obj): - """ - The owner attribute of a project is just an historical reference - """ - if user.is_superuser: return True diff --git a/taiga/projects/api.py b/taiga/projects/api.py index a5e2838c..b1e7943c 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -328,6 +328,74 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, self.check_permissions(request, "tags_colors", project) return response.Ok(dict(project.tags_colors)) + @detail_route(methods=["POST"]) + def transfer_request(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "transfer_request", project) + services.request_project_transfer(project, request.user) + return response.Ok() + + @detail_route(methods=['post']) + def transfer_start(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, "transfer_start", project) + + user_id = request.DATA.get('user', None) + if user_id is None: + raise exc.WrongArguments(_("Invalid user id")) + + user_model = apps.get_model("users", "User") + try: + user = user_model.objects.get(id=user_id) + + except user_model.DoesNotExist: + return response.BadRequest(_("The user doesn't exist")) + + # Check the user is an admin membership from the project + try: + project.memberships.get(is_owner=True, user=user) + except apps.get_model("projects", "Membership").DoesNotExist: + return response.BadRequest(_("The user must be an admin member of the project")) + + reason = request.DATA.get('reason', None) + transfer_token = services.start_project_transfer(project, user, reason) + return response.Ok() + + @detail_route(methods=["POST"]) + def transfer_accept(self, request, pk=None): + token = request.DATA.get('token', None) + if token is None: + raise exc.WrongArguments(_("Invalid token")) + + project = self.get_object() + self.check_permissions(request, "transfer_accept", project) + + members = project.memberships.count() + (enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project( + request.user, + project=project, + members=members + ) + if not enough_slots: + raise exc.BadRequest(not_enough_slots_error) + + reason = request.DATA.get('reason', None) + services.accept_project_transfer(project, request.user, token, reason) + return response.Ok() + + @detail_route(methods=["POST"]) + def transfer_reject(self, request, pk=None): + token = request.DATA.get('token', None) + if token is None: + raise exc.WrongArguments(_("Invalid token")) + + project = self.get_object() + self.check_permissions(request, "transfer_reject", project) + + reason = request.DATA.get('reason', None) + services.reject_project_transfer(project, request.user, token, reason) + return response.Ok() + def _set_base_permissions(self, obj): update_permissions = False if not obj.id: diff --git a/taiga/projects/migrations/0036_project_transfer_token.py b/taiga/projects/migrations/0036_project_transfer_token.py new file mode 100644 index 00000000..fbbdc28b --- /dev/null +++ b/taiga/projects/migrations/0036_project_transfer_token.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0035_project_blocked_code'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='transfer_token', + field=models.CharField(max_length=255, default=None, blank=True, null=True, verbose_name='project transfer token'), + ), + ] diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 60682a32..d85feb95 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -235,6 +235,9 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): tags_colors = TextArrayField(dimension=2, default=[], null=False, blank=True, verbose_name=_("tags colors")) + transfer_token = models.CharField(max_length=255, null=True, blank=True, default=None, + verbose_name=_("project transfer token")) + #Totals: totals_updated_datetime = models.DateTimeField(null=False, blank=False, auto_now_add=True, verbose_name=_("updated date time"), db_index=True) diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index ee96523f..855cc44b 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -42,6 +42,15 @@ class CanLeaveProject(PermissionComponent): except Membership.DoesNotExist: return False +class IsMainOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if not obj or not request.user.is_authenticated(): + return False + + if obj.owner is None: + return False + + return obj.owner == request.user class ProjectPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') @@ -68,6 +77,10 @@ class ProjectPermission(TaigaResourcePermission): unwatch_perms = IsAuthenticated() & HasProjectPerm('view_project') create_template_perms = IsSuperUser() leave_perms = CanLeaveProject() + transfer_request_perms = IsProjectOwner() + transfer_start_perms = IsMainOwner() + transfer_reject_perms = IsProjectOwner() + transfer_accept_perms = IsProjectOwner() class ProjectFansPermission(TaigaResourcePermission): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 54d6a48b..b60c474f 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -328,7 +328,8 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ model = models.Project read_only_fields = ("created_date", "modified_date", "owner", "slug", "blocked_code") exclude = ("logo", "last_us_ref", "last_task_ref", "last_issue_ref", - "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid") + "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid", + "transfer_token") def get_my_permissions(self, obj): if "request" in self.context: diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index b6a51e3e..c50e8386 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -46,3 +46,7 @@ from .stats import get_stats_for_project from .stats import get_member_stats_for_project from .tags_colors import update_project_tags_colors_handler +from .modules_config import get_modules_config + +from .transfer import request_project_transfer, start_project_transfer +from .transfer import accept_project_transfer, reject_project_transfer diff --git a/taiga/projects/services/transfer.py b/taiga/projects/services/transfer.py new file mode 100644 index 00000000..4ee456e2 --- /dev/null +++ b/taiga/projects/services/transfer.py @@ -0,0 +1,107 @@ +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.core import signing +from django.utils.translation import ugettext as _ + +import datetime + +from taiga.base.mails import mail_builder +from taiga.base import exceptions as exc + + +def request_project_transfer(project, user): + template = mail_builder.transfer_request + email = template(project.owner, {"project": project, "requester": user}) + email.send() + + +def start_project_transfer(project, user, reason): + """Generates the transfer token for a project transfer and notify to the destination user + + :param project: Project trying to transfer + :param user: Destination user + :param reason: Reason to transfer the project + """ + + signer = signing.TimestampSigner() + token = signer.sign(user.id) + project.transfer_token = token + project.save() + + template = mail_builder.transfer_start + context = { + "project": project, + "receiver": user, + "token": token, + "reason": reason + } + email = template(project.owner, context) + email.send() + + +def _validate_token(token, project_token, user_id): + signer = signing.TimestampSigner() + + if project_token != token: + raise exc.WrongArguments(_("Token is invalid")) + + try: + value = signer.unsign(token, max_age=datetime.timedelta(days=7)) + except signing.SignatureExpired: + raise exc.WrongArguments(_("Token has expired")) + except signing.BadSignature: + raise exc.WrongArguments(_("Token is invalid")) + + if str(value) != str(user_id): + raise exc.WrongArguments(_("Token is invalid")) + + +def reject_project_transfer(project, user, token, reason): + _validate_token(token, project.transfer_token, user.id) + + project.transfer_token = None + project.save() + + template = mail_builder.transfer_reject + context = { + "project": project, + "rejecter": user, + "reason": reason + } + email = template(project.owner, context) + email.send() + + +def accept_project_transfer(project, user, token, reason): + _validate_token(token, project.transfer_token, user.id) + + old_owner = project.owner + + project.transfer_token = None + project.owner = user + project.save() + + template = mail_builder.transfer_accept + context = { + "project": project, + "old_owner": old_owner, + "new_owner": user, + "reason": reason + } + email = template(old_owner, context) + email.send() diff --git a/taiga/projects/templates/emails/transfer_accept-body-html.jinja b/taiga/projects/templates/emails/transfer_accept-body-html.jinja new file mode 100644 index 00000000..f92233ae --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-body-html.jinja @@ -0,0 +1,19 @@ +{% extends "emails/hero-body-html.jinja" %} + +{% block body %} + {% trans old_owner_name=old_owner.get_full_name(), new_owner_name=new_owner.get_full_name(), project_name=project.name %} +

Hi {{old_owner_name}},

+

{{ new_owner_name}} has accepted your offer and will become the new project owner for "{{project_name}}".

+ {% endtrans %} + + {% if reason %} + {% trans %}

This is the reason/comment:

{% endtrans %} +

{{reason}}

+ {% endif %} + + {% trans %} +

From now on, your new status for this project will be "admin".

+ {% endtrans %} + +

{% trans %}The Taiga Team{% endtrans %}

+{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_accept-body-text.jinja b/taiga/projects/templates/emails/transfer_accept-body-text.jinja new file mode 100644 index 00000000..0d8779d8 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-body-text.jinja @@ -0,0 +1,17 @@ +{% trans old_owner_name=old_owner.get_full_name(), new_owner_name=new_owner.get_full_name(), project_name=project.name %} +Hi {{old_owner_name}}, +{{ new_owner_name}} has accepted your offer and will become the new project owner for "{{project_name}}". +{% endtrans %} + +{% if reason %}{% trans %}This is the reason/comment:{% endtrans %} +{{reason}} +{% endif %} + +{% trans %} +From now on, your new status for this project will be "admin". +{% endtrans %} + +--- +{% trans %} +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_accept-subject.jinja b/taiga/projects/templates/emails/transfer_accept-subject.jinja new file mode 100644 index 00000000..6b7c84d5 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_accept-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name %} +[{{project}}] Project ownership transfer offer accepted! +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_reject-body-html.jinja b/taiga/projects/templates/emails/transfer_reject-body-html.jinja new file mode 100644 index 00000000..b188b927 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-body-html.jinja @@ -0,0 +1,27 @@ +{% extends "emails/hero-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), rejecter_name=rejecter.get_full_name(), project_name=project.name %} +

Hi {{owner_name}},

+

{{ rejecter_name}} has declined your offer and will not become the new project owner for "{{project_name}}".

+ {% endtrans %} + + {% if reason %} + {% trans %} +

This is the reason/comment:

+ {% endtrans %} + +

{{ reason }}

+ {% endif %} + + {% trans %} +

If you want, you can still try to transfer the project ownership to a different person.

+ {% endtrans %} + + + {% trans %}Request transfer to a different person{% endtrans %} + + +

{% trans %}The Taiga Team{% endtrans %}

+{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_reject-body-text.jinja b/taiga/projects/templates/emails/transfer_reject-body-text.jinja new file mode 100644 index 00000000..6acc033a --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-body-text.jinja @@ -0,0 +1,20 @@ +{% trans owner_name=project.owner.get_full_name(), rejecter_name=rejecter.get_full_name(), project_name=project.name %} +Hi {{owner_name}}, +{{ rejecter_name}} has declined your offer and will not become the new project owner for "{{project_name}}". +{% endtrans %} + +{% if reason %}{% trans %}This is the reason/comment:{% endtrans %} +{{ reason }} +{% endif %} + +{% trans %} +If you want, you can still try to transfer the project ownership to a different person. +{% endtrans %} + +{% trans %}Request transfer to a different person:{% endtrans %} +{{ resolve_front_url("project-admin", project.slug) }} + +--- +{% trans %} +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_reject-subject.jinja b/taiga/projects/templates/emails/transfer_reject-subject.jinja new file mode 100644 index 00000000..e6eaa127 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_reject-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name %} +[{{project}}] Project ownership transfer declined +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_request-body-html.jinja b/taiga/projects/templates/emails/transfer_request-body-html.jinja new file mode 100644 index 00000000..c60deb2b --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-body-html.jinja @@ -0,0 +1,17 @@ +{% extends "emails/hero-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), requester_name=requester.get_full_name(), project_name=project.name %} +

Hi {{owner_name}},

+

{{ requester_name }} has requested to become the project owner for "{{project_name}}".

+ {% endtrans %} + + {% trans %} +

Please, click on "Continue" if you would like to start the project transfer from the administration panel.

+ {% endtrans %} + + {% trans %}Continue{% endtrans %} + +

{% trans %}The Taiga Team{% endtrans %}

+{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_request-body-text.jinja b/taiga/projects/templates/emails/transfer_request-body-text.jinja new file mode 100644 index 00000000..9b156a4d --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-body-text.jinja @@ -0,0 +1,15 @@ +{% trans owner_name=project.owner.get_full_name(), requester_name=requester.get_full_name(), project_name=project.name %} +Hi {{owner_name}}, +{{ requester_name }} has requested to become the project owner for "{{project_name}}". +{% endtrans %} + +{% trans %} +Please, go to your project settings if you would like to start the project transfer from the administration panel. +{% endtrans %} + +{{ _("Go to your project settings:") }} {{ resolve_front_url("project-admin", project.slug) }} + +--- +{% trans %} +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_request-subject.jinja b/taiga/projects/templates/emails/transfer_request-subject.jinja new file mode 100644 index 00000000..1f6ff81c --- /dev/null +++ b/taiga/projects/templates/emails/transfer_request-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name %} +[{{project}}] Project ownership transfer request +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_start-body-html.jinja b/taiga/projects/templates/emails/transfer_start-body-html.jinja new file mode 100644 index 00000000..4a3a7c8a --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-body-html.jinja @@ -0,0 +1,25 @@ +{% extends "emails/hero-body-html.jinja" %} + +{% block body %} + {% trans owner_name=project.owner.get_full_name(), receiver_name=receiver.get_full_name(), project_name=project.name %} +

Hi {{receiver_name}},

+

{{ owner_name }}, the current project owner at "{{project_name}}" would like you to become the new project owner.

+ {% endtrans %} + + {% if reason %} + {% trans %} +

This is the reason/comment:

+ {% endtrans %} + +

{{ reason }}

+ {% endif %} + + {% trans %} +

Please, click on "Continue" to either accept or reject this proposal.

+ {% endtrans %} + + {{ _("Continue") }} + +

{{ _("The Taiga Team") }}

+{% endblock %} diff --git a/taiga/projects/templates/emails/transfer_start-body-text.jinja b/taiga/projects/templates/emails/transfer_start-body-text.jinja new file mode 100644 index 00000000..8403c790 --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-body-text.jinja @@ -0,0 +1,19 @@ +{% trans owner_name=project.owner.get_full_name(), receiver_name=receiver.get_full_name(), project_name=project.name %} +Hi {{receiver_name}}, +{{ owner_name }}, the current project owner at "{{project_name}}" would like you to become the new project owner. +{% endtrans %} + +{% if reason %}{% trans %}This is the reason/comment:{% endtrans %} +{{ reason }} +{% endif %} + +{% trans %} +Please, go to the following link to either accept or reject this proposal.

+{% endtrans %} + +{{ _("Accept or reject the project transfer:") }} {{ resolve_front_url("project-transfer", project.slug, project.transfer_token) }} + +--- +{% trans %} +The Taiga Team +{% endtrans %} diff --git a/taiga/projects/templates/emails/transfer_start-subject.jinja b/taiga/projects/templates/emails/transfer_start-subject.jinja new file mode 100644 index 00000000..d0e34d3a --- /dev/null +++ b/taiga/projects/templates/emails/transfer_start-subject.jinja @@ -0,0 +1,3 @@ +{% trans project=project.name %} +[{{project}}] Project ownership transfer offer +{% endtrans %} diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 1627d537..0bbae37a 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -1,6 +1,8 @@ from django.core.urlresolvers import reverse from django.conf import settings from django.core.files import File +from django.core import mail +from django.core import signing from taiga.base.utils import json from taiga.projects.services import stats as stats_services @@ -19,6 +21,17 @@ import pytest pytestmark = pytest.mark.django_db +class ExpiredSigner(signing.TimestampSigner): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.salt = "django.core.signing.TimestampSigner" + + def timestamp(self): + from django.utils import baseconv + import time + time_in_the_far_past = int(time.time()) - 24*60*60*1000 + return baseconv.base62.encode(time_in_the_far_past) + def test_get_project_by_slug(client): project = f.create_project() @@ -698,3 +711,519 @@ def test_project_list_with_search_query_order_by_ranking(client): assert response.data[0]["id"] == project3.id assert response.data[1]["id"] == project2.id assert response.data[2]["id"] == project1.id + + +def test_transfer_request_from_not_anonimous(client): + user = f.UserFactory.create() + project = f.create_project(anon_permissions=["view_project"]) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + response = client.json.post(url) + assert response.status_code == 401 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_not_project_member(client): + user = f.UserFactory.create() + project = f.create_project(public_permissions=["view_project"]) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_not_admin_member(client): + user = f.UserFactory.create() + project = f.create_project() + role = f.RoleFactory(project=project, permissions=["view_project"]) + f.MembershipFactory(user=user, project=project, role=role, is_owner=False) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_transfer_request_from_admin_member(client): + user = f.UserFactory.create() + project = f.create_project() + role = f.RoleFactory(project=project, permissions=["view_project"]) + f.MembershipFactory(user=user, project=project, role=role, is_owner=True) + + url = reverse("projects-transfer-request", args=(project.id,)) + + mail.outbox = [] + + client.login(user) + response = client.json.post(url) + assert response.status_code == 200 + assert len(mail.outbox) == 1 + +def test_project_transfer_start_to_not_a_membership(client): + user_from = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.MembershipFactory(user=user_from, project=project, is_owner=True) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": 666, + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "The user doesn't exist" in response.data + + +def test_project_transfer_start_to_not_a_membership_admin(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": user_to.id, + } + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert "The user must be" in response.data + + +def test_project_transfer_start_to_a_valid_user(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + project = f.create_project(owner=user_from) + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_from) + url = reverse("projects-transfer-start", kwargs={"pk": project.pk}) + + data = { + "user": user_to.id, + } + mail.outbox = [] + + assert project.transfer_token is None + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 200 + project = Project.objects.get(id=project.id) + assert project.transfer_token is not None + assert len(mail.outbox) == 1 + + +def test_project_transfer_reject_from_admin_member_without_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = {} + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_not_admin_member(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, public_permissions=["view_project"]) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=False) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_admin_member_with_invalid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + project = f.create_project(owner=user_from, transfer_token="invalid-token") + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": "invalid-token", + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_admin_member_with_other_user_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + other_user = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(other_user.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_admin_member_with_expired_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = ExpiredSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token has expired" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_reject_from_admin_member_with_valid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-reject", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + + +def test_project_transfer_accept_from_admin_member_without_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = {} + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_not_admin_member(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, public_permissions=["view_project"]) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=False) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 403 + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_admin_member_with_invalid_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + project = f.create_project(owner=user_from, transfer_token="invalid-token") + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": "invalid-token", + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_admin_member_with_other_user_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + other_user = f.UserFactory.create() + + signer = signing.TimestampSigner() + token = signer.sign(other_user.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token is invalid" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_admin_member_with_expired_token(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create() + + signer = ExpiredSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert "Token has expired" == response.data["_error_message"] + assert len(mail.outbox) == 0 + + +def test_project_transfer_accept_from_admin_member_with_valid_token_without_enough_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=0) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_admin_member_with_valid_token_without_enough_memberships_public_project_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_members_public_projects=5) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=False) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_admin_member_with_valid_token_without_enough_memberships_private_project_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_members_private_projects=5) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + f.MembershipFactory(project=project) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 400 + assert len(mail.outbox) == 0 + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_from.id + assert project.transfer_token is not None + + +def test_project_transfer_accept_from_admin_member_with_valid_token_with_enough_slots(client): + user_from = f.UserFactory.create() + user_to = f.UserFactory.create(max_private_projects=1) + + signer = signing.TimestampSigner() + token = signer.sign(user_to.id) + project = f.create_project(owner=user_from, transfer_token=token, is_private=True) + + f.MembershipFactory(user=user_from, project=project, is_owner=True) + f.MembershipFactory(user=user_to, project=project, is_owner=True) + + client.login(user_to) + url = reverse("projects-transfer-accept", kwargs={"pk": project.pk}) + + data = { + "token": token, + } + mail.outbox = [] + + response = client.json.post(url, json.dumps(data)) + + assert response.status_code == 200 + assert len(mail.outbox) == 1 + assert mail.outbox[0].to == [user_from.email] + project = Project.objects.get(pk=project.pk) + assert project.owner.id == user_to.id + assert project.transfer_token is None