From 7d2b6c34ce71dea1cc966bf1a90abe4f719e8693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 19:29:49 +0200 Subject: [PATCH] Migrating users serializers and validators --- taiga/base/utils/dicts.py | 4 + taiga/users/api.py | 73 ++++++----- taiga/users/serializers.py | 208 +++++++++++++------------------- taiga/users/validators.py | 82 ++++++++++++- tests/integration/test_users.py | 9 +- 5 files changed, 214 insertions(+), 162 deletions(-) diff --git a/taiga/base/utils/dicts.py b/taiga/base/utils/dicts.py index 23b90f17..bf3d2c71 100644 --- a/taiga/base/utils/dicts.py +++ b/taiga/base/utils/dicts.py @@ -25,3 +25,7 @@ def dict_sum(*args): assert isinstance(arg, dict) result += collections.Counter(arg) return result + + +def into_namedtuple(dictionary): + return collections.namedtuple('GenericDict', dictionary.keys())(**dictionary) diff --git a/taiga/users/api.py b/taiga/users/api.py index a02e1576..00d5d279 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -19,7 +19,6 @@ import uuid from django.apps import apps -from django.db.models import Q, F from django.utils.translation import ugettext as _ from django.core.validators import validate_email from django.core.exceptions import ValidationError @@ -28,21 +27,21 @@ from django.conf import settings from taiga.base import exceptions as exc from taiga.base import filters from taiga.base import response +from taiga.base.utils.dicts import into_namedtuple from taiga.auth.tokens import get_user_for_token from taiga.base.decorators import list_route from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet from taiga.base.api.mixins import BlockedByProjectMixin -from taiga.base.filters import PermissionBasedFilterBackend from taiga.base.api.utils import get_object_or_404 from taiga.base.filters import MembersFilterBackend from taiga.base.mails import mail_builder -from taiga.projects.votes import services as votes_service from taiga.users.services import get_user_by_username_or_email from easy_thumbnails.source_generators import pil_image from . import models from . import serializers +from . import validators from . import permissions from . import filters as user_filters from . import services @@ -53,6 +52,8 @@ class UsersViewSet(ModelCrudViewSet): permission_classes = (permissions.UserPermission,) admin_serializer_class = serializers.UserAdminSerializer serializer_class = serializers.UserSerializer + admin_validator_class = validators.UserAdminValidator + validator_class = validators.UserValidator queryset = models.User.objects.all().prefetch_related("memberships") filter_backends = (MembersFilterBackend,) @@ -64,6 +65,14 @@ class UsersViewSet(ModelCrudViewSet): return self.serializer_class + def get_validator_class(self): + if self.action in ["partial_update", "update", "retrieve", "by_username"]: + user = self.object + if self.request.user == user or self.request.user.is_superuser: + return self.admin_validator_class + + return self.validator_class + def create(self, *args, **kwargs): raise exc.NotSupported() @@ -86,7 +95,7 @@ class UsersViewSet(ModelCrudViewSet): serializer = self.get_serializer(self.object) return response.Ok(serializer.data) - #TODO: commit_on_success + # 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 @@ -96,12 +105,10 @@ class UsersViewSet(ModelCrudViewSet): user = self.get_object() self.check_permissions(request, "update", user) - ret = super().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() + duplicated_email = models.User.objects.filter(email=new_email).exists() try: validate_email(new_email) @@ -115,14 +122,21 @@ class UsersViewSet(ModelCrudViewSet): elif not valid_new_email: raise exc.WrongArguments(_("Not valid email")) - #We need to generate a token for the 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"]) - email = mail_builder.change_email(request.user.new_email, {"user": request.user, - "lang": request.user.lang}) + email = mail_builder.change_email( + request.user.new_email, + { + "user": request.user, + "lang": request.user.lang + } + ) email.send() + ret = super().partial_update(request, *args, **kwargs) + return ret def destroy(self, request, pk=None): @@ -165,16 +179,16 @@ class UsersViewSet(ModelCrudViewSet): self.check_permissions(request, "change_password_from_recovery", None) - serializer = serializers.RecoverySerializer(data=request.DATA, many=False) - if not serializer.is_valid(): + validator = validators.RecoveryValidator(data=request.DATA, many=False) + if not validator.is_valid(): raise exc.WrongArguments(_("Token is invalid")) try: - user = models.User.objects.get(token=serializer.data["token"]) + user = models.User.objects.get(token=validator.data["token"]) except models.User.DoesNotExist: raise exc.WrongArguments(_("Token is invalid")) - user.set_password(serializer.data["password"]) + user.set_password(validator.data["password"]) user.token = None user.save(update_fields=["password", "token"]) @@ -247,13 +261,13 @@ class UsersViewSet(ModelCrudViewSet): """ Verify the email change to current logged user. """ - serializer = serializers.ChangeEmailSerializer(data=request.DATA, many=False) - if not serializer.is_valid(): + validator = validators.ChangeEmailValidator(data=request.DATA, many=False) + if not validator.is_valid(): raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you " "didn't use it before?")) try: - user = models.User.objects.get(email_token=serializer.data["email_token"]) + user = models.User.objects.get(email_token=validator.data["email_token"]) except models.User.DoesNotExist: raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you " "didn't use it before?")) @@ -280,14 +294,14 @@ class UsersViewSet(ModelCrudViewSet): """ Cancel an account via token """ - serializer = serializers.CancelAccountSerializer(data=request.DATA, many=False) - if not serializer.is_valid(): + validator = validators.CancelAccountValidator(data=request.DATA, many=False) + if not validator.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) + user = get_user_for_token(validator.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?")) @@ -305,7 +319,7 @@ class UsersViewSet(ModelCrudViewSet): self.object_list = user_filters.ContactsFilterBackend().filter_queryset( user, request, self.get_queryset(), self).extra( - select={"complete_user_name":"concat(full_name, username)"}).order_by("complete_user_name") + select={"complete_user_name": "concat(full_name, username)"}).order_by("complete_user_name") page = self.paginate_queryset(self.object_list) if page is not None: @@ -349,10 +363,10 @@ class UsersViewSet(ModelCrudViewSet): for elem in elements: if elem["type"] == "project": # projects are liked objects - response_data.append(serializers.LikedObjectSerializer(elem, **extra_args_liked).data ) + response_data.append(serializers.LikedObjectSerializer(into_namedtuple(elem), **extra_args_liked).data) else: # stories, tasks and issues are voted objects - response_data.append(serializers.VotedObjectSerializer(elem, **extra_args_voted).data ) + response_data.append(serializers.VotedObjectSerializer(into_namedtuple(elem), **extra_args_voted).data) return response.Ok(response_data) @@ -374,7 +388,7 @@ class UsersViewSet(ModelCrudViewSet): "user_likes": services.get_liked_content_for_user(request.user), } - response_data = [serializers.LikedObjectSerializer(elem, **extra_args).data for elem in elements] + response_data = [serializers.LikedObjectSerializer(into_namedtuple(elem), **extra_args).data for elem in elements] return response.Ok(response_data) @@ -397,17 +411,18 @@ class UsersViewSet(ModelCrudViewSet): "user_votes": services.get_voted_content_for_user(request.user), } - response_data = [serializers.VotedObjectSerializer(elem, **extra_args).data for elem in elements] + response_data = [serializers.VotedObjectSerializer(into_namedtuple(elem), **extra_args).data for elem in elements] return response.Ok(response_data) -###################################################### -## Role -###################################################### +###################################################### +# Role +###################################################### class RolesViewSet(BlockedByProjectMixin, ModelCrudViewSet): model = models.Role serializer_class = serializers.RoleSerializer + validator_class = validators.RoleValidator permission_classes = (permissions.RolesPermission, ) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ('project',) diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 3d584c53..75daa74e 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -22,7 +22,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ from taiga.base.api import serializers -from taiga.base.fields import PgArrayField, Field, MethodField +from taiga.base.fields import PgArrayField, Field, MethodField, I18NField from taiga.base.utils.thumbnails import get_thumbnail_url @@ -40,47 +40,28 @@ import re # User ###################################################### -class ContactProjectDetailSerializer(serializers.ModelSerializer): - class Meta: - model = Project - fields = ("id", "slug", "name") +class ContactProjectDetailSerializer(serializers.LightSerializer): + id = Field() + slug = Field() + name = Field() -class UserSerializer(serializers.ModelSerializer): - full_name_display = serializers.SerializerMethodField("get_full_name_display") - photo = serializers.SerializerMethodField("get_photo") - big_photo = serializers.SerializerMethodField("get_big_photo") - gravatar_url = serializers.SerializerMethodField("get_gravatar_url") - roles = serializers.SerializerMethodField("get_roles") - projects_with_me = serializers.SerializerMethodField("get_projects_with_me") - - class Meta: - model = User - # IMPORTANT: Maintain the UserAdminSerializer Meta up to date - # with this info (including there the email) - fields = ("id", "username", "full_name", "full_name_display", - "color", "bio", "lang", "theme", "timezone", "is_active", - "photo", "big_photo", "roles", "projects_with_me", - "gravatar_url") - read_only_fields = ("id",) - - def validate_username(self, attrs, source): - value = attrs[source] - validator = validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), - _("invalid")) - - try: - validator(value) - except ValidationError: - raise serializers.ValidationError(_("Required. 255 characters or fewer. Letters, " - "numbers and /./-/_ characters'")) - - if (self.object and - self.object.username != value and - User.objects.filter(username=value).exists()): - raise serializers.ValidationError(_("Invalid username. Try with a different one.")) - - return attrs +class UserSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = Field() + full_name_display = MethodField() + color = Field() + bio = Field() + lang = Field() + theme = Field() + timezone = Field() + is_active = Field() + photo = MethodField() + big_photo = MethodField() + gravatar_url = MethodField() + roles = MethodField() + projects_with_me = MethodField() def get_full_name_display(self, obj): return obj.get_full_name() if obj else "" @@ -113,24 +94,13 @@ class UserSerializer(serializers.ModelSerializer): class UserAdminSerializer(UserSerializer): - total_private_projects = serializers.SerializerMethodField("get_total_private_projects") - total_public_projects = serializers.SerializerMethodField("get_total_public_projects") - - class Meta: - model = User - # IMPORTANT: Maintain the UserSerializer Meta up to date - # with this info (including here the email) - fields = ("id", "username", "full_name", "full_name_display", "email", - "color", "bio", "lang", "theme", "timezone", "is_active", "photo", - "big_photo", "gravatar_url", - "max_private_projects", "max_public_projects", - "max_memberships_private_projects", "max_memberships_public_projects", - "total_private_projects", "total_public_projects") - - read_only_fields = ("id", "email", - "max_private_projects", "max_public_projects", - "max_memberships_private_projects", - "max_memberships_public_projects") + total_private_projects = MethodField() + total_public_projects = MethodField() + email = Field() + max_private_projects = Field() + max_public_projects = Field() + max_memberships_private_projects = Field() + max_memberships_public_projects = Field() def get_total_private_projects(self, user): return user.owned_projects.filter(is_private=True).count() @@ -163,75 +133,63 @@ class UserBasicInfoSerializer(serializers.LightSerializer): return super().to_value(instance) -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) - - -class CancelAccountSerializer(serializers.Serializer): - cancel_token = serializers.CharField(max_length=200) - - ###################################################### # Role ###################################################### -class RoleSerializer(serializers.ModelSerializer): - members_count = serializers.SerializerMethodField("get_members_count") +class RoleSerializer(serializers.LightSerializer): + id = Field() + name = Field() + computable = Field() + project = Field(attr="project_id") + order = Field() + members_count = MethodField() permissions = PgArrayField(required=False) - class Meta: - model = Role - fields = ('id', 'name', 'permissions', 'computable', 'project', 'order', 'members_count') - i18n_fields = ("name",) - def get_members_count(self, obj): return obj.memberships.count() -class ProjectRoleSerializer(serializers.ModelSerializer): - class Meta: - model = Role - fields = ('id', 'name', 'slug', 'order', 'computable') - i18n_fields = ("name",) +class ProjectRoleSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + order = Field() + computable = Field() ###################################################### # Like ###################################################### -class HighLightedContentSerializer(serializers.Serializer): - type = serializers.CharField() - id = serializers.IntegerField() - ref = serializers.IntegerField() - slug = serializers.CharField() - name = serializers.CharField() - subject = serializers.CharField() - description = serializers.SerializerMethodField("get_description") - assigned_to = serializers.IntegerField() - status = serializers.CharField() - status_color = serializers.CharField() - tags_colors = serializers.SerializerMethodField("get_tags_color") - created_date = serializers.DateTimeField() - is_private = serializers.SerializerMethodField("get_is_private") - logo_small_url = serializers.SerializerMethodField("get_logo_small_url") +class HighLightedContentSerializer(serializers.LightSerializer): + type = Field() + id = Field() + ref = Field() + slug = Field() + name = Field() + subject = Field() + description = MethodField() + assigned_to = Field() + status = Field() + status_color = Field() + tags_colors = MethodField() + created_date = Field() + is_private = MethodField() + logo_small_url = MethodField() - project = serializers.SerializerMethodField("get_project") - project_name = serializers.SerializerMethodField("get_project_name") - project_slug = serializers.SerializerMethodField("get_project_slug") - project_is_private = serializers.SerializerMethodField("get_project_is_private") - project_blocked_code = serializers.CharField() + project = MethodField() + project_name = MethodField() + project_slug = MethodField() + project_is_private = MethodField() + project_blocked_code = Field() - assigned_to_username = serializers.CharField() - assigned_to_full_name = serializers.CharField() - assigned_to_photo = serializers.SerializerMethodField("get_photo") + assigned_to_username = Field() + assigned_to_full_name = Field() + assigned_to_photo = MethodField() - is_watcher = serializers.SerializerMethodField("get_is_watcher") - total_watchers = serializers.IntegerField() + is_watcher = MethodField() + total_watchers = Field() def __init__(self, *args, **kwargs): # Don't pass the extra ids args up to the superclass @@ -241,18 +199,18 @@ class HighLightedContentSerializer(serializers.Serializer): super().__init__(*args, **kwargs) def _none_if_project(self, obj, property): - type = obj.get("type", "") + type = getattr(obj, "type", "") if type == "project": return None - return obj.get(property) + return getattr(obj, property) def _none_if_not_project(self, obj, property): - type = obj.get("type", "") + type = getattr(obj, "type", "") if type != "project": return None - return obj.get(property) + return getattr(obj, property) def get_project(self, obj): return self._none_if_project(obj, "project") @@ -278,29 +236,29 @@ class HighLightedContentSerializer(serializers.Serializer): return get_thumbnail_url(logo, settings.THN_LOGO_SMALL) return None - def get_photo(self, obj): - type = obj.get("type", "") + def get_assigned_to_photo(self, obj): + type = getattr(obj, "type", "") if type == "project": return None UserData = namedtuple("UserData", ["photo", "email"]) - user_data = UserData(photo=obj["assigned_to_photo"], email=obj.get("assigned_to_email") or "") + user_data = UserData(photo=obj.assigned_to_photo, email=obj.assigned_to_email or "") return get_photo_or_gravatar_url(user_data) - def get_tags_color(self, obj): - tags = obj.get("tags", []) + def get_tags_colors(self, obj): + tags = getattr(obj, "tags", []) tags = tags if tags is not None else [] - tags_colors = obj.get("tags_colors", []) + tags_colors = getattr(obj, "tags_colors", []) tags_colors = tags_colors if tags_colors is not None else [] return [{"name": tc[0], "color": tc[1]} for tc in tags_colors if tc[0] in tags] def get_is_watcher(self, obj): - return obj["id"] in self.user_watching.get(obj["type"], []) + return obj.id in self.user_watching.get(obj.type, []) class LikedObjectSerializer(HighLightedContentSerializer): - is_fan = serializers.SerializerMethodField("get_is_fan") - total_fans = serializers.IntegerField() + is_fan = MethodField() + total_fans = Field() def __init__(self, *args, **kwargs): # Don't pass the extra ids args up to the superclass @@ -310,12 +268,12 @@ class LikedObjectSerializer(HighLightedContentSerializer): super().__init__(*args, **kwargs) def get_is_fan(self, obj): - return obj["id"] in self.user_likes.get(obj["type"], []) + return obj.id in self.user_likes.get(obj.type, []) class VotedObjectSerializer(HighLightedContentSerializer): - is_voter = serializers.SerializerMethodField("get_is_voter") - total_voters = serializers.IntegerField() + is_voter = MethodField() + total_voters = Field() def __init__(self, *args, **kwargs): # Don't pass the extra ids args up to the superclass @@ -325,4 +283,4 @@ class VotedObjectSerializer(HighLightedContentSerializer): super().__init__(*args, **kwargs) def get_is_voter(self, obj): - return obj["id"] in self.user_votes.get(obj["type"], []) + return obj.id in self.user_votes.get(obj.type, []) diff --git a/taiga/users/validators.py b/taiga/users/validators.py index 477342de..11e78efb 100644 --- a/taiga/users/validators.py +++ b/taiga/users/validators.py @@ -3,7 +3,6 @@ # Copyright (C) 2014-2016 Jesús Espino # Copyright (C) 2014-2016 David Barragán # Copyright (C) 2014-2016 Alejandro Alonso -# Copyright (C) 2014-2016 Anler Hernández # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the @@ -17,17 +16,92 @@ # 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 as _ +from django.core import validators as core_validators +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.fields import PgArrayField, Field -from . import models +from .models import User, Role + +import re class RoleExistsValidator: def validate_role_id(self, attrs, source): value = attrs[source] - if not models.Role.objects.filter(pk=value).exists(): + if not Role.objects.filter(pk=value).exists(): msg = _("There's no role with that id") raise serializers.ValidationError(msg) return attrs + + +###################################################### +# User +###################################################### +class UserValidator(validators.ModelValidator): + class Meta: + model = User + fields = ("username", "full_name", "color", "bio", "lang", + "theme", "timezone", "is_active") + + def validate_username(self, attrs, source): + value = attrs[source] + validator = core_validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), + _("invalid")) + + try: + validator(value) + except ValidationError: + raise validators.ValidationError(_("Required. 255 characters or fewer. Letters, " + "numbers and /./-/_ characters'")) + + if (self.object and + self.object.username != value and + User.objects.filter(username=value).exists()): + raise validators.ValidationError(_("Invalid username. Try with a different one.")) + + return attrs + + +class UserAdminValidator(UserValidator): + class Meta: + model = User + # IMPORTANT: Maintain the UserSerializer Meta up to date + # with this info (including here the email) + fields = ("username", "full_name", "color", "bio", "lang", + "theme", "timezone", "is_active", "email") + + +class RecoveryValidator(validators.Validator): + token = serializers.CharField(max_length=200) + password = serializers.CharField(min_length=6) + + +class ChangeEmailValidator(validators.Validator): + email_token = serializers.CharField(max_length=200) + + +class CancelAccountValidator(validators.Validator): + cancel_token = serializers.CharField(max_length=200) + + +###################################################### +# Role +###################################################### + +class RoleValidator(validators.ModelValidator): + permissions = PgArrayField(required=False) + + class Meta: + model = Role + fields = ('id', 'name', 'permissions', 'computable', 'project', 'order') + i18n_fields = ("name",) + + +class ProjectRoleValidator(validators.ModelValidator): + class Meta: + model = Role + fields = ('id', 'name', 'slug', 'order', 'computable') diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index 90ed5599..f4bf2b51 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -30,6 +30,7 @@ from ..utils import DUMMY_BMP_DATA from taiga.base.utils import json from taiga.base.utils.thumbnails import get_thumbnail_url +from taiga.base.utils.dicts import into_namedtuple from taiga.users import models from taiga.users.serializers import LikedObjectSerializer, VotedObjectSerializer from taiga.auth.tokens import get_token_for_user @@ -505,7 +506,7 @@ def test_get_watched_list_valid_info_for_project(): raw_project_watch_info = get_watched_list(fav_user, viewer_user)[0] - project_watch_info = LikedObjectSerializer(raw_project_watch_info).data + project_watch_info = LikedObjectSerializer(into_namedtuple(raw_project_watch_info)).data assert project_watch_info["type"] == "project" assert project_watch_info["id"] == project.id @@ -559,7 +560,7 @@ def test_get_liked_list_valid_info(): project.refresh_totals() raw_project_like_info = get_liked_list(fan_user, viewer_user)[0] - project_like_info = LikedObjectSerializer(raw_project_like_info).data + project_like_info = LikedObjectSerializer(into_namedtuple(raw_project_like_info)).data assert project_like_info["type"] == "project" assert project_like_info["id"] == project.id @@ -609,7 +610,7 @@ def test_get_watched_list_valid_info_for_not_project_types(): instance.add_watcher(fav_user) raw_instance_watch_info = get_watched_list(fav_user, viewer_user, type=object_type)[0] - instance_watch_info = VotedObjectSerializer(raw_instance_watch_info).data + instance_watch_info = VotedObjectSerializer(into_namedtuple(raw_instance_watch_info)).data assert instance_watch_info["type"] == object_type assert instance_watch_info["id"] == instance.id @@ -666,7 +667,7 @@ def test_get_voted_list_valid_info(): f.VotesFactory(content_type=content_type, object_id=instance.id, count=3) raw_instance_vote_info = get_voted_list(fav_user, viewer_user, type=object_type)[0] - instance_vote_info = VotedObjectSerializer(raw_instance_vote_info).data + instance_vote_info = VotedObjectSerializer(into_namedtuple(raw_instance_vote_info)).data assert instance_vote_info["type"] == object_type assert instance_vote_info["id"] == instance.id