Skip to content

Commit

Permalink
Improving contacts endpoints from users API
Browse files Browse the repository at this point in the history
  • Loading branch information
superalex committed Nov 10, 2016
1 parent 17bea5b commit 0ff9aeb
Show file tree
Hide file tree
Showing 11 changed files with 364 additions and 138 deletions.
15 changes: 13 additions & 2 deletions taiga/base/api/fields.py
Expand Up @@ -618,13 +618,24 @@ def from_native(self, value):
return value


class InvalidEmailValidationError(ValidationError):
pass


class InvalidDomainValidationError(ValidationError):
pass


def validate_user_email_allowed_domains(value):
validators.validate_email(value)
try:
validators.validate_email(value)
except ValidationError as e:
raise InvalidEmailValidationError(e)

domain_name = value.split("@")[1]

if settings.USER_EMAIL_ALLOWED_DOMAINS and domain_name not in settings.USER_EMAIL_ALLOWED_DOMAINS:
raise ValidationError(_("You email domain is not allowed"))
raise InvalidDomainValidationError(_("You email domain is not allowed"))


class EmailField(CharField):
Expand Down
12 changes: 4 additions & 8 deletions taiga/projects/api.py
Expand Up @@ -653,7 +653,6 @@ def get_queryset(self):
class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet):
model = models.Membership
admin_serializer_class = serializers.MembershipAdminSerializer
admin_validator_class = validators.MembershipAdminValidator
serializer_class = serializers.MembershipSerializer
validator_class = validators.MembershipValidator
permission_classes = (permissions.MembershipPermission,)
Expand All @@ -680,12 +679,6 @@ def get_serializer_class(self):
else:
return self.serializer_class

def get_validator_class(self):
if self.action == "create":
return self.admin_validator_class

return self.validator_class

