diff --git a/CHANGELOG.md b/CHANGELOG.md index 734a282d..2c2c65fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog # +## 1.2.0 (Unreleased) + +### Features +- Send an email to the user on signup. +- Emit django signal on user signout. + +### Misc +- Lots of small and not so small bugfixes + ## 1.1.0 (2014-10-13) ### Misc diff --git a/requirements.txt b/requirements.txt index ad35c9ee..fa761be4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ djangorestframework==2.3.13 Django==1.7 django-picklefield==0.3.1 django-sampledatahelper==0.2.2 -gunicorn==18.0 +gunicorn==19.1.1 psycopg2==2.5.4 pillow==2.5.3 pytz==2014.4 diff --git a/settings/common.py b/settings/common.py index d726741f..9834a2de 100644 --- a/settings/common.py +++ b/settings/common.py @@ -271,6 +271,9 @@ AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", # default ) +MAX_AGE_AUTH_TOKEN = None +MAX_AGE_CANCEL_ACCOUNT = 30 * 24 * 60 * 60 # 30 days in seconds + ANONYMOUS_USER_ID = -1 MAX_SEARCH_RESULTS = 100 diff --git a/taiga/auth/backends.py b/taiga/auth/backends.py index d0331fdf..b514f1e7 100644 --- a/taiga/auth/backends.py +++ b/taiga/auth/backends.py @@ -35,11 +35,10 @@ fraudulent modifications. import base64 import re -from django.core import signing -from django.apps import apps +from django.conf import settings from rest_framework.authentication import BaseAuthentication -from taiga.base import exceptions as exc +from .tokens import get_user_for_token class Session(BaseAuthentication): """ @@ -62,39 +61,6 @@ class Session(BaseAuthentication): return (user, None) -def get_token_for_user(user): - """ - Generate a new signed token containing - a specified user. - """ - data = {"user_id": user.id} - return signing.dumps(data) - - -def get_user_for_token(token): - """ - Given a selfcontained token, try parse and - unsign it. - - If token passes a validation, returns - a user instance corresponding with user_id stored - in the incoming token. - """ - try: - data = signing.loads(token) - except signing.BadSignature: - raise exc.NotAuthenticated("Invalid token") - - model_cls = apps.get_model("users", "User") - - try: - user = model_cls.objects.get(pk=data["user_id"]) - except model_cls.DoesNotExist: - raise exc.NotAuthenticated("Invalid token") - else: - return user - - class Token(BaseAuthentication): """ Self-contained stateles authentication implementatrion @@ -114,7 +80,10 @@ class Token(BaseAuthentication): return None token = token_rx_match.group(1) - user = get_user_for_token(token) + max_age_auth_token = getattr(settings, "MAX_AGE_AUTH_TOKEN", None) + user = get_user_for_token(token, "authentication", + max_age=max_age_auth_token) + return (user, token) def authenticate_header(self, request): diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 10c4e573..348e41d4 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -35,31 +35,18 @@ from taiga.base import exceptions as exc from taiga.users.serializers import UserSerializer from taiga.users.services import get_and_validate_user -from .backends import get_token_for_user +from .tokens import get_token_for_user from .signals import user_registered as user_registered_signal -def send_public_register_email(user) -> bool: +def send_register_email(user) -> bool: """ - Given a user, send public register welcome email + Given a user, send register welcome email message to specified user. """ - - context = {"user": user} + cancel_token = get_token_for_user(user, "cancel_account") + context = {"user": user, "cancel_token": cancel_token} mbuilder = MagicMailBuilder() - email = mbuilder.public_register_user(user.email, context) - return bool(email.send()) - - -def send_private_register_email(user, **kwargs) -> bool: - """ - Given a user, send private register welcome - email message to specified user. - """ - context = {"user": user} - context.update(kwargs) - - mbuilder = MagicMailBuilder() - email = mbuilder.private_register_user(user.email, context) + email = mbuilder.registered_user(user.email, context) return bool(email.send()) @@ -125,7 +112,7 @@ def public_register(username:str, password:str, email:str, full_name:str): except IntegrityError: raise exc.WrongArguments("User is already register.") - # send_public_register_email(user) + send_register_email(user) user_registered_signal.send(sender=user.__class__, user=user) return user @@ -149,7 +136,7 @@ def private_register_for_existing_user(token:str, username:str, password:str): except IntegrityError: raise exc.IntegrityError("Membership with user is already exists.") - # send_private_register_email(user) + send_register_email(user) return user @@ -178,6 +165,7 @@ def private_register_for_new_user(token:str, username:str, email:str, membership = get_membership_by_token(token) membership.user = user membership.save(update_fields=["user"]) + send_register_email(user) user_registered_signal.send(sender=user.__class__, user=user) return user @@ -205,6 +193,7 @@ def github_register(username:str, email:str, full_name:str, github_id:int, bio:s membership.save(update_fields=["user"]) if created: + send_register_email(user) user_registered_signal.send(sender=user.__class__, user=user) return user @@ -218,5 +207,5 @@ def make_auth_response_data(user) -> dict: """ serializer = UserSerializer(user) data = dict(serializer.data) - data["auth_token"] = get_token_for_user(user) + data["auth_token"] = get_token_for_user(user, "authentication") return data diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py new file mode 100644 index 00000000..680e70fc --- /dev/null +++ b/taiga/auth/tokens.py @@ -0,0 +1,54 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 taiga.base import exceptions as exc + +from django.apps import apps +from django.core import signing + +def get_token_for_user(user, scope): + """ + Generate a new signed token containing + a specified user limited for a scope (identified as a string). + """ + data = {"user_%s_id"%(scope): user.id} + return signing.dumps(data) + + +def get_user_for_token(token, scope, max_age=None): + """ + Given a selfcontained token and a scope try to parse and + unsign it. + + If max_age is specified it checks token expiration. + + If token passes a validation, returns + a user instance corresponding with user_id stored + in the incoming token. + """ + try: + data = signing.loads(token, max_age=max_age) + except signing.BadSignature: + raise exc.NotAuthenticated("Invalid token") + + model_cls = apps.get_model("users", "User") + + try: + user = model_cls.objects.get(pk=data["user_%s_id"%(scope)]) + except (model_cls.DoesNotExist, KeyError): + raise exc.NotAuthenticated("Invalid token") + else: + return user diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 25287800..986a0192 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -246,8 +246,11 @@ class QFilter(FilterBackend): def filter_queryset(self, request, queryset, view): q = request.QUERY_PARAMS.get('q', None) if q: - qs_args = [Q(subject__icontains=x) for x in q.split()] - qs_args += [Q(ref=x) for x in q.split() if x.isdigit()] - queryset = queryset.filter(reduce(operator.or_, qs_args)) + if q.isdigit(): + qs_args = [Q(ref=q)] + else: + qs_args = [Q(subject__icontains=x) for x in q.split()] + + queryset = queryset.filter(reduce(operator.and_, qs_args)) return queryset diff --git a/taiga/base/neighbors.py b/taiga/base/neighbors.py index b8368fa6..14223487 100644 --- a/taiga/base/neighbors.py +++ b/taiga/base/neighbors.py @@ -87,7 +87,7 @@ def get_neighbors(obj, results_set=None): :return: Tuple `, `. Left and right neighbors can be `None`. """ - if results_set is None: + if results_set is None or results_set.count() == 0: results_set = type(obj).objects.get_queryset() try: left = _left_candidates(obj, results_set).reverse()[0] diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index 977da9d7..77773db3 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -296,6 +296,23 @@ class MilestoneExportSerializer(serializers.ModelSerializer): watchers = UserRelatedField(many=True, required=False) modified_date = serializers.DateTimeField(required=False) + def __init__(self, *args, **kwargs): + project = kwargs.pop('project', None) + super(MilestoneExportSerializer, self).__init__(*args, **kwargs) + if project: + self.project = project + + def validate_name(self, attrs, source): + """ + Check the milestone name is not duplicated in the project + """ + name = attrs[source] + qs = self.project.milestones.filter(name=name) + if qs.exists(): + raise serializers.ValidationError("Name duplicated for the project") + + return attrs + class Meta: model = milestones_models.Milestone exclude = ('id', 'project') diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 24d44aea..3f1b3145 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -16,7 +16,9 @@ import uuid import os.path as path +from unidecode import unidecode +from django.template.defaultfilters import slugify from django.contrib.contenttypes.models import ContentType from taiga.projects.history.services import make_key_from_model_object @@ -183,7 +185,7 @@ def store_task(project, task): def store_milestone(project, milestone): - serialized = serializers.MilestoneExportSerializer(data=milestone) + serialized = serializers.MilestoneExportSerializer(data=milestone, project=project) if serialized.is_valid(): serialized.object.project = project serialized.object._importing = True @@ -229,6 +231,7 @@ def store_history(project, obj, history): def store_wiki_page(project, wiki_page): + wiki_page['slug'] = slugify(unidecode(wiki_page.get('slug', ''))) serialized = serializers.WikiPageExportSerializer(data=wiki_page) if serialized.is_valid(): serialized.object.project = project diff --git a/taiga/front/__init__.py b/taiga/front/__init__.py index be3ca27e..1b2d3535 100644 --- a/taiga/front/__init__.py +++ b/taiga/front/__init__.py @@ -22,6 +22,7 @@ urls = { "login": "/login", "change-password": "/change-password/{0}", "change-email": "/change-email/{0}", + "cancel-account": "/cancel-account/{0}", "invitation": "/invitation/{0}", "project": "/project/{0}", diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index dfb3c553..348b4b7f 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -52,13 +52,16 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, super().pre_conditions_on_save(obj) if obj.milestone and obj.milestone.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions for add/modify this task.")) + raise exc.WrongArguments(_("You don't have permissions for add/modify this task.")) if obj.user_story and obj.user_story.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions for add/modify this task.")) + raise exc.WrongArguments(_("You don't have permissions for add/modify this task.")) if obj.status and obj.status.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions for add/modify this task.")) + raise exc.WrongArguments(_("You don't have permissions for add/modify this task.")) + + if obj.milestone and obj.user_story and obj.milestone != obj.user_story.milestone: + raise exc.WrongArguments(_("You don't have permissions for add/modify this task.")) @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): diff --git a/taiga/users/api.py b/taiga/users/api.py index 50855b13..56bee9e7 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -22,6 +22,7 @@ from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.conf import settings from easy_thumbnails.source_generators import pil_image @@ -32,6 +33,7 @@ from rest_framework import status from djmail.template_mail import MagicMailBuilder +from taiga.auth.tokens import get_user_for_token from taiga.base.decorators import list_route, detail_route from taiga.base import exceptions as exc from taiga.base.api import ModelCrudViewSet @@ -42,6 +44,7 @@ from taiga.projects.serializers import StarredSerializer from . import models from . import serializers from . import permissions +from .signals import user_cancel_account as user_cancel_account_signal class MembersFilterBackend(BaseFilterBackend): @@ -258,20 +261,34 @@ class UsersViewSet(ModelCrudViewSet): return Response(status=status.HTTP_204_NO_CONTENT) + @list_route(methods=["POST"]) + def cancel(self, request, pk=None): + """ + Cancel an account via token + """ + serializer = serializers.CancelAccountSerializer(data=request.DATA, many=False) + if not serializer.is_valid(): + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + try: + max_age_cancel_account = getattr(settings, "MAX_AGE_CANCEL_ACCOUNT", None) + user = get_user_for_token(serializer.data["cancel_token"], "cancel_account", + max_age=max_age_cancel_account) + + except exc.NotAuthenticated: + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + if not user.is_active: + raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) + + user.cancel() + return Response(status=status.HTTP_204_NO_CONTENT) + def destroy(self, request, pk=None): user = self.get_object() self.check_permissions(request, "destroy", user) - user.username = slugify_uniquely("deleted-user", models.User, slugfield="username") - user.email = "{}@taiga.io".format(user.username) - user.is_active = False - user.full_name = "Deleted user" - user.color = "" - user.bio = "" - user.default_language = "" - user.default_timezone = "" - user.colorize_tags = True - user.token = None - user.github_id = None - user.set_unusable_password() - user.save() + stream = request.stream + request_data = stream is not None and stream.GET or None + user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data) + user.cancel() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/taiga/users/models.py b/taiga/users/models.py index 3c1698ea..0ebfc81d 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -19,6 +19,7 @@ import os import os.path as path import random import re +import uuid from unidecode import unidecode @@ -33,6 +34,7 @@ from django.template.defaultfilters import slugify from djorm_pgarray.fields import TextArrayField +from taiga.auth.tokens import get_token_for_user from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.iterators import split_by_n from taiga.permissions.permissions import MEMBERS_PERMISSIONS @@ -152,6 +154,24 @@ class User(AbstractBaseUser, PermissionsMixin): def get_full_name(self): return self.full_name or self.username or self.email + def save(self, *args, **kwargs): + get_token_for_user(self, "cancel_account") + super().save(*args, **kwargs) + + def cancel(self): + self.username = slugify_uniquely("deleted-user", User, slugfield="username") + self.email = "{}@taiga.io".format(self.username) + self.is_active = False + self.full_name = "Deleted user" + self.color = "" + self.bio = "" + self.default_language = "" + self.default_timezone = "" + self.colorize_tags = True + self.token = None + self.github_id = None + self.set_unusable_password() + self.save() class Role(models.Model): name = models.CharField(max_length=200, null=False, blank=False, diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 2c76b154..8132c7db 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -69,3 +69,7 @@ class RecoverySerializer(serializers.Serializer): class ChangeEmailSerializer(serializers.Serializer): email_token = serializers.CharField(max_length=200) + + +class CancelAccountSerializer(serializers.Serializer): + cancel_token = serializers.CharField(max_length=200) diff --git a/taiga/users/signals.py b/taiga/users/signals.py new file mode 100644 index 00000000..e61cec01 --- /dev/null +++ b/taiga/users/signals.py @@ -0,0 +1,20 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import django.dispatch + + +user_cancel_account = django.dispatch.Signal(providing_args=["user", "request_data"]) diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja new file mode 100644 index 00000000..e82d0606 --- /dev/null +++ b/taiga/users/templates/emails/registered_user-body-html.jinja @@ -0,0 +1,24 @@ +{% extends "emails/base.jinja" %} + +{% block body %} + + + + +
+

