From a43711be70f199e1d4142253d39c337b53edfd8e Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 8 Oct 2014 09:18:34 +0200
Subject: [PATCH 01/31] Adding cancel_token generation to user on creation
---
taiga/users/admin.py | 2 +-
.../migrations/0006_user_cancel_token.py | 20 +++++++++++++++++++
taiga/users/models.py | 9 +++++++++
3 files changed, 30 insertions(+), 1 deletion(-)
create mode 100644 taiga/users/migrations/0006_user_cancel_token.py
diff --git a/taiga/users/admin.py b/taiga/users/admin.py
index a3452616..701e9da6 100644
--- a/taiga/users/admin.py
+++ b/taiga/users/admin.py
@@ -48,7 +48,7 @@ class UserAdmin(DjangoUserAdmin):
fieldsets = (
(None, {'fields': ('username', 'password')}),
(_('Personal info'), {'fields': ('full_name', 'email', 'bio', 'photo')}),
- (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email')}),
+ (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email', 'cancel_token')}),
(_('Permissions'), {'fields': ('is_active', 'is_superuser',)}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
)
diff --git a/taiga/users/migrations/0006_user_cancel_token.py b/taiga/users/migrations/0006_user_cancel_token.py
new file mode 100644
index 00000000..d854a31a
--- /dev/null
+++ b/taiga/users/migrations/0006_user_cancel_token.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0005_alter_user_photo'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='cancel_token',
+ field=models.CharField(default=None, max_length=200, blank=True, null=True, verbose_name='email token'),
+ preserve_default=True,
+ ),
+ ]
diff --git a/taiga/users/models.py b/taiga/users/models.py
index 2a3c35eb..08423515 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 django.db import models
from django.dispatch import receiver
@@ -123,6 +124,9 @@ class User(AbstractBaseUser, PermissionsMixin):
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"))
+ cancel_token = models.CharField(max_length=200, null=True, blank=True, default=None,
+ verbose_name=_("cancel account token"))
+
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']
@@ -146,6 +150,11 @@ class User(AbstractBaseUser, PermissionsMixin):
def get_full_name(self):
return self.full_name or self.username or self.email
+ def save(self, *args, **kwargs):
+ if not self.cancel_token:
+ self.cancel_token = str(uuid.uuid1())
+
+ super().save(*args, **kwargs)
class Role(models.Model):
name = models.CharField(max_length=200, null=False, blank=False,
From 2bfc09b2ee1b1e5c258ebded6bf859dc794cb002 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 8 Oct 2014 10:33:08 +0200
Subject: [PATCH 02/31] Adding cancel account by token API
---
taiga/users/api.py | 31 +++++++++++++++++------------
taiga/users/models.py | 15 ++++++++++++++
tests/integration/test_users.py | 35 +++++++++++++++++++++++++++++++++
3 files changed, 68 insertions(+), 13 deletions(-)
diff --git a/taiga/users/api.py b/taiga/users/api.py
index 8c602b89..6a38ab35 100644
--- a/taiga/users/api.py
+++ b/taiga/users/api.py
@@ -258,20 +258,25 @@ 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:
+ user = models.User.objects.get(cancel_token=serializer.data["cancel_token"])
+ except models.User.DoesNotExist:
+ 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()
+ user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/taiga/users/models.py b/taiga/users/models.py
index 08423515..782dd0b7 100644
--- a/taiga/users/models.py
+++ b/taiga/users/models.py
@@ -156,6 +156,21 @@ class User(AbstractBaseUser, PermissionsMixin):
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,
verbose_name=_("name"))
diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py
index 6354fbad..fb79ef24 100644
--- a/tests/integration/test_users.py
+++ b/tests/integration/test_users.py
@@ -113,3 +113,38 @@ def test_api_user_action_change_email_invalid_token(client):
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_delete(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_api_user_cancel_valid_token(client):
+ user = f.UserFactory.create()
+ url = reverse('users-cancel')
+ data = {"cancel_token": user.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_api_user_cancel_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
+ assert response.data['_error_message'] == "Invalid, are you sure the token is correct?"
From cceef80dbd8352727502e540aab79524f6139751 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 8 Oct 2014 11:27:43 +0200
Subject: [PATCH 03/31] Adding serializer
---
taiga/users/serializers.py | 4 ++++
1 file changed, 4 insertions(+)
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)
From 227add34f065743ef6757ab614e7a4d93de864ce Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 8 Oct 2014 13:08:54 +0200
Subject: [PATCH 04/31] Sending email on the registry of user
---
taiga/auth/services.py | 25 +++++-------------
taiga/front/__init__.py | 1 +
.../emails/registered_user-body-html.jinja | 12 +++++++++
.../emails/registered_user-body-text.jinja | 12 +++++++++
.../emails/registered_user-subject.jinja | 1 +
tests/integration/test_auth_api.py | 26 ++++++++++++++++++-
6 files changed, 58 insertions(+), 19 deletions(-)
create mode 100644 taiga/users/templates/emails/registered_user-body-html.jinja
create mode 100644 taiga/users/templates/emails/registered_user-body-text.jinja
create mode 100644 taiga/users/templates/emails/registered_user-subject.jinja
diff --git a/taiga/auth/services.py b/taiga/auth/services.py
index 10c4e573..17209711 100644
--- a/taiga/auth/services.py
+++ b/taiga/auth/services.py
@@ -38,28 +38,15 @@ from taiga.users.services import get_and_validate_user
from .backends 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}
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
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/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja
new file mode 100644
index 00000000..9db5151a
--- /dev/null
+++ b/taiga/users/templates/emails/registered_user-body-html.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', user.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-body-text.jinja b/taiga/users/templates/emails/registered_user-body-text.jinja
new file mode 100644
index 00000000..9db5151a
--- /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', user.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..6db03f83 100644
--- a/tests/integration/test_auth_api.py
+++ b/tests/integration/test_auth_api.py
@@ -20,10 +20,13 @@ 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
pytestmark = pytest.mark.django_db
@@ -68,6 +71,27 @@ 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_url = resolve_front_url("cancel-account", user.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",
From 4404a58b45375f7df5995a6679cfd0c6f8c44c10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Xavier=20Juli=C3=A1n?=
Date: Thu, 9 Oct 2014 09:59:10 +0200
Subject: [PATCH 05/31] Basic email template layout
---
.../emails/registered_user-body-html.jinja | 29 ++++++++++++++-----
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja
index 9db5151a..ba48feb9 100644
--- a/taiga/users/templates/emails/registered_user-body-html.jinja
+++ b/taiga/users/templates/emails/registered_user-body-html.jinja
@@ -1,12 +1,27 @@
-Welcome to Taiga, an Open Source, Agile Project Management Tool
+{% extends "emails/base.jinja" %}
-You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:
+{% block body %}
+
+
+
+ Welcome to Taiga, an Open Source, Agile Project Management Tool
-{{ resolve_front_url('cancel-account', user.cancel_token) }}
+ You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:
-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.
+ {{ resolve_front_url('cancel-account', user.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 %}
-We hope you enjoy it.
---
-The Taiga development team.
From 4b859bbde98be15279959e36737fe8d38659ed57 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Thu, 9 Oct 2014 18:06:17 +0200
Subject: [PATCH 06/31] Removing cancel_token and using django.core.signing
stuff
---
settings/common.py | 3 ++
taiga/auth/backends.py | 43 +++------------
taiga/auth/services.py | 8 +--
taiga/auth/tokens.py | 54 +++++++++++++++++++
taiga/users/admin.py | 2 +-
taiga/users/api.py | 8 ++-
.../migrations/0006_user_cancel_token.py | 20 -------
taiga/users/models.py | 8 +--
.../emails/registered_user-body-html.jinja | 5 +-
.../emails/registered_user-body-text.jinja | 2 +-
tests/integration/test_auth_api.py | 4 +-
tests/integration/test_users.py | 4 +-
12 files changed, 84 insertions(+), 77 deletions(-)
create mode 100644 taiga/auth/tokens.py
delete mode 100644 taiga/users/migrations/0006_user_cancel_token.py
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 17209711..348e41d4 100644
--- a/taiga/auth/services.py
+++ b/taiga/auth/services.py
@@ -35,7 +35,7 @@ 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_register_email(user) -> bool:
@@ -43,8 +43,8 @@ def send_register_email(user) -> bool:
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.registered_user(user.email, context)
return bool(email.send())
@@ -207,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..6b5afd7b
--- /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:
+ raise exc.NotAuthenticated("Invalid token")
+ else:
+ return user
diff --git a/taiga/users/admin.py b/taiga/users/admin.py
index 701e9da6..a3452616 100644
--- a/taiga/users/admin.py
+++ b/taiga/users/admin.py
@@ -48,7 +48,7 @@ class UserAdmin(DjangoUserAdmin):
fieldsets = (
(None, {'fields': ('username', 'password')}),
(_('Personal info'), {'fields': ('full_name', 'email', 'bio', 'photo')}),
- (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email', 'cancel_token')}),
+ (_('Extra info'), {'fields': ('color', 'default_language', 'default_timezone', 'token', 'colorize_tags', 'email_token', 'new_email')}),
(_('Permissions'), {'fields': ('is_active', 'is_superuser',)}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
)
diff --git a/taiga/users/api.py b/taiga/users/api.py
index 6a38ab35..7e2a0105 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
@@ -268,8 +270,10 @@ class UsersViewSet(ModelCrudViewSet):
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
try:
- user = models.User.objects.get(cancel_token=serializer.data["cancel_token"])
- except models.User.DoesNotExist:
+ 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?"))
user.cancel()
diff --git a/taiga/users/migrations/0006_user_cancel_token.py b/taiga/users/migrations/0006_user_cancel_token.py
deleted file mode 100644
index d854a31a..00000000
--- a/taiga/users/migrations/0006_user_cancel_token.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
-from django.db import models, migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('users', '0005_alter_user_photo'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='user',
- name='cancel_token',
- field=models.CharField(default=None, max_length=200, blank=True, null=True, verbose_name='email token'),
- preserve_default=True,
- ),
- ]
diff --git a/taiga/users/models.py b/taiga/users/models.py
index 782dd0b7..e38f5417 100644
--- a/taiga/users/models.py
+++ b/taiga/users/models.py
@@ -31,6 +31,7 @@ from django.utils.encoding import force_bytes
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
@@ -124,9 +125,6 @@ class User(AbstractBaseUser, PermissionsMixin):
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"))
- cancel_token = models.CharField(max_length=200, null=True, blank=True, default=None,
- verbose_name=_("cancel account token"))
-
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']
@@ -151,9 +149,7 @@ class User(AbstractBaseUser, PermissionsMixin):
return self.full_name or self.username or self.email
def save(self, *args, **kwargs):
- if not self.cancel_token:
- self.cancel_token = str(uuid.uuid1())
-
+ get_token_for_user(self, "cancel_account")
super().save(*args, **kwargs)
def cancel(self):
diff --git a/taiga/users/templates/emails/registered_user-body-html.jinja b/taiga/users/templates/emails/registered_user-body-html.jinja
index ba48feb9..e82d0606 100644
--- a/taiga/users/templates/emails/registered_user-body-html.jinja
+++ b/taiga/users/templates/emails/registered_user-body-html.jinja
@@ -7,8 +7,7 @@
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', user.cancel_token) }}
+ {{ 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.
@@ -23,5 +22,3 @@
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
index 9db5151a..dde7e8b0 100644
--- a/taiga/users/templates/emails/registered_user-body-text.jinja
+++ b/taiga/users/templates/emails/registered_user-body-text.jinja
@@ -2,7 +2,7 @@ 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', user.cancel_token) }}
+{{ 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.
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
index 6db03f83..60dced10 100644
--- a/tests/integration/test_auth_api.py
+++ b/tests/integration/test_auth_api.py
@@ -27,6 +27,7 @@ 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
@@ -89,7 +90,8 @@ def test_response_200_in_public_registration(client, settings):
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == "You've been Taigatized!"
user = models.User.objects.get(username="mmcfly")
- cancel_url = resolve_front_url("cancel-account", user.cancel_token)
+ 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):
diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py
index fb79ef24..2beb3762 100644
--- a/tests/integration/test_users.py
+++ b/tests/integration/test_users.py
@@ -6,6 +6,7 @@ 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
@@ -130,7 +131,8 @@ def test_api_user_delete(client):
def test_api_user_cancel_valid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
- data = {"cancel_token": user.cancel_token}
+ 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")
From fab6d20338a1bffe1c765eec6a07f0b9d357076a Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Mon, 13 Oct 2014 10:30:14 +0200
Subject: [PATCH 07/31] Add next version section on changelog.
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 734a282d..6e801a10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog #
+## 1.2.0 (Unreleased)
+
+No changes at this moment.
+
## 1.1.0 (2014-10-13)
### Misc
From 51f0ef7fef9cf1f9bffa843fa73fb951a504c4c6 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Mon, 13 Oct 2014 16:59:00 +0200
Subject: [PATCH 08/31] Update gunicorn version on requirements.
---
requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From e533cadb3fac453bc7f5451789ff60a937bf5255 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Tue, 14 Oct 2014 17:31:47 +0200
Subject: [PATCH 09/31] Increase default backlog order values
---
.../migrations/0006_auto_20141014_1524.py | 29 +++++++++++++++++++
taiga/projects/userstories/models.py | 6 ++--
2 files changed, 32 insertions(+), 3 deletions(-)
create mode 100644 taiga/projects/userstories/migrations/0006_auto_20141014_1524.py
diff --git a/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py b/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py
new file mode 100644
index 00000000..bbf974e1
--- /dev/null
+++ b/taiga/projects/userstories/migrations/0006_auto_20141014_1524.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('userstories', '0005_auto_20141009_1656'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='userstory',
+ name='backlog_order',
+ field=models.IntegerField(default=10000, verbose_name='backlog order'),
+ ),
+ migrations.AlterField(
+ model_name='userstory',
+ name='kanban_order',
+ field=models.IntegerField(default=10000, verbose_name='sprint order'),
+ ),
+ migrations.AlterField(
+ model_name='userstory',
+ name='sprint_order',
+ field=models.IntegerField(default=10000, verbose_name='sprint order'),
+ ),
+ ]
diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py
index 7f824c84..77e487eb 100644
--- a/taiga/projects/userstories/models.py
+++ b/taiga/projects/userstories/models.py
@@ -68,11 +68,11 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, mod
related_name="userstories", through="RolePoints",
verbose_name=_("points"))
- backlog_order = models.IntegerField(null=False, blank=False, default=1,
+ backlog_order = models.IntegerField(null=False, blank=False, default=10000,
verbose_name=_("backlog order"))
- sprint_order = models.IntegerField(null=False, blank=False, default=1,
+ sprint_order = models.IntegerField(null=False, blank=False, default=10000,
verbose_name=_("sprint order"))
- kanban_order = models.IntegerField(null=False, blank=False, default=1,
+ kanban_order = models.IntegerField(null=False, blank=False, default=10000,
verbose_name=_("sprint order"))
created_date = models.DateTimeField(null=False, blank=False,
From 169c0e364c81ad623f0a3424e9c7e9c44b485db5 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Mon, 13 Oct 2014 12:19:24 +0200
Subject: [PATCH 10/31] Launching cancel_account signal when needed
---
taiga/users/api.py | 4 ++++
taiga/users/signals.py | 20 ++++++++++++++++++++
2 files changed, 24 insertions(+)
create mode 100644 taiga/users/signals.py
diff --git a/taiga/users/api.py b/taiga/users/api.py
index 74d50ddf..ef93e36d 100644
--- a/taiga/users/api.py
+++ b/taiga/users/api.py
@@ -44,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):
@@ -282,5 +283,8 @@ class UsersViewSet(ModelCrudViewSet):
def destroy(self, request, pk=None):
user = self.get_object()
self.check_permissions(request, "destroy", user)
+ 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/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"])
From 63364f430443ca995570748e8247e24a5bba02cd Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Tue, 14 Oct 2014 17:21:00 +0200
Subject: [PATCH 11/31] Fixing data sent by cancel account signal
---
taiga/users/api.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/users/api.py b/taiga/users/api.py
index ef93e36d..79c0373f 100644
--- a/taiga/users/api.py
+++ b/taiga/users/api.py
@@ -285,6 +285,6 @@ class UsersViewSet(ModelCrudViewSet):
self.check_permissions(request, "destroy", user)
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_account_signal.send(sender=user.__class__, user=user, request_data=request_data)
user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)
From be8d1719d3ff2990af1afdbff34b32f19879eeb6 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Wed, 15 Oct 2014 13:16:37 +0200
Subject: [PATCH 12/31] Disabling the posibility of multiple account
cancelations for the same cancel_token
---
taiga/users/api.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/taiga/users/api.py b/taiga/users/api.py
index 79c0373f..56bee9e7 100644
--- a/taiga/users/api.py
+++ b/taiga/users/api.py
@@ -274,9 +274,13 @@ class UsersViewSet(ModelCrudViewSet):
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)
From 480baa3b948818059c0d31c758398ddcd58b1bbb Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 17:29:20 +0200
Subject: [PATCH 13/31] Auto coerce bytes to string on own json module.
---
taiga/base/utils/json.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/taiga/base/utils/json.py b/taiga/base/utils/json.py
index dcb818a3..bb8dde78 100644
--- a/taiga/base/utils/json.py
+++ b/taiga/base/utils/json.py
@@ -16,13 +16,15 @@
import json
from rest_framework.utils import encoders
+from django.utils.encoding import force_text
def dumps(data, ensure_ascii=True, encoder_class=encoders.JSONEncoder):
return json.dumps(data, cls=encoder_class, indent=None, ensure_ascii=ensure_ascii)
-
def loads(data):
+ if isinstance(data, bytes):
+ data = force_text(data)
return json.loads(data)
# Some backward compatibility that should
From 9b6c58bad941003b482a53773463da54c6626245 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 17:29:58 +0200
Subject: [PATCH 14/31] Normalize some tests file names.
---
tests/integration/{test_project_history.py => test_history.py} | 0
.../{test_project_notifications.py => test_notifications.py} | 0
...oject_references_sequences.py => test_references_sequences.py} | 0
3 files changed, 0 insertions(+), 0 deletions(-)
rename tests/integration/{test_project_history.py => test_history.py} (100%)
rename tests/integration/{test_project_notifications.py => test_notifications.py} (100%)
rename tests/integration/{test_project_references_sequences.py => test_references_sequences.py} (100%)
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_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_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
From 0f207da0dc77f18d8b67c772fc956736350fb497 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 17:30:22 +0200
Subject: [PATCH 15/31] Put more clear names to user test functions.
---
tests/integration/test_change_avatar.py | 54 ------------------------
tests/integration/test_users.py | 56 ++++++++++++++++++-------
2 files changed, 41 insertions(+), 69 deletions(-)
delete mode 100644 tests/integration/test_change_avatar.py
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_users.py b/tests/integration/test_users.py
index 2beb3762..7c749cdb 100644
--- a/tests/integration/test_users.py
+++ b/tests/integration/test_users.py
@@ -1,5 +1,6 @@
import pytest
import json
+from tempfile import NamedTemporaryFile
from django.core.urlresolvers import reverse
@@ -11,7 +12,7 @@ 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')
@@ -26,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"}
@@ -38,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})
@@ -51,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"}
@@ -63,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"}
@@ -77,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"}
@@ -92,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"}
@@ -113,10 +112,9 @@ 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_api_user_delete(client):
+def test_delete_self_user(client):
user = f.UserFactory.create()
url = reverse('users-detail', kwargs={"pk": user.pk})
@@ -128,7 +126,7 @@ def test_api_user_delete(client):
assert user.full_name == "Deleted user"
-def test_api_user_cancel_valid_token(client):
+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")
@@ -141,7 +139,7 @@ def test_api_user_cancel_valid_token(client):
assert user.full_name == "Deleted user"
-def test_api_user_cancel_invalid_token(client):
+def test_cancel_self_user_with_invalid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
data = {"cancel_token": "invalid_cancel_token"}
@@ -149,4 +147,32 @@ def test_api_user_cancel_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?"
+
+
+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
From 786599239cb69f72f5365f37772bdffb70023a32 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 17:31:14 +0200
Subject: [PATCH 16/31] Minor tests cleaning.
---
tests/integration/test_milestones.py | 1 -
tests/integration/test_occ.py | 10 +++++-----
tests/integration/test_project_us.py | 10 +++++-----
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py
index 7af07411..ab5cc962 100644
--- a/tests/integration/test_milestones.py
+++ b/tests/integration/test_milestones.py
@@ -39,7 +39,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_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
index c369daa7..65abf3dc 100644
--- a/tests/integration/test_project_us.py
+++ b/tests/integration/test_project_us.py
@@ -16,9 +16,9 @@
# along with this program. If not, see .
import pytest
-import json
-
from django.core.urlresolvers import reverse
+
+from taiga.base.utils import json
from .. import factories as f
@@ -38,12 +38,12 @@ def test_archived_filter(client):
data = {}
response = client.get(url, data)
- assert len(json.loads(response.content.decode('utf-8'))) == 2
+ assert len(json.loads(response.content)) == 2
data = {"is_archived": 0}
response = client.get(url, data)
- assert len(json.loads(response.content.decode('utf-8'))) == 1
+ assert len(json.loads(response.content)) == 1
data = {"is_archived": 1}
response = client.get(url, data)
- assert len(json.loads(response.content.decode('utf-8'))) == 1
+ assert len(json.loads(response.content)) == 1
From 21f183556761d5001bf05ff612dc0f5dd47c7e18 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 17:36:19 +0200
Subject: [PATCH 17/31] Fix milestone test name.
---
tests/integration/test_milestones.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py
index ab5cc962..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)
From 43fd17aaaa61c7f1cd2872465d4e4c1840867ed7 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 18:05:53 +0200
Subject: [PATCH 18/31] Fix wrong handling moveTo parameter on role destroy.
---
taiga/projects/api.py | 30 +++++-------
tests/integration/test_roles.py | 82 +++++++++++++++++++++++++++++++++
2 files changed, 94 insertions(+), 18 deletions(-)
create mode 100644 tests/integration/test_roles.py
diff --git a/taiga/projects/api.py b/taiga/projects/api.py
index 1b18e4c3..914c4f77 100644
--- a/taiga/projects/api.py
+++ b/taiga/projects/api.py
@@ -256,20 +256,14 @@ class RolesViewSet(ModelCrudViewSet):
filter_backends = (filters.CanViewProjectFilterBackend,)
filter_fields = ('project',)
- @tx.atomic
- def destroy(self, request, *args, **kwargs):
- moveTo = self.request.QUERY_PARAMS.get('moveTo', None)
- if moveTo is None:
- return super().destroy(request, *args, **kwargs)
+ def pre_delete(self, obj):
+ move_to = self.request.QUERY_PARAMS.get('moveTo', None)
+ if move_to:
+ role_dest = get_object_or_404(self.model, project=obj.project, id=move_to)
+ qs = models.Membership.objects.filter(project_id=obj.project.pk, role=obj)
+ qs.update(role=role_dest)
- obj = self.get_object_or_none()
-
- moveItem = get_object_or_404(self.model, project=obj.project, id=moveTo)
-
- self.check_permissions(request, 'destroy', obj)
-
- models.Membership.objects.filter(project=obj.project, role=obj).update(role=moveItem)
- return super().destroy(request, *args, **kwargs)
+ super().pre_delete(obj)
# User Stories commin ViewSets
@@ -317,19 +311,19 @@ class PointsViewSet(ModelCrudViewSet, BulkUpdateOrderMixin):
class MoveOnDestroyMixin(object):
@tx.atomic
def destroy(self, request, *args, **kwargs):
- moveTo = self.request.QUERY_PARAMS.get('moveTo', None)
- if moveTo is None:
+ move_to = self.request.QUERY_PARAMS.get('moveTo', None)
+ if move_to is None:
return super().destroy(request, *args, **kwargs)
obj = self.get_object_or_none()
- moveItem = get_object_or_404(self.model, project=obj.project, id=moveTo)
+ move_item = get_object_or_404(self.model, project=obj.project, id=move_to)
self.check_permissions(request, 'destroy', obj)
- kwargs = {self.move_on_destroy_related_field: moveItem}
+ kwargs = {self.move_on_destroy_related_field: move_item}
self.move_on_destroy_related_class.objects.filter(project=obj.project, **{self.move_on_destroy_related_field: obj}).update(**kwargs)
if getattr(obj.project, self.move_on_destroy_project_default_field) == obj:
- setattr(obj.project, self.move_on_destroy_project_default_field, moveItem)
+ setattr(obj.project, self.move_on_destroy_project_default_field, move_item)
obj.project.save()
return super().destroy(request, *args, **kwargs)
diff --git a/tests/integration/test_roles.py b/tests/integration/test_roles.py
new file mode 100644
index 00000000..e8bd89fa
--- /dev/null
+++ b/tests/integration/test_roles.py
@@ -0,0 +1,82 @@
+# 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 unittest.mock import patch, Mock
+
+from django.apps import apps
+from django.core.urlresolvers import reverse
+
+from taiga.base.utils import json
+
+from taiga.users.models import Role
+from taiga.projects.models import Membership
+from taiga.projects.models import Project
+from taiga.projects.userstories.serializers import UserStorySerializer
+
+from .. import factories as f
+
+
+pytestmark = pytest.mark.django_db
+
+def test_destroy_role_and_reassign_members(client):
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user1)
+ role1 = f.RoleFactory.create(project=project)
+ role2 = f.RoleFactory.create(project=project)
+ member = f.MembershipFactory.create(project=project, user=user1, role=role1)
+ member = f.MembershipFactory.create(project=project, user=user2, role=role2)
+
+ url = reverse("roles-detail", args=[role2.pk]) + "?moveTo={}".format(role1.pk)
+
+ client.login(user1)
+
+ response = client.delete(url)
+ assert response.status_code == 204
+
+ qs = Role.objects.filter(project=project)
+ assert qs.count() == 1
+
+ qs = Membership.objects.filter(project=project, role_id=role2.pk)
+ assert qs.count() == 0
+
+ qs = Membership.objects.filter(project=project, role_id=role1.pk)
+ assert qs.count() == 2
+
+def test_destroy_role_and_reassign_members_with_deleted_project(client):
+ """
+ Regression test, that fixes some 500 errors on production
+ """
+
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user1)
+ role1 = f.RoleFactory.create(project=project)
+ role2 = f.RoleFactory.create(project=project)
+ member = f.MembershipFactory.create(project=project, user=user1, role=role1)
+ member = f.MembershipFactory.create(project=project, user=user2, role=role2)
+
+ Project.objects.filter(pk=project.id).delete()
+
+ url = reverse("roles-detail", args=[role2.pk]) + "?moveTo={}".format(role1.pk)
+ client.login(user1)
+
+ response = client.delete(url)
+
+ # FIXME: really should return 403? I think it should be 404
+ assert response.status_code == 403, response.content
From a3cb48cf8e9fbb01c965f12800032d1c672c2ab9 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 19:49:16 +0200
Subject: [PATCH 19/31] Fix wrong handling role points update.
Additionally it moves the logic from serializer to resource.
---
taiga/projects/userstories/api.py | 38 +++++++++++--
taiga/projects/userstories/serializers.py | 13 -----
tests/integration/test_userstories.py | 65 +++++++++++++++++++----
3 files changed, 89 insertions(+), 27 deletions(-)
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index f4b604ed..f1a87c9a 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -14,9 +14,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+from contextlib import suppress
+
+from django.apps import apps
from django.db import transaction
from django.utils.translation import ugettext as _
from django.shortcuts import get_object_or_404
+from django.core.exceptions import ObjectDoesNotExist
from rest_framework.response import Response
from rest_framework import status
@@ -62,6 +66,35 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
qs = qs.select_related("milestone", "project")
return qs
+ def pre_save(self, obj):
+ # This is very ugly hack, but having
+ # restframework is the only way to do it.
+ # NOTE: code moved as is from serializer
+ # to api because is not serializer logic.
+ related_data = getattr(obj, "_related_data", {})
+ self._role_points = related_data.pop("role_points", None)
+
+ if not obj.id:
+ obj.owner = self.request.user
+
+ super().pre_save(obj)
+
+ def post_save(self, obj, created=False):
+ # Code related to the hack of pre_save method. Rather,
+ # this is the continuation of it.
+
+ Points = apps.get_model("projects", "Points")
+ RolePoints = apps.get_model("userstories", "RolePoints")
+
+ if self._role_points:
+ with suppress(ObjectDoesNotExist):
+ for role_id, points_id in self._role_points.items():
+ role_points = RolePoints.objects.get(role__id=role_id, user_story_id=obj.pk)
+ role_points.points = Points.objects.get(id=points_id, project_id=obj.project_id)
+ role_points.save()
+
+ super().post_save(obj, created)
+
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.UserStoriesBulkSerializer(data=request.DATA)
@@ -145,8 +178,3 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
return response
- def pre_save(self, obj):
- if not obj.id:
- obj.owner = self.request.user
-
- super().pre_save(obj)
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index ef2607e2..cc7d1638 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -52,19 +52,6 @@ class UserStorySerializer(serializers.ModelSerializer):
depth = 0
read_only_fields = ('created_date', 'modified_date')
- def save_object(self, obj, **kwargs):
- role_points = obj._related_data.pop("role_points", None)
- super().save_object(obj, **kwargs)
-
- points_modelcls = apps.get_model("projects", "Points")
-
- if role_points:
- for role_id, points_id in role_points.items():
- role_points = obj.role_points.get(role__id=role_id)
- role_points.points = points_modelcls.objects.get(id=points_id,
- project=obj.project)
- role_points.save()
-
def get_total_points(self, obj):
return obj.get_total_points()
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index 63721eca..61d31535 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -1,3 +1,4 @@
+import copy
from unittest import mock
from django.core.urlresolvers import reverse
@@ -11,10 +12,7 @@ pytestmark = pytest.mark.django_db
def test_get_userstories_from_bulk():
- data = """
-User Story #1
-User Story #2
-"""
+ data = "User Story #1\nUser Story #2\n"
userstories = services.get_userstories_from_bulk(data)
assert len(userstories) == 2
@@ -23,10 +21,7 @@ User Story #2
def test_create_userstories_in_bulk():
- data = """
-User Story #1
-User Story #2
-"""
+ data = "User Story #1\nUser Story #2\n"
with mock.patch("taiga.projects.userstories.services.db") as db:
userstories = services.create_userstories_in_bulk(data)
@@ -41,7 +36,9 @@ def test_update_userstories_order_in_bulk():
with mock.patch("taiga.projects.userstories.services.db") as db:
services.update_userstories_order_in_bulk(data, "backlog_order", project)
- db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"backlog_order": 1}, {"backlog_order": 2}],
+ db.update_in_bulk_with_ids.assert_called_once_with([1, 2],
+ [{"backlog_order": 1},
+ {"backlog_order": 2}],
model=models.UserStory)
@@ -108,3 +105,53 @@ def test_api_update_backlog_order_in_bulk(client):
assert response1.status_code == 204, response.data
assert response2.status_code == 204, response.data
assert response3.status_code == 204, response.data
+
+
+from taiga.projects.userstories.serializers import UserStorySerializer
+
+
+def test_update_userstory_points(client):
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user1)
+
+ role1 = f.RoleFactory.create(project=project)
+ role2 = f.RoleFactory.create(project=project)
+
+ member = f.MembershipFactory.create(project=project, user=user1, role=role1)
+ member = f.MembershipFactory.create(project=project, user=user2, role=role2)
+
+ points1 = f.PointsFactory.create(project=project, value=None)
+ points2 = f.PointsFactory.create(project=project, value=1)
+ points3 = f.PointsFactory.create(project=project, value=2)
+
+ us = f.UserStoryFactory.create(project=project, owner=user1)
+ url = reverse("userstories-detail", args=[us.pk])
+ usdata = UserStorySerializer(us).data
+
+ client.login(user1)
+
+ # Api should ignore invalid values
+ data = {}
+ data["version"] = usdata["version"]
+ data["points"] = copy.copy(usdata["points"])
+ data["points"].update({'2000':points3.pk})
+
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 200, response.data
+
+ # Api should save successful
+ data = {}
+ data["version"] = usdata["version"]
+ data["points"] = copy.copy(usdata["points"])
+ data["points"].update({str(role1.pk):points3.pk})
+
+ response = client.json.patch(url, json.dumps(data))
+ assert response.status_code == 200, response.data
+
+ us = models.UserStory.objects.get(pk=us.pk)
+ rp = list(us.role_points.values_list("role_id", "points_id"))
+
+ assert rp == [(role1.pk, points3.pk), (role2.pk, points1.pk)]
+
+
From 3fddbe2054336364cd9efb0ce91d9dead5d3cf6a Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 20:17:36 +0200
Subject: [PATCH 20/31] Add temporal fix to history attachments changes
rendering.
---
taiga/projects/history/models.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py
index 809cae1c..ab58b312 100644
--- a/taiga/projects/history/models.py
+++ b/taiga/projects/history/models.py
@@ -171,8 +171,8 @@ class HistoryEntry(models.Model):
if changes:
change = {
- "filename": newattachs[aid]["filename"],
- "url": newattachs[aid]["url"],
+ "filename": newattachs.get(aid, {}).get("filename", ""),
+ "url": newattachs.get(aid, {}).get("url", ""),
"changes": changes
}
attachments["changed"].append(change)
From 5617de891e8a9295ddff43c595e876d995ac5a7d Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 20:25:43 +0200
Subject: [PATCH 21/31] Move some userstories test to right location.
---
tests/integration/test_project_us.py | 49 ---------------------------
tests/integration/test_userstories.py | 23 +++++++++++++
2 files changed, 23 insertions(+), 49 deletions(-)
delete mode 100644 tests/integration/test_project_us.py
diff --git a/tests/integration/test_project_us.py b/tests/integration/test_project_us.py
deleted file mode 100644
index 65abf3dc..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
-from django.core.urlresolvers import reverse
-
-from taiga.base.utils import json
-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)) == 2
-
- data = {"is_archived": 0}
- response = client.get(url, data)
- assert len(json.loads(response.content)) == 1
-
- data = {"is_archived": 1}
- response = client.get(url, data)
- assert len(json.loads(response.content)) == 1
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index 61d31535..fc1d31e5 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -155,3 +155,26 @@ def test_update_userstory_points(client):
assert rp == [(role1.pk, points3.pk), (role2.pk, points1.pk)]
+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)) == 2
+
+ data = {"is_archived": 0}
+ response = client.get(url, data)
+ assert len(json.loads(response.content)) == 1
+
+ data = {"is_archived": 1}
+ response = client.get(url, data)
+ assert len(json.loads(response.content)) == 1
+
From 84a7c47210ff400a3376b0ad58ec28a046b783c5 Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 20:26:54 +0200
Subject: [PATCH 22/31] Rename project tests.
---
tests/integration/test_projects.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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": ""}
From 4056d5214b648cf0941eb0f8e85d17d3894193eb Mon Sep 17 00:00:00 2001
From: Andrey Antukh
Date: Wed, 15 Oct 2014 21:25:58 +0200
Subject: [PATCH 23/31] Fix incorrect handling of userstory rolepoints update
process.
The current implementation allows duplicate points values in one project,
and because of it, unexpected MultipleObjectsReturned can be raised with
previous update_role_points code. The new one fixes this.
Additionally, in some circumstances, the project does not have any points
with None as value, that causes also unexpected errors. The new
implementation fixes it creating a points instance with None as value
if it not exists.
---
taiga/projects/models.py | 27 ++++++++++++++++-----------
tests/integration/test_userstories.py | 23 +++++++++++++++++++++++
2 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/taiga/projects/models.py b/taiga/projects/models.py
index c8306c46..092d5b7f 100644
--- a/taiga/projects/models.py
+++ b/taiga/projects/models.py
@@ -220,22 +220,27 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
if roles.count() == 0:
return
- # Get point instance that represent a null/undefined
- try:
- null_points_value = self.points.get(value=None)
- except Points.DoesNotExist:
- null_points_value = None
-
# Iter over all project user stories and create
# role point instance for new created roles.
if user_stories is None:
user_stories = self.user_stories.all()
- for story in user_stories:
- story_related_roles = Role.objects.filter(role_points__in=story.role_points.all())\
- .distinct()
- new_roles = roles.exclude(id__in=story_related_roles)
- new_rolepoints = [RolePoints(role=role, user_story=story, points=null_points_value)
+ # Get point instance that represent a null/undefined
+ # The current model allows dulplicate values. Because
+ # of it, we should get all poins with None as value
+ # and use the first one.
+ # In case of that not exists, creates one for avoid
+ # unxpected errors.
+ none_points = list(self.points.filter(value=None))
+ if none_points:
+ null_points_value = none_points[0]
+ else:
+ null_points_value = Points.objects.create(name="?", value=None, project=self)
+
+ for us in user_stories:
+ usroles = Role.objects.filter(role_points__in=us.role_points.all()).distinct()
+ new_roles = roles.exclude(id__in=usroles)
+ new_rolepoints = [RolePoints(role=role, user_story=us, points=null_points_value)
for role in new_roles]
RolePoints.objects.bulk_create(new_rolepoints)
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index fc1d31e5..5544a4a0 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -155,6 +155,29 @@ def test_update_userstory_points(client):
assert rp == [(role1.pk, points3.pk), (role2.pk, points1.pk)]
+def test_update_userstory_rolepoints_on_add_new_role(client):
+ # This test is explicitly without assertions. It simple should
+ # works without raising any exception.
+
+ user1 = f.UserFactory.create()
+ user2 = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user1)
+
+ role1 = f.RoleFactory.create(project=project)
+
+ member1 = f.MembershipFactory.create(project=project, user=user1, role=role1)
+
+ points1 = f.PointsFactory.create(project=project, value=2)
+
+ us = f.UserStoryFactory.create(project=project, owner=user1)
+ # url = reverse("userstories-detail", args=[us.pk])
+ # client.login(user1)
+
+ role2 = f.RoleFactory.create(project=project, computable=True)
+ member2 = f.MembershipFactory.create(project=project, user=user2, role=role2)
+ us.save()
+
+
def test_archived_filter(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
From e71a9a1202d416c8e47181113ac046ba71f6aed9 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Thu, 16 Oct 2014 08:48:12 +0200
Subject: [PATCH 24/31] Fixing searchs, using an and for the words we search
instead of an or
---
taiga/base/filters.py | 9 ++--
tests/integration/test_issues.py | 78 ++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+), 3 deletions(-)
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/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
From 35143a59e343f66a548ed7f04b237a7b842ca018 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Fri, 17 Oct 2014 10:01:20 +0200
Subject: [PATCH 25/31] Auto slugify the wiki page slug input in the importer
---
taiga/export_import/service.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py
index 24d44aea..147613f7 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
@@ -229,6 +231,7 @@ def store_history(project, obj, history):
def store_wiki_page(project, wiki_page):
+ wiki_page['slug'] = slugify(unidecode(wiki_page['slug']))
serialized = serializers.WikiPageExportSerializer(data=wiki_page)
if serialized.is_valid():
serialized.object.project = project
From d6e987f18ef6791fe9abb3b6edf293a395e42dde Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Fri, 17 Oct 2014 13:01:07 +0200
Subject: [PATCH 26/31] fixed problem on taiga wiki page import
---
taiga/export_import/service.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py
index 147613f7..4372fd57 100644
--- a/taiga/export_import/service.py
+++ b/taiga/export_import/service.py
@@ -231,7 +231,7 @@ def store_history(project, obj, history):
def store_wiki_page(project, wiki_page):
- wiki_page['slug'] = slugify(unidecode(wiki_page['slug']))
+ wiki_page['slug'] = slugify(unidecode(wiki_page.get('slug', '')))
serialized = serializers.WikiPageExportSerializer(data=wiki_page)
if serialized.is_valid():
serialized.object.project = project
From 6fc97cf99df7b6e75cd7f3c66c1002b0582901c0 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Thu, 16 Oct 2014 16:06:47 +0200
Subject: [PATCH 27/31] When creating a task with milestone and user_story
associated checking the user_story is also associated to the milestone
---
taiga/projects/tasks/api.py | 9 ++++++---
tests/integration/test_tasks.py | 21 +++++++++++++++++++++
2 files changed, 27 insertions(+), 3 deletions(-)
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/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
From 991e5a7f42824b36782e2a51d27d0a3c43660a54 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Fri, 17 Oct 2014 14:00:31 +0200
Subject: [PATCH 28/31] Fixing error 500 when importing two milestones with the
same name
---
taiga/export_import/serializers.py | 17 +++++++++++++++++
taiga/export_import/service.py | 2 +-
tests/integration/test_importer_api.py | 18 ++++++++++++++++++
3 files changed, 36 insertions(+), 1 deletion(-)
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 4372fd57..3f1b3145 100644
--- a/taiga/export_import/service.py
+++ b/taiga/export_import/service.py
@@ -185,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
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"
From 2cc65a17cba63f0591400f1fc2815ab235041808 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Mon, 20 Oct 2014 09:40:51 +0200
Subject: [PATCH 29/31] Fixing error when recovering the user token and the key
doesn't exist
---
taiga/auth/tokens.py | 2 +-
tests/unit/test_tokens.py | 52 +++++++++++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 1 deletion(-)
create mode 100644 tests/unit/test_tokens.py
diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py
index 6b5afd7b..680e70fc 100644
--- a/taiga/auth/tokens.py
+++ b/taiga/auth/tokens.py
@@ -48,7 +48,7 @@ def get_user_for_token(token, scope, max_age=None):
try:
user = model_cls.objects.get(pk=data["user_%s_id"%(scope)])
- except model_cls.DoesNotExist:
+ except (model_cls.DoesNotExist, KeyError):
raise exc.NotAuthenticated("Invalid token")
else:
return user
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")
From a5c922be9e434a45f1db5702dfeb67511716d4b1 Mon Sep 17 00:00:00 2001
From: Alejandro Alonso
Date: Mon, 20 Oct 2014 08:44:56 +0200
Subject: [PATCH 30/31] Fixing bug when selecting neighbors in an empty
queryset of related elements
---
taiga/base/neighbors.py | 2 +-
tests/integration/test_neighbors.py | 12 ++++++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
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/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)
From ca1b820b12c23f3102da063bdd5dc1dce6f8b0d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jes=C3=BAs=20Espino?=
Date: Mon, 20 Oct 2014 13:42:56 +0200
Subject: [PATCH 31/31] Update changelog
---
CHANGELOG.md | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e801a10..2c2c65fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,12 @@
## 1.2.0 (Unreleased)
-No changes at this moment.
+### 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)