def _check_if_project_can_have_more_memberships(self, project, total_new_memberships):
(can_add_memberships, error_type) = services.check_if_project_can_have_more_memberships(
project,
Expand All @@ -700,7 +693,10 @@ def _check_if_project_can_have_more_memberships(self, project, total_new_members

@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
validator = validators.MembersBulkValidator(data=request.DATA)
context = {
"request": request
}
validator = validators.MembersBulkValidator(data=request.DATA, context=context)
if not validator.is_valid():
return response.BadRequest(validator.errors)

Expand Down
18 changes: 16 additions & 2 deletions taiga/projects/services/members.py
Expand Up @@ -16,11 +16,16 @@
# 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.exceptions import ValidationError
from taiga.base.utils import db, text
from taiga.users.models import User

from django.core.validators import validate_email
from django.utils.translation import ugettext as _

from .. import models


def get_members_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of members.
Expand All @@ -32,6 +37,15 @@ def get_members_from_bulk(bulk_data, **additional_fields):
members = []
for data in bulk_data:
data_copy = data.copy()
username = data_copy.pop("username")
try:
validate_email(username)
data_copy["email"] = username

except ValidationError:
user = User.objects.filter(username=username).first()
data_copy["user_id"] = user.id

data_copy.update(additional_fields)
members.append(models.Membership(**data_copy))
return members
Expand All @@ -40,7 +54,7 @@ def get_members_from_bulk(bulk_data, **additional_fields):
def create_members_in_bulk(bulk_data, callback=None, precall=None, **additional_fields):
"""Create members from `bulk_data`.
:param bulk_data: List of dicts `{"project_id": <>, "role_id": <>, "email": <>}`.
:param bulk_data: List of dicts `{"project_id": <>, "role_id": <>, "username": <>}`.
:param callback: Callback to execute after each task save.
:param additional_fields: Additional fields when instantiating each task.
Expand Down Expand Up @@ -116,7 +130,7 @@ def check_if_project_can_have_more_memberships(project, total_new_memberships):
"""
if project.owner is None:
return False, _("Project without owner")

if project.is_private:
total_memberships = project.memberships.count() + total_new_memberships
max_memberships = project.owner.max_memberships_private_projects
Expand Down
104 changes: 73 additions & 31 deletions taiga/projects/validators.py
Expand Up @@ -16,15 +16,19 @@
# 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.validators import validate_email
from django.db.models import Q
from django.utils.translation import ugettext as _

from taiga.base.api import serializers
from taiga.base.api import validators
from taiga.base.api.fields import validate_user_email_allowed_domains, InvalidEmailValidationError
from taiga.base.exceptions import ValidationError
from taiga.base.fields import JSONField
from taiga.base.fields import PgArrayField
from taiga.users.models import Role
from taiga.users.models import User, Role
from taiga.users import filters as user_filters


from .tagging.fields import TagsField

Expand Down Expand Up @@ -112,21 +116,24 @@ class Meta:
######################################################

class MembershipValidator(validators.ModelValidator):
email = serializers.EmailField(required=True)
username = serializers.CharField(required=True)
# email = serializers.EmailField(required=False)
# user = serializers.PrimaryKeyRelatedField(required=False)

class Meta:
model = models.Membership
# IMPORTANT: Maintain the MembershipAdminSerializer Meta up to date
# with this info (excluding here user_email and email)
read_only_fields = ("user",)
exclude = ("token", "email")
read_only_fields = ("user", "email")

def validate_email(self, attrs, source):
project = attrs.get("project", None)
if project is None:
project = self.object.project
def restore_object(self, attrs, instance=None):
username = attrs.pop("username", None)
obj = super(MembershipValidator, self).restore_object(attrs, instance=instance)
obj.username = username
return obj

email = attrs[source]
def _validate_member_doesnt_exist(self, attrs, email):
project = attrs.get("project", None if self.object is None else self.object.project)
if project is None:
return attrs

qs = models.Membership.objects.all()

Expand All @@ -139,14 +146,12 @@ def validate_email(self, attrs, source):
Q(project_id=project.id, email=email))

if qs.count() > 0:
raise ValidationError(_("Email address is already taken"))

return attrs
raise ValidationError(_("The user yet exists in the project"))

def validate_role(self, attrs, source):
project = attrs.get("project", None)
project = attrs.get("project", None if self.object is None else self.object.project)
if project is None:
project = self.object.project
return attrs

role = attrs[source]

Expand All @@ -155,10 +160,35 @@ def validate_role(self, attrs, source):

return attrs

def validate_username(self, attrs, source):
username = attrs.get(source, None)
try:
validate_user_email_allowed_domains(username)

except ValidationError:
# If the validation comes from a request let's check the user is a valid contact
request = self.context.get("request", None)
if request is not None and request.user.is_authenticated():
valid_usernames = request.user.contacts_visible_by_user(request.user).values_list("username", flat=True)
if username not in valid_usernames:
raise ValidationError(_("The user must be a valid contact"))

user = User.objects.filter(Q(username=username) | Q(email=username)).first()
if user is not None:
email = user.email
self.user = user

else:
email = username

self.email = email
self._validate_member_doesnt_exist(attrs, email)
return attrs

def validate_is_admin(self, attrs, source):
project = attrs.get("project", None)
project = attrs.get("project", None if self.object is None else self.object.project)
if project is None:
project = self.object.project
return attrs

if (self.object and self.object.user):
if self.object.user.id == project.owner_id and not attrs[source]:
Expand All @@ -171,33 +201,45 @@ def validate_is_admin(self, attrs, source):

return attrs

def is_valid(self):
errors = super().is_valid()
if hasattr(self, "email") and self.object is not None:
self.object.email = self.email

class MembershipAdminValidator(MembershipValidator):
class Meta:
model = models.Membership
# IMPORTANT: Maintain the MembershipSerializer Meta up to date
# with this info (excluding there user_email and email)
read_only_fields = ("user",)
exclude = ("token",)
if hasattr(self, "user") and self.object is not None:
self.object.user = self.user

return errors


class _MemberBulkValidator(validators.Validator):
email = serializers.EmailField()
username = serializers.CharField()
role_id = serializers.IntegerField()

def validate_username(self, attrs, source):
username = attrs.get(source)
try:
validate_user_email_allowed_domains(username)
except InvalidEmailValidationError:
# If the validation comes from a request let's check the user is a valid contact
request = self.context.get("request", None)
if request is not None and request.user.is_authenticated():
valid_usernames = set(request.user.contacts_visible_by_user(request.user).values_list("username", flat=True))
if username not in valid_usernames:
raise ValidationError(_("The user must be a valid contact"))

return attrs

class MembersBulkValidator(ProjectExistsValidator, validators.Validator):
project_id = serializers.IntegerField()
bulk_memberships = _MemberBulkValidator(many=True)
invitation_extra_text = serializers.CharField(required=False, max_length=255)

def validate_bulk_memberships(self, attrs, source):
filters = {
"project__id": attrs["project_id"],
"id__in": [r["role_id"] for r in attrs["bulk_memberships"]]
}
project_id = attrs["project_id"]
role_ids = [r["role_id"] for r in attrs["bulk_memberships"]]

if Role.objects.filter(**filters).count() != len(set(filters["id__in"])):
if Role.objects.filter(project_id=project_id, id__in=role_ids).count() != len(set(role_ids)):
raise ValidationError(_("Invalid role ids. All roles must belong to the same project."))

return attrs
Expand Down
12 changes: 9 additions & 3 deletions taiga/users/api.py
Expand Up @@ -46,17 +46,17 @@
from . import permissions
from . import filters as user_filters
from . import services
from . import utils as user_utils
from .signals import user_cancel_account as user_cancel_account_signal


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,)
model = models.User

