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'