diff --git a/taiga/front/__init__.py b/taiga/front/__init__.py
index ad7123e9..96110475 100644
--- a/taiga/front/__init__.py
+++ b/taiga/front/__init__.py
@@ -12,8 +12,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from django.conf import settings
-
from django_jinja import library
from django_sites import get_by_id as get_site_by_id
@@ -23,6 +21,7 @@ urls = {
"login": "/login",
"change-password": "/change-password/{0}",
+ "change-email": "/change-email/{0}",
"invitation": "/invitation/{0}",
"backlog": "/project/{0}/backlog/",
diff --git a/taiga/users/admin.py b/taiga/users/admin.py
index c364929c..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')}),
+ (_('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 7c76502c..ca67ec89 100644
--- a/taiga/users/api.py
+++ b/taiga/users/api.py
@@ -19,26 +19,22 @@ import uuid
from django.db.models.loading import get_model
from django.db.models import Q
from django.shortcuts import get_object_or_404
-from django.contrib.auth import logout, login, authenticate
-from django.contrib.auth.hashers import make_password
from django.utils.translation import ugettext_lazy as _
+from django.core.validators import validate_email
+from django.core.exceptions import ValidationError
-from easy_thumbnails.exceptions import InvalidImageFormatError
from easy_thumbnails.source_generators import pil_image
from rest_framework.response import Response
from rest_framework.filters import BaseFilterBackend
-from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework import status
from djmail.template_mail import MagicMailBuilder
from taiga.base.decorators import list_route, detail_route
-from taiga.base.decorators import action
from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet
-from taiga.base.api import ModelListViewSet
from taiga.base.utils.slug import slugify_uniquely
from taiga.projects.votes import services as votes_service
from taiga.projects.serializers import StarredSerializer
@@ -208,6 +204,67 @@ class UsersViewSet(ModelCrudViewSet):
stars_data = StarredSerializer(stars, many=True)
return Response(stars_data.data)
+ #TODO: commit_on_success
+ def partial_update(self, request, *args, **kwargs):
+ """
+ We must detect if the user is trying to change his email so we can
+ save that value and generate a token that allows him to validate it in
+ the new email account
+ """
+ user = self.get_object()
+ self.check_permissions(request, "update", user)
+
+ ret = super(UsersViewSet, self).partial_update(request, *args, **kwargs)
+
+ new_email = request.DATA.get('email', None)
+ if new_email is not None:
+ valid_new_email = True
+ duplicated_email = models.User.objects.filter(email = new_email).exists()
+
+ try:
+ validate_email(new_email)
+ except ValidationError:
+ valid_new_email = False
+
+ valid_new_email = valid_new_email and new_email != request.user.email
+
+ if duplicated_email:
+ raise exc.WrongArguments(_("Duplicated email"))
+ elif not valid_new_email:
+ raise exc.WrongArguments(_("Not valid email"))
+
+ #We need to generate a token for the email
+ request.user.email_token = str(uuid.uuid1())
+ request.user.new_email = new_email
+ request.user.save(update_fields=["email_token", "new_email"])
+ mbuilder = MagicMailBuilder()
+ email = mbuilder.change_email(request.user.email, {"user": request.user})
+ email.send()
+
+ return ret
+
+ @list_route(methods=["POST"])
+ def change_email(self, request, pk=None):
+ """
+ Verify the email change to current logged user.
+ """
+ serializer = serializers.ChangeEmailSerializer(data=request.DATA, many=False)
+ if not serializer.is_valid():
+ raise exc.WrongArguments(_("Token is invalid"))
+
+ try:
+ user = models.User.objects.get(email_token=serializer.data["email_token"])
+ except models.User.DoesNotExist:
+ raise exc.WrongArguments(_("Token is invalid"))
+
+ self.check_permissions(request, "change_email", user)
+ user.email = user.new_email
+ user.new_email = None
+ user.email_token = None
+ user.save(update_fields=["email", "new_email", "email_token"])
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
def destroy(self, request, pk=None):
user = self.get_object()
self.check_permissions(request, "destroy", user)
diff --git a/taiga/users/migrations/0008_auto__add_field_user_email_token__add_field_user_new_email.py b/taiga/users/migrations/0008_auto__add_field_user_email_token__add_field_user_new_email.py
new file mode 100644
index 00000000..47839bcd
--- /dev/null
+++ b/taiga/users/migrations/0008_auto__add_field_user_email_token__add_field_user_new_email.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding field 'User.email_token'
+ db.add_column('users_user', 'email_token',
+ self.gf('django.db.models.fields.CharField')(default=None, blank=True, null=True, max_length=200),
+ keep_default=False)
+
+ # Adding field 'User.new_email'
+ db.add_column('users_user', 'new_email',
+ self.gf('django.db.models.fields.EmailField')(blank=True, null=True, max_length=75),
+ keep_default=False)
+
+
+ def backwards(self, orm):
+ # Deleting field 'User.email_token'
+ db.delete_column('users_user', 'email_token')
+
+ # Deleting field 'User.new_email'
+ db.delete_column('users_user', 'new_email')
+
+
+ models = {
+ 'projects.issuestatus': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'IssueStatus', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'issue_statuses'"})
+ },
+ 'projects.issuetype': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'IssueType', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'issue_types'"})
+ },
+ 'projects.membership': {
+ 'Meta': {'ordering': "['project', 'user__full_name', 'user__username', 'user__email', 'email']", 'object_name': 'Membership', 'unique_together': "(('user', 'project'),)"},
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'default': 'datetime.datetime.now', 'auto_now_add': 'True'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'default': 'None', 'blank': 'True', 'null': 'True', 'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'invited_by_id': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
+ 'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'memberships'"}),
+ 'role': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.Role']", 'related_name': "'memberships'"}),
+ 'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'blank': 'True', 'null': 'True', 'max_length': '60'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.User']", 'default': 'None', 'blank': 'True', 'null': 'True', 'related_name': "'memberships'"})
+ },
+ 'projects.points': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'Points', 'unique_together': "(('project', 'name'),)"},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'points'"}),
+ 'value': ('django.db.models.fields.FloatField', [], {'default': 'None', 'blank': 'True', 'null': 'True'})
+ },
+ 'projects.priority': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'Priority', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'priorities'"})
+ },
+ 'projects.project': {
+ 'Meta': {'ordering': "['name']", 'object_name': 'Project'},
+ 'anon_permissions': ('djorm_pgarray.fields.TextArrayField', [], {'default': '[]', 'blank': 'True', 'null': 'True', 'dbtype': "'text'"}),
+ 'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}),
+ 'creation_template': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.ProjectTemplate']", 'default': 'None', 'blank': 'True', 'null': 'True', 'related_name': "'projects'"}),
+ 'default_issue_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.IssueStatus']", 'unique': 'True'}),
+ 'default_issue_type': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.IssueType']", 'unique': 'True'}),
+ 'default_points': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.Points']", 'unique': 'True'}),
+ 'default_priority': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.Priority']", 'unique': 'True'}),
+ 'default_severity': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.Severity']", 'unique': 'True'}),
+ 'default_task_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.TaskStatus']", 'unique': 'True'}),
+ 'default_us_status': ('django.db.models.fields.related.OneToOneField', [], {'blank': 'True', 'null': 'True', 'on_delete': 'models.SET_NULL', 'related_name': "'+'", 'to': "orm['projects.UserStoryStatus']", 'unique': 'True'}),
+ 'description': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_private': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'members': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['users.User']", 'symmetrical': 'False', 'through': "orm['projects.Membership']", 'related_name': "'projects'"}),
+ 'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}),
+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.User']", 'related_name': "'owned_projects'"}),
+ 'public': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'public_permissions': ('djorm_pgarray.fields.TextArrayField', [], {'default': '[]', 'blank': 'True', 'null': 'True', 'dbtype': "'text'"}),
+ 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '250', 'blank': 'True', 'unique': 'True'}),
+ 'tags': ('djorm_pgarray.fields.TextArrayField', [], {'default': 'None', 'blank': 'True', 'null': 'True', 'dbtype': "'text'"}),
+ 'total_milestones': ('django.db.models.fields.IntegerField', [], {'default': '0', 'blank': 'True', 'null': 'True'}),
+ 'total_story_points': ('django.db.models.fields.FloatField', [], {'default': '0'}),
+ 'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}),
+ 'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'})
+ },
+ 'projects.projecttemplate': {
+ 'Meta': {'ordering': "['name']", 'object_name': 'ProjectTemplate'},
+ 'created_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now_add': 'True'}),
+ 'default_options': ('django_pgjson.fields.JsonField', [], {}),
+ 'default_owner_role': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+ 'description': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_backlog_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_issues_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_kanban_activated': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_wiki_activated': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'issue_statuses': ('django_pgjson.fields.JsonField', [], {}),
+ 'issue_types': ('django_pgjson.fields.JsonField', [], {}),
+ 'modified_date': ('django.db.models.fields.DateTimeField', [], {'blank': 'True', 'auto_now': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '250'}),
+ 'points': ('django_pgjson.fields.JsonField', [], {}),
+ 'priorities': ('django_pgjson.fields.JsonField', [], {}),
+ 'roles': ('django_pgjson.fields.JsonField', [], {}),
+ 'severities': ('django_pgjson.fields.JsonField', [], {}),
+ 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '250', 'blank': 'True', 'unique': 'True'}),
+ 'task_statuses': ('django_pgjson.fields.JsonField', [], {}),
+ 'us_statuses': ('django_pgjson.fields.JsonField', [], {}),
+ 'videoconferences': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'}),
+ 'videoconferences_salt': ('django.db.models.fields.CharField', [], {'blank': 'True', 'null': 'True', 'max_length': '250'})
+ },
+ 'projects.severity': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'Severity', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'severities'"})
+ },
+ 'projects.taskstatus': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'TaskStatus', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'task_statuses'"})
+ },
+ 'projects.userstorystatus': {
+ 'Meta': {'ordering': "['project', 'order', 'name']", 'object_name': 'UserStoryStatus', 'unique_together': "(('project', 'name'),)"},
+ 'color': ('django.db.models.fields.CharField', [], {'max_length': '20', 'default': "'#999999'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'us_statuses'"}),
+ 'wip_limit': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'blank': 'True', 'null': 'True'})
+ },
+ 'users.role': {
+ 'Meta': {'ordering': "['order', 'slug']", 'object_name': 'Role', 'unique_together': "(('slug', 'project'),)"},
+ 'computable': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+ 'order': ('django.db.models.fields.IntegerField', [], {'default': '10'}),
+ 'permissions': ('djorm_pgarray.fields.TextArrayField', [], {'default': '[]', 'blank': 'True', 'null': 'True', 'dbtype': "'text'"}),
+ 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['projects.Project']", 'related_name': "'roles'"}),
+ 'slug': ('django.db.models.fields.SlugField', [], {'blank': 'True', 'max_length': '250'})
+ },
+ 'users.user': {
+ 'Meta': {'ordering': "['username']", 'object_name': 'User'},
+ 'bio': ('django.db.models.fields.TextField', [], {'blank': 'True', 'default': "''"}),
+ 'color': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "'#d8b489'", 'max_length': '9'}),
+ 'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
+ 'default_timezone': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}),
+ 'email_token': ('django.db.models.fields.CharField', [], {'default': 'None', 'blank': 'True', 'null': 'True', 'max_length': '200'}),
+ 'full_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '256'}),
+ 'github_id': ('django.db.models.fields.IntegerField', [], {'blank': 'True', 'null': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'new_email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'null': 'True', 'max_length': '75'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'photo': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '500'}),
+ 'token': ('django.db.models.fields.CharField', [], {'default': 'None', 'blank': 'True', 'null': 'True', 'max_length': '200'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ }
+ }
+
+ complete_apps = ['users']
\ No newline at end of file
diff --git a/taiga/users/models.py b/taiga/users/models.py
index a94425bf..6e8a2434 100644
--- a/taiga/users/models.py
+++ b/taiga/users/models.py
@@ -96,6 +96,12 @@ class User(AbstractBaseUser, PermissionsMixin):
verbose_name=_("colorize tags"))
token = models.CharField(max_length=200, null=True, blank=True, default=None,
verbose_name=_("token"))
+
+ email_token = models.CharField(max_length=200, null=True, blank=True, default=None,
+ verbose_name=_("email token"))
+
+ new_email = models.EmailField(_('new email address'), null=True, blank=True)
+
github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"))
USERNAME_FIELD = 'username'
diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py
index 7d74e8cd..a8ac5361 100644
--- a/taiga/users/permissions.py
+++ b/taiga/users/permissions.py
@@ -38,3 +38,4 @@ class UserPermission(ResourcePermission):
change_avatar_perms = IsAuthenticated()
remove_avatar_perms = IsAuthenticated()
starred_perms = AllowAny()
+ change_email_perms = IsTheSameUser()
diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py
index 457f640b..05cb5e95 100644
--- a/taiga/users/serializers.py
+++ b/taiga/users/serializers.py
@@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from django.utils.translation import ugettext_lazy as _
-
from rest_framework import serializers
from .models import User
@@ -47,3 +45,6 @@ class UserSerializer(serializers.ModelSerializer):
class RecoverySerializer(serializers.Serializer):
token = serializers.CharField(max_length=200)
password = serializers.CharField(min_length=6)
+
+class ChangeEmailSerializer(serializers.Serializer):
+ email_token = serializers.CharField(max_length=200)
diff --git a/taiga/users/templates/emails/change_email-body-html.jinja b/taiga/users/templates/emails/change_email-body-html.jinja
new file mode 100644
index 00000000..0e930164
--- /dev/null
+++ b/taiga/users/templates/emails/change_email-body-html.jinja
@@ -0,0 +1,27 @@
+{% extends "emails/base.jinja" %}
+
+{% set final_url = resolve_front_url("change-email", user.email_token) %}
+{% set final_url_name = "Taiga - Change email" %}
+
+{% block body %}
+
+
+
+ Change your email:
+ Hello {{ user.get_full_name() }},
+ you can recover your password by going to the following url:
+ {{ final_url }}
+ You can ignore this message if you did not request.
+ Regards
+ -- The Taiga Team
+ |
+
+
+{% endblock %}
+
+{% block footer %}
+
+ More info at:
+ {{ final_url_name }}
+
+{% endblock %}
diff --git a/taiga/users/templates/emails/change_email-body-text.jinja b/taiga/users/templates/emails/change_email-body-text.jinja
new file mode 100644
index 00000000..bd133d43
--- /dev/null
+++ b/taiga/users/templates/emails/change_email-body-text.jinja
@@ -0,0 +1,12 @@
+Hello {{ user.get_full_name() }},
+
+you can confirm your change of email by going to the following url:
+
+{{ resolve_front_url('change-email', user.email_token) }}
+
+You can ignore this message if you did not request.
+
+Regards
+
+--
+The Taiga Team
diff --git a/taiga/users/templates/emails/change_email-subject.jinja b/taiga/users/templates/emails/change_email-subject.jinja
new file mode 100644
index 00000000..e00ea7a5
--- /dev/null
+++ b/taiga/users/templates/emails/change_email-subject.jinja
@@ -0,0 +1 @@
+[Taiga] Change email
diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py
index 03c551fa..4e04f2e4 100644
--- a/tests/integration/resources_permissions/test_users_resources.py
+++ b/tests/integration/resources_permissions/test_users_resources.py
@@ -4,8 +4,6 @@ from django.core.urlresolvers import reverse
from rest_framework.renderers import JSONRenderer
from taiga.users.serializers import UserSerializer
-from taiga.users.models import User
-from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
@@ -232,7 +230,7 @@ def test_user_action_change_password_from_recovery(client, data):
def test_user_action_password_recovery(client, data):
url = reverse('users-password-recovery')
- new_user = f.UserFactory.create(username="test")
+ f.UserFactory.create(username="test")
users = [
None,
@@ -244,3 +242,20 @@ def test_user_action_password_recovery(client, data):
patch_data = json.dumps({"username": "test"})
results = helper_test_http_method(client, 'post', url, patch_data, users)
assert results == [200, 200, 200, 200]
+
+def test_user_action_change_email(client, data):
+ url = reverse('users-change-email')
+
+ data.registered_user.email_token = "test-token"
+ data.registered_user.new_email = "new@email.com"
+ data.registered_user.save()
+
+ users = [
+ None,
+ data.registered_user,
+ data.other_user,
+ ]
+
+ patch_data = json.dumps({"email_token": "test-token"})
+ results = helper_test_http_method(client, 'post', url, patch_data, users)
+ assert results == [401, 204, 400]
diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py
new file mode 100644
index 00000000..1f817dae
--- /dev/null
+++ b/tests/integration/test_users.py
@@ -0,0 +1,100 @@
+import pytest
+import json
+
+from django.core.urlresolvers import reverse
+
+from .. import factories as f
+
+from taiga.users import models
+
+pytestmark = pytest.mark.django_db
+
+
+def test_api_user_patch_same_email(client):
+ user = f.UserFactory.create(email="same@email.com")
+ url = reverse('users-detail', kwargs={"pk": user.pk})
+ data = {"email": "same@email.com"}
+
+ client.login(user)
+ response = client.patch(url, json.dumps(data), content_type="application/json")
+
+ assert response.status_code == 400
+ assert response.data['_error_message'] == 'Duplicated email'
+
+
+def test_api_user_patch_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})
+ data = {"email": "one@email.com"}
+
+ client.login(user)
+ response = client.patch(url, json.dumps(data), content_type="application/json")
+
+ assert response.status_code == 400
+ assert response.data['_error_message'] == 'Duplicated email'
+
+
+def test_api_user_patch_invalid_email(client):
+ user = f.UserFactory.create(email="my@email.com")
+ url = reverse('users-detail', kwargs={"pk": user.pk})
+ data = {"email": "my@email"}
+
+ client.login(user)
+ response = client.patch(url, json.dumps(data), content_type="application/json")
+
+ assert response.status_code == 400
+ assert response.data['_error_message'] == 'Not valid email'
+
+
+def test_api_user_patch_valid_email(client):
+ user = f.UserFactory.create(email="old@email.com")
+ url = reverse('users-detail', kwargs={"pk": user.pk})
+ data = {"email": "new@email.com"}
+
+ client.login(user)
+ response = client.patch(url, json.dumps(data), content_type="application/json")
+
+ assert response.status_code == 200
+ user = models.User.objects.get(pk=user.id)
+ assert user.email_token != None
+ assert user.new_email == "new@email.com"
+
+
+def test_api_user_action_change_email_ok(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"}
+
+ 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.email_token == None
+ assert user.new_email == None
+ assert user.email == "new@email.com"
+
+
+def test_api_user_action_change_email_no_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'] == 'Token is invalid'
+
+
+def test_api_user_action_change_email_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"}
+
+ client.login(user)
+ response = client.post(url, json.dumps(data), content_type="application/json")
+
+ assert response.status_code == 400
+ assert response.data['_error_message'] == 'Token is invalid'