Welcome to Taiga, an Open Source, Agile Project Management Tool

+ +

You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:

+ {{ resolve_front_url('cancel-account', cancel_token) }} + +

We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done.

+ +

We hope you enjoy it.

+
+{% endblock %} + +{% block footer %} +

+ The Taiga development team. +

+{% endblock %} diff --git a/taiga/users/templates/emails/registered_user-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja new file mode 100644 index 00000000..dde7e8b0 --- /dev/null +++ b/taiga/users/templates/emails/registered_user-body-text.jinja @@ -0,0 +1,12 @@ +Welcome to Taiga, an Open Source, Agile Project Management Tool + +You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here: + +{{ resolve_front_url('cancel-account', cancel_token) }} + +We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done. + +We hope you enjoy it. + +-- +The Taiga development team. diff --git a/taiga/users/templates/emails/registered_user-subject.jinja b/taiga/users/templates/emails/registered_user-subject.jinja new file mode 100644 index 00000000..527a27a8 --- /dev/null +++ b/taiga/users/templates/emails/registered_user-subject.jinja @@ -0,0 +1 @@ +You've been Taigatized! diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 56a8140f..60dced10 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -20,10 +20,14 @@ from unittest.mock import patch, Mock from django.apps import apps from django.core.urlresolvers import reverse +from django.core import mail + from .. import factories from taiga.base.connectors import github - +from taiga.front import resolve as resolve_front_url +from taiga.users import models +from taiga.auth.tokens import get_token_for_user pytestmark = pytest.mark.django_db @@ -68,6 +72,28 @@ def test_respond_201_with_invitation_without_public_registration(client, registe assert response.status_code == 201, response.data +def test_response_200_in_public_registration(client, settings): + settings.PUBLIC_REGISTER_ENABLED = True + form = { + "type": "public", + "username": "mmcfly", + "full_name": "martin seamus mcfly", + "email": "mmcfly@bttf.com", + "password": "password", + } + + response = client.post(reverse("auth-register"), form) + assert response.status_code == 201 + assert response.data["username"] == "mmcfly" + assert response.data["email"] == "mmcfly@bttf.com" + assert response.data["full_name"] == "martin seamus mcfly" + assert len(mail.outbox) == 1 + assert mail.outbox[0].subject == "You've been Taigatized!" + user = models.User.objects.get(username="mmcfly") + cancel_token = get_token_for_user(user, "cancel_account") + cancel_url = resolve_front_url("cancel-account", cancel_token) + assert mail.outbox[0].body.index(cancel_url) > 0 + def test_response_200_in_registration_with_github_account(client, settings): settings.PUBLIC_REGISTER_ENABLED = False form = {"type": "github", diff --git a/tests/integration/test_change_avatar.py b/tests/integration/test_change_avatar.py deleted file mode 100644 index a2202f32..00000000 --- a/tests/integration/test_change_avatar.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 David Barragán -# Copyright (C) 2014 Anler Hernández -# 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.urlresolvers import reverse - -from tempfile import NamedTemporaryFile - -import pytest - -from .. import factories as f - -pytestmark = pytest.mark.django_db - -DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' - - -def test_change_avatar(client): - url = reverse('users-change-avatar') - - user = f.UserFactory() - client.login(user) - - with NamedTemporaryFile() as avatar: - # Test no avatar send - post_data = {} - response = client.post(url, post_data) - assert response.status_code == 400 - - # Test invalid file send - post_data = { - 'avatar': avatar - } - response = client.post(url, post_data) - assert response.status_code == 400 - - # Test empty valid avatar send - avatar.write(DUMMY_BMP_DATA) - avatar.seek(0) - response = client.post(url, post_data) - assert response.status_code == 200 diff --git a/tests/integration/test_project_history.py b/tests/integration/test_history.py similarity index 100% rename from tests/integration/test_project_history.py rename to tests/integration/test_history.py diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index a500d37c..33fb4972 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -637,3 +637,21 @@ def test_valid_milestone_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 201 response_data = json.loads(response.content.decode("utf-8")) + +def test_milestone_import_duplicated_milestone(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + client.login(user) + + url = reverse("importer-milestone", args=[project.pk]) + data = { + "name": "Imported milestone", + "estimated_start": "2014-10-10", + "estimated_finish": "2014-10-20", + } + # We create twice the same milestone + response = client.post(url, json.dumps(data), content_type="application/json") + response = client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["milestones"][0]["name"][0] == "Name duplicated for the project" diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index b0b2f67a..a40cd418 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -69,3 +69,81 @@ def test_api_filter_by_subject(client): assert response.status_code == 200 assert number_of_issues == 1, number_of_issues + + +def test_api_filter_by_text_1(client): + f.create_issue() + issue = f.create_issue(subject="this is the issue one") + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=one" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + +def test_api_filter_by_text_2(client): + f.create_issue() + issue = f.create_issue(subject="this is the issue one") + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=this is the issue one" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + +def test_api_filter_by_text_3(client): + f.create_issue() + issue = f.create_issue(subject="this is the issue one") + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=this is the issue" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 2 + +def test_api_filter_by_text_4(client): + f.create_issue() + issue = f.create_issue(subject="this is the issue one") + f.create_issue(subject="this is the issue two", owner=issue.owner) + url = reverse("issues-list") + "?q=one two" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 0 + +def test_api_filter_by_text_5(client): + f.create_issue() + issue = f.create_issue(subject="python 3") + url = reverse("issues-list") + "?q=python 3" + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 + + +def test_api_filter_by_text_6(client): + f.create_issue() + issue = f.create_issue(subject="test") + url = reverse("issues-list") + "?q=%s"%(issue.ref) + + client.login(issue.owner) + response = client.get(url) + number_of_issues = len(response.data) + + assert response.status_code == 200 + assert number_of_issues == 1 diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py index 7af07411..e32f23ac 100644 --- a/tests/integration/test_milestones.py +++ b/tests/integration/test_milestones.py @@ -29,8 +29,7 @@ from .. import factories as f pytestmark = pytest.mark.django_db - -def test_api_update_milestone(client): +def test_update_milestone_with_userstories_list(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) role = f.RoleFactory.create(project=project) @@ -39,7 +38,6 @@ def test_api_update_milestone(client): points = f.PointsFactory.create(project=project, value=None) us = f.UserStoryFactory.create(project=project, owner=user) - # role_points = f.RolePointsFactory.create(points=points, user_story=us, role=role) url = reverse("milestones-detail", args=[sprint.pk]) diff --git a/tests/integration/test_neighbors.py b/tests/integration/test_neighbors.py index 83adf1be..3281dcee 100644 --- a/tests/integration/test_neighbors.py +++ b/tests/integration/test_neighbors.py @@ -137,6 +137,18 @@ class TestIssues: assert neighbors.left == issue3 assert neighbors.right == issue1 + def test_empty_related_queryset(self): + project = f.ProjectFactory.create() + + issue1 = f.IssueFactory.create(project=project) + issue2 = f.IssueFactory.create(project=project) + issue3 = f.IssueFactory.create(project=project) + + neighbors = n.get_neighbors(issue2, Issue.objects.none()) + + assert neighbors.left == issue3 + assert neighbors.right == issue1 + def test_ordering_by_severity(self): project = f.ProjectFactory.create() severity1 = f.SeverityFactory.create(project=project, order=1) diff --git a/tests/integration/test_project_notifications.py b/tests/integration/test_notifications.py similarity index 100% rename from tests/integration/test_project_notifications.py rename to tests/integration/test_notifications.py diff --git a/tests/integration/test_occ.py b/tests/integration/test_occ.py index 97626290..eb25eef8 100644 --- a/tests/integration/test_occ.py +++ b/tests/integration/test_occ.py @@ -15,11 +15,11 @@ # along with this program. If not, see . import pytest -import json from unittest.mock import patch from django.core.urlresolvers import reverse +from taiga.base.utils import json from taiga.projects.issues.models import Issue from taiga.projects.wiki.models import WikiPage from taiga.projects.userstories.models import UserStory @@ -58,7 +58,7 @@ def test_valid_concurrent_save_for_issue(client): url = reverse("issues-detail", args=(issue.id,)) data = {"version": 10} response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content.decode('utf-8'))['version'] == 11 + assert json.loads(response.content)['version'] == 11 assert response.status_code == 200 issue = Issue.objects.get(id=issue.id) assert issue.version == 11 @@ -85,7 +85,7 @@ def test_valid_concurrent_save_for_wiki_page(client): url = reverse("wiki-detail", args=(wiki_page.id,)) data = {"version": 10} response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content.decode('utf-8'))['version'] == 11 + assert json.loads(response.content)['version'] == 11 assert response.status_code == 200 wiki_page = WikiPage.objects.get(id=wiki_page.id) assert wiki_page.version == 11 @@ -128,7 +128,7 @@ def test_valid_concurrent_save_for_us(client): url = reverse("userstories-detail", args=(userstory.id,)) data = {"version": 10} response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content.decode('utf-8'))['version'] == 11 + assert json.loads(response.content)['version'] == 11 assert response.status_code == 200 userstory = UserStory.objects.get(id=userstory.id) assert userstory.version == 11 @@ -159,7 +159,7 @@ def test_valid_concurrent_save_for_task(client): url = reverse("tasks-detail", args=(task.id,)) data = {"version": 10} response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content.decode('utf-8'))['version'] == 11 + assert json.loads(response.content)['version'] == 11 assert response.status_code == 200 task = Task.objects.get(id=task.id) assert task.version == 11 diff --git a/tests/integration/test_project_us.py b/tests/integration/test_project_us.py deleted file mode 100644 index c369daa7..00000000 --- a/tests/integration/test_project_us.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 David Barragán -# Copyright (C) 2014 Anler Hernández -# 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 . - -import pytest -import json - -from django.core.urlresolvers import reverse -from .. import factories as f - - -pytestmark = pytest.mark.django_db - - -def test_archived_filter(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, user=user) - f.UserStoryFactory.create(project=project) - f.UserStoryFactory.create(is_archived=True, project=project) - - client.login(user) - - url = reverse("userstories-list") - - data = {} - response = client.get(url, data) - assert len(json.loads(response.content.decode('utf-8'))) == 2 - - data = {"is_archived": 0} - response = client.get(url, data) - assert len(json.loads(response.content.decode('utf-8'))) == 1 - - data = {"is_archived": 1} - response = client.get(url, data) - assert len(json.loads(response.content.decode('utf-8'))) == 1 diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 20762415..f2a6c249 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -7,7 +7,7 @@ import pytest pytestmark = pytest.mark.django_db -def test_api_create_project(client): +def test_create_project(client): user = f.create_user() url = reverse("projects-list") data = {"name": "project name", "description": "project description"} @@ -18,7 +18,7 @@ def test_api_create_project(client): assert response.status_code == 201 -def test_api_partially_update_project(client): +def test_partially_update_project(client): project = f.create_project() url = reverse("projects-detail", kwargs={"pk": project.pk}) data = {"name": ""} diff --git a/tests/integration/test_project_references_sequences.py b/tests/integration/test_references_sequences.py similarity index 100% rename from tests/integration/test_project_references_sequences.py rename to tests/integration/test_references_sequences.py diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 6418eba8..c7c9e6ad 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -61,3 +61,24 @@ def test_api_create_in_bulk_with_status(client): assert response.status_code == 200 assert response.data[0]["status"] == us.project.default_task_status.id + + +def test_api_create_invalid_task(client): + # Associated to a milestone and a user story. + # But the User Story is not associated with the milestone + us_milestone = f.MilestoneFactory.create() + us = f.create_userstory(milestone=us_milestone) + task_milestone = f.MilestoneFactory.create(project=us.project, owner=us.owner) + + url = reverse("tasks-list") + data = { + "user_story": us.id, + "milestone": task_milestone.id, + "subject": "Testing subject", + "status": us.project.default_task_status.id, + "project": us.project.id + } + + client.login(us.owner) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index 6354fbad..7c749cdb 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -1,16 +1,18 @@ import pytest import json +from tempfile import NamedTemporaryFile from django.core.urlresolvers import reverse from .. import factories as f from taiga.users import models +from taiga.auth.tokens import get_token_for_user pytestmark = pytest.mark.django_db -def test_api_user_normal_user(client): +def test_users_create_through_standard_api(client): user = f.UserFactory.create(is_superuser=True) url = reverse('users-list') @@ -25,7 +27,7 @@ def test_api_user_normal_user(client): assert response.status_code == 405 -def test_api_user_patch_same_email(client): +def test_update_user_with_same_email(client): user = f.UserFactory.create(email="same@email.com") url = reverse('users-detail', kwargs={"pk": user.pk}) data = {"email": "same@email.com"} @@ -37,7 +39,7 @@ def test_api_user_patch_same_email(client): assert response.data['_error_message'] == 'Duplicated email' -def test_api_user_patch_duplicated_email(client): +def test_update_user_with_duplicated_email(client): f.UserFactory.create(email="one@email.com") user = f.UserFactory.create(email="two@email.com") url = reverse('users-detail', kwargs={"pk": user.pk}) @@ -50,7 +52,7 @@ def test_api_user_patch_duplicated_email(client): assert response.data['_error_message'] == 'Duplicated email' -def test_api_user_patch_invalid_email(client): +def test_update_user_with_invalid_email(client): user = f.UserFactory.create(email="my@email.com") url = reverse('users-detail', kwargs={"pk": user.pk}) data = {"email": "my@email"} @@ -62,7 +64,7 @@ def test_api_user_patch_invalid_email(client): assert response.data['_error_message'] == 'Not valid email' -def test_api_user_patch_valid_email(client): +def test_update_user_with_valid_email(client): user = f.UserFactory.create(email="old@email.com") url = reverse('users-detail', kwargs={"pk": user.pk}) data = {"email": "new@email.com"} @@ -76,7 +78,7 @@ def test_api_user_patch_valid_email(client): assert user.new_email == "new@email.com" -def test_api_user_action_change_email_ok(client): +def test_validate_requested_email_change(client): user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com") url = reverse('users-change-email') data = {"email_token": "change_email_token"} @@ -91,19 +93,17 @@ def test_api_user_action_change_email_ok(client): assert user.email == "new@email.com" -def test_api_user_action_change_email_no_token(client): +def test_validate_requested_email_change_without_token(client): user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com") url = reverse('users-change-email') data = {} client.login(user) response = client.post(url, json.dumps(data), content_type="application/json") - assert response.status_code == 400 - assert response.data['_error_message'] == 'Invalid, are you sure the token is correct and you didn\'t use it before?' -def test_api_user_action_change_email_invalid_token(client): +def test_validate_requested_email_change_with_invalid_token(client): user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com") url = reverse('users-change-email') data = {"email_token": "invalid_email_token"} @@ -112,4 +112,67 @@ def test_api_user_action_change_email_invalid_token(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 - assert response.data['_error_message'] == 'Invalid, are you sure the token is correct and you didn\'t use it before?' + + +def test_delete_self_user(client): + user = f.UserFactory.create() + url = reverse('users-detail', kwargs={"pk": user.pk}) + + client.login(user) + response = client.delete(url) + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + +def test_cancel_self_user_with_valid_token(client): + user = f.UserFactory.create() + url = reverse('users-cancel') + cancel_token = get_token_for_user(user, "cancel_account") + data = {"cancel_token": cancel_token} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 204 + user = models.User.objects.get(pk=user.id) + assert user.full_name == "Deleted user" + + +def test_cancel_self_user_with_invalid_token(client): + user = f.UserFactory.create() + url = reverse('users-cancel') + data = {"cancel_token": "invalid_cancel_token"} + client.login(user) + response = client.post(url, json.dumps(data), content_type="application/json") + + assert response.status_code == 400 + + +DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + + +def test_change_avatar(client): + url = reverse('users-change-avatar') + + user = f.UserFactory() + client.login(user) + + with NamedTemporaryFile() as avatar: + # Test no avatar send + post_data = {} + response = client.post(url, post_data) + assert response.status_code == 400 + + # Test invalid file send + post_data = { + 'avatar': avatar + } + response = client.post(url, post_data) + assert response.status_code == 400 + + # Test empty valid avatar send + avatar.write(DUMMY_BMP_DATA) + avatar.seek(0) + response = client.post(url, post_data) + assert response.status_code == 200 diff --git a/tests/unit/test_tokens.py b/tests/unit/test_tokens.py new file mode 100644 index 00000000..626555ab --- /dev/null +++ b/tests/unit/test_tokens.py @@ -0,0 +1,52 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# Copyright (C) 2014 Anler Hernández +# 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 . + +import pytest + +from .. import factories as f + +from taiga.base import exceptions as exc +from taiga.auth.tokens import get_token_for_user, get_user_for_token + + +pytestmark = pytest.mark.django_db + +def test_valid_token(): + user = f.UserFactory.create(email="old@email.com") + token = get_token_for_user(user, "testing_scope") + user_from_token = get_user_for_token(token, "testing_scope") + assert user.id == user_from_token.id + + +@pytest.mark.xfail(raises=exc.NotAuthenticated) +def test_invalid_token(): + user = f.UserFactory.create(email="old@email.com") + user_from_token = get_user_for_token("testing_invalid_token", "testing_scope") + + +@pytest.mark.xfail(raises=exc.NotAuthenticated) +def test_invalid_token_expiration(): + user = f.UserFactory.create(email="old@email.com") + token = get_token_for_user(user, "testing_scope") + user_from_token = get_user_for_token(token, "testing_scope", max_age=1) + + +@pytest.mark.xfail(raises=exc.NotAuthenticated) +def test_invalid_token_scope(): + user = f.UserFactory.create(email="old@email.com") + token = get_token_for_user(user, "testing_scope") + user_from_token = get_user_for_token(token, "testing_invalid_scope")