Merge branch 'master' into stable

Conflicts:
	tests/integration/test_userstories.py
remotes/origin/enhancement/email-actions 1.1.1
Jesús Espino 2014-10-20 13:43:44 +02:00
commit bd519b3d64
34 changed files with 520 additions and 206 deletions

View File

@ -1,5 +1,14 @@
# Changelog #
## 1.2.0 (Unreleased)
### Features
- Send an email to the user on signup.
- Emit django signal on user signout.
### Misc
- Lots of small and not so small bugfixes
## 1.1.0 (2014-10-13)
### Misc

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -35,31 +35,18 @@ from taiga.base import exceptions as exc
from taiga.users.serializers import UserSerializer
from taiga.users.services import get_and_validate_user
from .backends import get_token_for_user
from .tokens import get_token_for_user
from .signals import user_registered as user_registered_signal
def send_public_register_email(user) -> bool:
def send_register_email(user) -> bool:
"""
Given a user, send public register welcome email
Given a user, send register welcome email
message to specified user.
"""
context = {"user": user}
cancel_token = get_token_for_user(user, "cancel_account")
context = {"user": user, "cancel_token": cancel_token}
mbuilder = MagicMailBuilder()
email = mbuilder.public_register_user(user.email, context)
return bool(email.send())
def send_private_register_email(user, **kwargs) -> bool:
"""
Given a user, send private register welcome
email message to specified user.
"""
context = {"user": user}
context.update(kwargs)
mbuilder = MagicMailBuilder()
email = mbuilder.private_register_user(user.email, context)
email = mbuilder.registered_user(user.email, context)
return bool(email.send())
@ -125,7 +112,7 @@ def public_register(username:str, password:str, email:str, full_name:str):
except IntegrityError:
raise exc.WrongArguments("User is already register.")
# send_public_register_email(user)
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -149,7 +136,7 @@ def private_register_for_existing_user(token:str, username:str, password:str):
except IntegrityError:
raise exc.IntegrityError("Membership with user is already exists.")
# send_private_register_email(user)
send_register_email(user)
return user
@ -178,6 +165,7 @@ def private_register_for_new_user(token:str, username:str, email:str,
membership = get_membership_by_token(token)
membership.user = user
membership.save(update_fields=["user"])
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -205,6 +193,7 @@ def github_register(username:str, email:str, full_name:str, github_id:int, bio:s
membership.save(update_fields=["user"])
if created:
send_register_email(user)
user_registered_signal.send(sender=user.__class__, user=user)
return user
@ -218,5 +207,5 @@ def make_auth_response_data(user) -> dict:
"""
serializer = UserSerializer(user)
data = dict(serializer.data)
data["auth_token"] = get_token_for_user(user)
data["auth_token"] = get_token_for_user(user, "authentication")
return data

54
taiga/auth/tokens.py Normal file
View File

@ -0,0 +1,54 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
from taiga.base import exceptions as exc
from django.apps import apps
from django.core import signing
def get_token_for_user(user, scope):
"""
Generate a new signed token containing
a specified user limited for a scope (identified as a string).
"""
data = {"user_%s_id"%(scope): user.id}
return signing.dumps(data)
def get_user_for_token(token, scope, max_age=None):
"""
Given a selfcontained token and a scope try to parse and
unsign it.
If max_age is specified it checks token expiration.
If token passes a validation, returns
a user instance corresponding with user_id stored
in the incoming token.
"""
try:
data = signing.loads(token, max_age=max_age)
except signing.BadSignature:
raise exc.NotAuthenticated("Invalid token")
model_cls = apps.get_model("users", "User")
try:
user = model_cls.objects.get(pk=data["user_%s_id"%(scope)])
except (model_cls.DoesNotExist, KeyError):
raise exc.NotAuthenticated("Invalid token")
else:
return user

View File

@ -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

View File

@ -87,7 +87,7 @@ def get_neighbors(obj, results_set=None):
:return: Tuple `<left neighbor>, <right neighbor>`. 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]

View File

@ -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')

View File

@ -16,7 +16,9 @@
import uuid
import os.path as path
from unidecode import unidecode
from django.template.defaultfilters import slugify
from django.contrib.contenttypes.models import ContentType
from taiga.projects.history.services import make_key_from_model_object
@ -183,7 +185,7 @@ def store_task(project, task):
def store_milestone(project, milestone):
serialized = serializers.MilestoneExportSerializer(data=milestone)
serialized = serializers.MilestoneExportSerializer(data=milestone, project=project)
if serialized.is_valid():
serialized.object.project = project
serialized.object._importing = True
@ -229,6 +231,7 @@ def store_history(project, obj, history):
def store_wiki_page(project, wiki_page):
wiki_page['slug'] = slugify(unidecode(wiki_page.get('slug', '')))
serialized = serializers.WikiPageExportSerializer(data=wiki_page)
if serialized.is_valid():
serialized.object.project = project

View File

@ -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}",

View File

@ -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):

View File

@ -22,6 +22,7 @@ from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext_lazy as _
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
from easy_thumbnails.source_generators import pil_image
@ -32,6 +33,7 @@ from rest_framework import status
from djmail.template_mail import MagicMailBuilder
from taiga.auth.tokens import get_user_for_token
from taiga.base.decorators import list_route, detail_route
from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet
@ -42,6 +44,7 @@ from taiga.projects.serializers import StarredSerializer
from . import models
from . import serializers
from . import permissions
from .signals import user_cancel_account as user_cancel_account_signal
class MembersFilterBackend(BaseFilterBackend):
@ -258,20 +261,34 @@ class UsersViewSet(ModelCrudViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@list_route(methods=["POST"])
def cancel(self, request, pk=None):
"""
Cancel an account via token
"""
serializer = serializers.CancelAccountSerializer(data=request.DATA, many=False)
if not serializer.is_valid():
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
try:
max_age_cancel_account = getattr(settings, "MAX_AGE_CANCEL_ACCOUNT", None)
user = get_user_for_token(serializer.data["cancel_token"], "cancel_account",
max_age=max_age_cancel_account)
except exc.NotAuthenticated:
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
if not user.is_active:
raise exc.WrongArguments(_("Invalid, are you sure the token is correct?"))
user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, pk=None):
user = self.get_object()
self.check_permissions(request, "destroy", user)
user.username = slugify_uniquely("deleted-user", models.User, slugfield="username")
user.email = "{}@taiga.io".format(user.username)
user.is_active = False
user.full_name = "Deleted user"
user.color = ""
user.bio = ""
user.default_language = ""
user.default_timezone = ""
user.colorize_tags = True
user.token = None
user.github_id = None
user.set_unusable_password()
user.save()
stream = request.stream
request_data = stream is not None and stream.GET or None
user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data)
user.cancel()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -19,6 +19,7 @@ import os
import os.path as path
import random
import re
import uuid
from unidecode import unidecode
@ -33,6 +34,7 @@ from django.template.defaultfilters import slugify
from djorm_pgarray.fields import TextArrayField
from taiga.auth.tokens import get_token_for_user
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.iterators import split_by_n
from taiga.permissions.permissions import MEMBERS_PERMISSIONS
@ -152,6 +154,24 @@ class User(AbstractBaseUser, PermissionsMixin):
def get_full_name(self):
return self.full_name or self.username or self.email
def save(self, *args, **kwargs):
get_token_for_user(self, "cancel_account")
super().save(*args, **kwargs)
def cancel(self):
self.username = slugify_uniquely("deleted-user", User, slugfield="username")
self.email = "{}@taiga.io".format(self.username)
self.is_active = False
self.full_name = "Deleted user"
self.color = ""
self.bio = ""
self.default_language = ""
self.default_timezone = ""
self.colorize_tags = True
self.token = None
self.github_id = None
self.set_unusable_password()
self.save()
class Role(models.Model):
name = models.CharField(max_length=200, null=False, blank=False,

View File

@ -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)

20
taiga/users/signals.py Normal file
View File

@ -0,0 +1,20 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
import django.dispatch
user_cancel_account = django.dispatch.Signal(providing_args=["user", "request_data"])

View File

@ -0,0 +1,24 @@
{% extends "emails/base.jinja" %}
{% block body %}
<table border="0" width="100%" cellpadding="0" cellspacing="0" class="table-body">
<tr>
<td>
<p>Welcome to Taiga, an Open Source, Agile Project Management Tool</p>
<p>You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:</p>
{{ resolve_front_url('cancel-account', cancel_token) }}
<p>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.</p>
<p>We hope you enjoy it.</p>
</td>
</tr>
</table>
{% endblock %}
{% block footer %}
<p style="padding: 10px; border-top: 1px solid #eee;">
The Taiga development team.
</p>
{% endblock %}

View File

@ -0,0 +1,12 @@
Welcome to Taiga, an Open Source, Agile Project Management Tool
You, or someone you know has invited you to Taiga. You may remove your account from this service by clicking here:
{{ resolve_front_url('cancel-account', cancel_token) }}
We built Taiga because we wanted the project management tool that sits open on our computers all day long, to serve as a continued reminder of why we love to collaborate, code and design. We built it to be beautiful, elegant, simple to use and fun - without forsaking flexibility and power. We named it Taiga after the forest biome, because like its namesake, our tool is at the center of an ecosystem - your team, your projects. A great project management tool also helps you see the forest for the trees. We couldn't think of a more appropriate name for what we've done.
We hope you enjoy it.
--
The Taiga development team.

View File

@ -0,0 +1 @@
You've been Taigatized!

View File

@ -20,10 +20,14 @@ from unittest.mock import patch, Mock
from django.apps import apps
from django.core.urlresolvers import reverse
from django.core import mail
from .. import factories
from taiga.base.connectors import github
from taiga.front import resolve as resolve_front_url
from taiga.users import models
from taiga.auth.tokens import get_token_for_user
pytestmark = pytest.mark.django_db
@ -68,6 +72,28 @@ def test_respond_201_with_invitation_without_public_registration(client, registe
assert response.status_code == 201, response.data
def test_response_200_in_public_registration(client, settings):
settings.PUBLIC_REGISTER_ENABLED = True
form = {
"type": "public",
"username": "mmcfly",
"full_name": "martin seamus mcfly",
"email": "mmcfly@bttf.com",
"password": "password",
}
response = client.post(reverse("auth-register"), form)
assert response.status_code == 201
assert response.data["username"] == "mmcfly"
assert response.data["email"] == "mmcfly@bttf.com"
assert response.data["full_name"] == "martin seamus mcfly"
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == "You've been Taigatized!"
user = models.User.objects.get(username="mmcfly")
cancel_token = get_token_for_user(user, "cancel_account")
cancel_url = resolve_front_url("cancel-account", cancel_token)
assert mail.outbox[0].body.index(cancel_url) > 0
def test_response_200_in_registration_with_github_account(client, settings):
settings.PUBLIC_REGISTER_ENABLED = False
form = {"type": "github",

View File

@ -1,54 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014 Anler Hernández <hello@anler.me>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -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"

View File

@ -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

View File

@ -29,8 +29,7 @@ from .. import factories as f
pytestmark = pytest.mark.django_db
def test_api_update_milestone(client):
def test_update_milestone_with_userstories_list(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
role = f.RoleFactory.create(project=project)
@ -39,7 +38,6 @@ def test_api_update_milestone(client):
points = f.PointsFactory.create(project=project, value=None)
us = f.UserStoryFactory.create(project=project, owner=user)
# role_points = f.RolePointsFactory.create(points=points, user_story=us, role=role)
url = reverse("milestones-detail", args=[sprint.pk])

View File

@ -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)

View File

@ -15,11 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -1,49 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014 Anler Hernández <hello@anler.me>
# 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 <http://www.gnu.org/licenses/>.
import pytest
import json
from django.core.urlresolvers import reverse
from .. import factories as f
pytestmark = pytest.mark.django_db
def test_archived_filter(client):
user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user)
f.MembershipFactory.create(project=project, user=user)
f.UserStoryFactory.create(project=project)
f.UserStoryFactory.create(is_archived=True, project=project)
client.login(user)
url = reverse("userstories-list")
data = {}
response = client.get(url, data)
assert len(json.loads(response.content.decode('utf-8'))) == 2
data = {"is_archived": 0}
response = client.get(url, data)
assert len(json.loads(response.content.decode('utf-8'))) == 1
data = {"is_archived": 1}
response = client.get(url, data)
assert len(json.loads(response.content.decode('utf-8'))) == 1

View File

@ -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": ""}

View File

@ -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

View File

@ -1,16 +1,18 @@
import pytest
import json
from tempfile import NamedTemporaryFile
from django.core.urlresolvers import reverse
from .. import factories as f
from taiga.users import models
from taiga.auth.tokens import get_token_for_user
pytestmark = pytest.mark.django_db
def test_api_user_normal_user(client):
def test_users_create_through_standard_api(client):
user = f.UserFactory.create(is_superuser=True)
url = reverse('users-list')
@ -25,7 +27,7 @@ def test_api_user_normal_user(client):
assert response.status_code == 405
def test_api_user_patch_same_email(client):
def test_update_user_with_same_email(client):
user = f.UserFactory.create(email="same@email.com")
url = reverse('users-detail', kwargs={"pk": user.pk})
data = {"email": "same@email.com"}
@ -37,7 +39,7 @@ def test_api_user_patch_same_email(client):
assert response.data['_error_message'] == 'Duplicated email'
def test_api_user_patch_duplicated_email(client):
def test_update_user_with_duplicated_email(client):
f.UserFactory.create(email="one@email.com")
user = f.UserFactory.create(email="two@email.com")
url = reverse('users-detail', kwargs={"pk": user.pk})
@ -50,7 +52,7 @@ def test_api_user_patch_duplicated_email(client):
assert response.data['_error_message'] == 'Duplicated email'
def test_api_user_patch_invalid_email(client):
def test_update_user_with_invalid_email(client):
user = f.UserFactory.create(email="my@email.com")
url = reverse('users-detail', kwargs={"pk": user.pk})
data = {"email": "my@email"}
@ -62,7 +64,7 @@ def test_api_user_patch_invalid_email(client):
assert response.data['_error_message'] == 'Not valid email'
def test_api_user_patch_valid_email(client):
def test_update_user_with_valid_email(client):
user = f.UserFactory.create(email="old@email.com")
url = reverse('users-detail', kwargs={"pk": user.pk})
data = {"email": "new@email.com"}
@ -76,7 +78,7 @@ def test_api_user_patch_valid_email(client):
assert user.new_email == "new@email.com"
def test_api_user_action_change_email_ok(client):
def test_validate_requested_email_change(client):
user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com")
url = reverse('users-change-email')
data = {"email_token": "change_email_token"}
@ -91,19 +93,17 @@ def test_api_user_action_change_email_ok(client):
assert user.email == "new@email.com"
def test_api_user_action_change_email_no_token(client):
def test_validate_requested_email_change_without_token(client):
user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com")
url = reverse('users-change-email')
data = {}
client.login(user)
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
assert response.data['_error_message'] == 'Invalid, are you sure the token is correct and you didn\'t use it before?'
def test_api_user_action_change_email_invalid_token(client):
def test_validate_requested_email_change_with_invalid_token(client):
user = f.UserFactory.create(email_token="change_email_token", new_email="new@email.com")
url = reverse('users-change-email')
data = {"email_token": "invalid_email_token"}
@ -112,4 +112,67 @@ def test_api_user_action_change_email_invalid_token(client):
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
assert response.data['_error_message'] == 'Invalid, are you sure the token is correct and you didn\'t use it before?'
def test_delete_self_user(client):
user = f.UserFactory.create()
url = reverse('users-detail', kwargs={"pk": user.pk})
client.login(user)
response = client.delete(url)
assert response.status_code == 204
user = models.User.objects.get(pk=user.id)
assert user.full_name == "Deleted user"
def test_cancel_self_user_with_valid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
cancel_token = get_token_for_user(user, "cancel_account")
data = {"cancel_token": cancel_token}
client.login(user)
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 204
user = models.User.objects.get(pk=user.id)
assert user.full_name == "Deleted user"
def test_cancel_self_user_with_invalid_token(client):
user = f.UserFactory.create()
url = reverse('users-cancel')
data = {"cancel_token": "invalid_cancel_token"}
client.login(user)
response = client.post(url, json.dumps(data), content_type="application/json")
assert response.status_code == 400
DUMMY_BMP_DATA = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x13\x0b\x00\x00\x13\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
def test_change_avatar(client):
url = reverse('users-change-avatar')
user = f.UserFactory()
client.login(user)
with NamedTemporaryFile() as avatar:
# Test no avatar send
post_data = {}
response = client.post(url, post_data)
assert response.status_code == 400
# Test invalid file send
post_data = {
'avatar': avatar
}
response = client.post(url, post_data)
assert response.status_code == 400
# Test empty valid avatar send
avatar.write(DUMMY_BMP_DATA)
avatar.seek(0)
response = client.post(url, post_data)
assert response.status_code == 200

52
tests/unit/test_tokens.py Normal file
View File

@ -0,0 +1,52 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014 Anler Hernández <hello@anler.me>
# 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 <http://www.gnu.org/licenses/>.
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")