def get_serializer_class(self):
if self.action in ["partial_update", "update", "retrieve", "by_username"]:
Expand All @@ -74,6 +74,12 @@ def get_validator_class(self):

return self.validator_class

def get_queryset(self):
qs = super().get_queryset()
qs = qs.prefetch_related("memberships")
qs = user_utils.attach_extra_info(qs, user=self.request.user)
return qs

def create(self, *args, **kwargs):
raise exc.NotSupported()

Expand All @@ -91,7 +97,7 @@ def list(self, request, *args, **kwargs):
return response.Ok(serializer.data)

def retrieve(self, request, *args, **kwargs):
self.object = get_object_or_404(models.User, **kwargs)
self.object = get_object_or_404(self.get_queryset(), **kwargs)
self.check_permissions(request, 'retrieve', self.object)
serializer = self.get_serializer(self.object)
return response.Ok(serializer.data)
Expand Down
19 changes: 15 additions & 4 deletions taiga/users/filters.py
Expand Up @@ -17,13 +17,24 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from taiga.base.filters import PermissionBasedFilterBackend
from taiga.base.utils.db import to_tsquery

from . import services


class ContactsFilterBackend(PermissionBasedFilterBackend):
def filter_queryset(self, user, request, queryset, view):
qs = queryset.filter(is_active=True)
project_ids = services.get_visible_project_ids(user, request.user)
qs = qs.filter(memberships__project_id__in=project_ids)
qs = qs.exclude(id=user.id)
qs = user.contacts_visible_by_user(request.user)
q = request.QUERY_PARAMS.get('q', None)
if q:
table = qs.model._meta.db_table
where_clause = ("""
to_tsvector('english_nostop',
coalesce({table}.username, '') || ' ' ||
coalesce({table}.full_name) || ' ' ||
coalesce({table}.email, '')) @@ to_tsquery('english_nostop', %s)
""".format(table=table))

qs = qs.extra(where=[where_clause], params=[to_tsquery(q)])

return qs.distinct()
9 changes: 9 additions & 0 deletions taiga/users/models.py
Expand Up @@ -45,6 +45,8 @@
from taiga.projects.choices import BLOCKED_BY_OWNER_LEAVING
from taiga.projects.notifications.choices import NotifyLevel

from . import services


def get_user_model_safe():
"""
Expand Down Expand Up @@ -258,6 +260,13 @@ def get_short_name(self):
def get_full_name(self):
return self.full_name or self.username or self.email

def contacts_visible_by_user(self, user):
qs = User.objects.filter(is_active=True)
project_ids = services.get_visible_project_ids(self, user)
qs = qs.filter(memberships__project_id__in=project_ids)
qs = qs.exclude(id=self.id)
return qs

def save(self, *args, **kwargs):
get_token_for_user(self, "cancel_account")
super().save(*args, **kwargs)
Expand Down

0 comments on commit 0ff9aeb

Please sign in to comment.