From 78a2118e8e08eebcabde02d6e12584dacf0d1a3f Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 23 Jun 2016 15:11:16 +0200 Subject: [PATCH 01/14] API performance --- taiga/base/api/serializers.py | 2 + taiga/permissions/services.py | 70 +-- taiga/projects/api.py | 50 +- taiga/projects/filters.py | 35 +- taiga/projects/issues/api.py | 1 - taiga/projects/notifications/mixins.py | 7 +- taiga/projects/serializers.py | 283 ++++++++++-- taiga/projects/services/projects.py | 53 ++- taiga/projects/utils.py | 436 ++++++++++++++++++ taiga/users/models.py | 2 +- .../test_projects_resource.py | 6 +- tests/integration/test_projects.py | 2 +- 12 files changed, 818 insertions(+), 129 deletions(-) create mode 100644 taiga/projects/utils.py diff --git a/taiga/base/api/serializers.py b/taiga/base/api/serializers.py index 7de82458..601c1753 100644 --- a/taiga/base/api/serializers.py +++ b/taiga/base/api/serializers.py @@ -1228,4 +1228,6 @@ class LightSerializer(serpy.Serializer): kwargs.pop("read_only", None) kwargs.pop("partial", None) kwargs.pop("files", None) + context = kwargs.pop("context", {}) super().__init__(*args, **kwargs) + self.context = context diff --git a/taiga/permissions/services.py b/taiga/permissions/services.py index 32357926..6d89c168 100644 --- a/taiga/permissions/services.py +++ b/taiga/permissions/services.py @@ -91,39 +91,55 @@ def _get_membership_permissions(membership): return [] +def calculate_permissions(is_authenticated=False, is_superuser=False, is_member=False, + is_admin=False, role_permissions=[], anon_permissions=[], + public_permissions=[]): + if is_superuser: + admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) + members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) + public_permissions = [] + anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) + elif is_member: + if is_admin: + admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) + members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) + else: + admins_permissions = [] + members_permissions = [] + members_permissions = members_permissions + role_permissions + public_permissions = public_permissions if public_permissions is not None else [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + elif is_authenticated: + admins_permissions = [] + members_permissions = [] + public_permissions = public_permissions if public_permissions is not None else [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + else: + admins_permissions = [] + members_permissions = [] + public_permissions = [] + anon_permissions = anon_permissions if anon_permissions is not None else [] + + return set(admins_permissions + members_permissions + public_permissions + anon_permissions) + + def get_user_project_permissions(user, project, cache="user"): """ cache param determines how memberships are calculated trying to reuse the existing data in cache """ membership = _get_user_project_membership(user, project, cache=cache) - if user.is_superuser: - admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) - members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) - public_permissions = [] - anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) - elif membership: - if membership.is_admin: - admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS)) - members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) - else: - admins_permissions = [] - members_permissions = [] - members_permissions = members_permissions + _get_membership_permissions(membership) - public_permissions = project.public_permissions if project.public_permissions is not None else [] - anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] - elif user.is_authenticated(): - admins_permissions = [] - members_permissions = [] - public_permissions = project.public_permissions if project.public_permissions is not None else [] - anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] - else: - admins_permissions = [] - members_permissions = [] - public_permissions = [] - anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] - - return set(admins_permissions + members_permissions + public_permissions + anon_permissions) + is_member = membership is not None + is_admin = is_member and membership.is_admin + return calculate_permissions( + is_authenticated = user.is_authenticated(), + is_superuser = user.is_superuser, + is_member = is_member, + is_admin = is_admin, + role_permissions = _get_membership_permissions(membership), + anon_permissions = project.anon_permissions, + public_permissions = project.public_permissions + ) def set_base_permissions_for_project(project): diff --git a/taiga/projects/api.py b/taiga/projects/api.py index 6c7a4ec9..c6fbbe0d 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -61,7 +61,7 @@ from . import models from . import permissions from . import serializers from . import services - +from . import utils as project_utils ###################################################### ## Project @@ -70,11 +70,9 @@ from . import services class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMixin, BlockeableDeleteMixin, TagsColorsResourceMixin, ModelCrudViewSet): queryset = models.Project.objects.all() - serializer_class = serializers.ProjectDetailSerializer - admin_serializer_class = serializers.ProjectDetailAdminSerializer - list_serializer_class = serializers.ProjectSerializer permission_classes = (permissions.ProjectPermission, ) - filter_backends = (project_filters.QFilterBackend, + filter_backends = (project_filters.UserOrderFilterBackend, + project_filters.QFilterBackend, project_filters.CanViewProjectObjFilterBackend, project_filters.DiscoverModeFilterBackend) @@ -85,8 +83,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix "is_kanban_activated") ordering = ("name", "id") - order_by_fields = ("memberships__user_order", - "total_fans", + order_by_fields = ("total_fans", "total_fans_last_week", "total_fans_last_month", "total_fans_last_year", @@ -106,18 +103,8 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix def get_queryset(self): qs = super().get_queryset() - qs = qs.select_related("owner") - # Prefetch doesn"t work correctly if then if the field is filtered later (it generates more queries) - # so we add some custom prefetching - qs = qs.prefetch_related("members") - qs = qs.prefetch_related("memberships") - qs = qs.prefetch_related(Prefetch("notify_policies", - NotifyPolicy.objects.exclude(notify_level=NotifyLevel.none), to_attr="valid_notify_policies")) - - Milestone = apps.get_model("milestones", "Milestone") - qs = qs.prefetch_related(Prefetch("milestones", - Milestone.objects.filter(closed=True), to_attr="closed_milestones")) + qs = project_utils.attach_extra_info(qs, user=self.request.user) # If filtering an activity period we must exclude the activities not updated recently enough now = timezone.now() @@ -137,22 +124,20 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix return qs + def retrieve(self, request, *args, **kwargs): + if self.action == "by_slug": + self.lookup_field = "slug" + + return super().retrieve(request, *args, **kwargs) + def get_serializer_class(self): - serializer_class = self.serializer_class - if self.action == "list": - serializer_class = self.list_serializer_class - elif self.action != "create": - if self.action == "by_slug": - slug = self.request.QUERY_PARAMS.get("slug", None) - project = get_object_or_404(models.Project, slug=slug) - else: - project = self.get_object() + return serializers.LightProjectSerializer - if permissions_services.is_project_admin(self.request.user, project): - serializer_class = self.admin_serializer_class + if self.action in ["retrieve", "by_slug"]: + return serializers.LightProjectDetailSerializer - return serializer_class + return serializers.ProjectSerializer @detail_route(methods=["POST"]) def change_logo(self, request, *args, **kwargs): @@ -283,10 +268,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix return response.Ok(data) @list_route(methods=["GET"]) - def by_slug(self, request): + def by_slug(self, request, *args, **kwargs): slug = request.QUERY_PARAMS.get("slug", None) - project = get_object_or_404(models.Project, slug=slug) - return self.retrieve(request, pk=project.pk) + return self.retrieve(request, slug=slug) @detail_route(methods=["GET", "PATCH"]) def modules(self, request, pk=None): diff --git a/taiga/projects/filters.py b/taiga/projects/filters.py index b3be1a0a..cbb692b8 100644 --- a/taiga/projects/filters.py +++ b/taiga/projects/filters.py @@ -45,7 +45,7 @@ class DiscoverModeFilterBackend(FilterBackend): if request.QUERY_PARAMS.get("is_featured", None) == 'true': qs = qs.order_by("?") - return super().filter_queryset(request, qs.distinct(), view) + return super().filter_queryset(request, qs, view) class CanViewProjectObjFilterBackend(FilterBackend): @@ -86,7 +86,7 @@ class CanViewProjectObjFilterBackend(FilterBackend): # external users / anonymous qs = qs.filter(anon_permissions__contains=["view_project"]) - return super().filter_queryset(request, qs.distinct(), view) + return super().filter_queryset(request, qs, view) class QFilterBackend(FilterBackend): @@ -121,3 +121,34 @@ class QFilterBackend(FilterBackend): params=params, order_by=order_by) return queryset + + +class UserOrderFilterBackend(FilterBackend): + def filter_queryset(self, request, queryset, view): + if request.user.is_anonymous(): + return queryset + + raw_fieldname = request.QUERY_PARAMS.get(self.order_by_query_param, None) + if not raw_fieldname: + return queryset + + if raw_fieldname.startswith("-"): + field_name = raw_fieldname[1:] + else: + field_name = raw_fieldname + + if field_name != "user_order": + return queryset + + model = queryset.model + sql = """SELECT projects_membership.user_order + FROM projects_membership + WHERE + projects_membership.project_id = {tbl}.id AND + projects_membership.user_id = {user_id} + """ + + sql = sql.format(tbl=model._meta.db_table, user_id=request.user.id) + queryset = queryset.extra(select={"user_order": sql}) + queryset = queryset.order_by(raw_fieldname) + return queryset diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 57acfca8..8da13476 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -144,7 +144,6 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W def get_queryset(self): qs = super().get_queryset() - qs = qs.prefetch_related("attachments", "generated_user_stories") qs = qs.select_related("owner", "assigned_to", "status", "project") qs = self.attach_votes_attrs_to_queryset(qs) return self.attach_watchers_attrs_to_queryset(qs) diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py index 62db374e..2cad1e97 100644 --- a/taiga/projects/notifications/mixins.py +++ b/taiga/projects/notifications/mixins.py @@ -188,9 +188,10 @@ class WatchedModelMixin(object): class BaseWatchedResourceModelSerializer(object): def get_is_watcher(self, obj): + # The "is_watcher" attribute is attached in the get_queryset of the viewset. if "request" in self.context: user = self.context["request"].user - return user.is_authenticated() and user.is_watcher(obj) + return user.is_authenticated() and getattr(obj, "is_watcher", False) return False @@ -205,8 +206,8 @@ class WatchedResourceModelSerializer(BaseWatchedResourceModelSerializer, seriali class ListWatchedResourceModelSerializer(BaseWatchedResourceModelSerializer, serpy.Serializer): - is_watcher = serializers.SerializerMethodField("get_is_watcher") - total_watchers = serializers.SerializerMethodField("get_total_watchers") + is_watcher = serpy.MethodField("get_is_watcher") + total_watchers = serpy.MethodField("get_total_watchers") class EditableWatchedResourceModelSerializer(WatchedResourceModelSerializer): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 9c185a97..c10a4810 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -25,12 +25,15 @@ from taiga.base.api import serializers from taiga.base.fields import JsonField from taiga.base.fields import PgArrayField +from taiga.permissions import services as permissions_services from taiga.users.services import get_photo_or_gravatar_url from taiga.users.serializers import UserBasicInfoSerializer from taiga.users.serializers import ProjectRoleSerializer +from taiga.users.serializers import ListUserBasicInfoSerializer from taiga.users.validators import RoleExistsValidator from taiga.permissions.services import get_user_project_permissions +from taiga.permissions.services import calculate_permissions from taiga.permissions.services import is_project_admin, is_project_owner from . import models @@ -46,6 +49,7 @@ from .tagging.fields import TagsField from .tagging.fields import TagsColorsField from .validators import ProjectExistsValidator +import serpy ###################################################### ## Custom values for selectors @@ -295,11 +299,6 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ return False def get_total_closed_milestones(self, obj): - # The "closed_milestone" attribute can be attached in the get_queryset method of the viewset. - qs_closed_milestones = getattr(obj, "closed_milestones", None) - if qs_closed_milestones is not None: - return len(qs_closed_milestones) - return obj.milestones.filter(closed=True).count() def get_notify_level(self, obj): @@ -310,11 +309,6 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ return None def get_total_watchers(self, obj): - # The "valid_notify_policies" attribute can be attached in the get_queryset method of the viewset. - qs_valid_notify_policies = getattr(obj, "valid_notify_policies", None) - if qs_valid_notify_policies is not None: - return len(qs_valid_notify_policies) - return obj.notify_policies.exclude(notify_level=NotifyLevel.none).count() def get_logo_small_url(self, obj): @@ -324,60 +318,253 @@ class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializ return services.get_logo_big_thumbnail_url(obj) -class ProjectDetailSerializer(ProjectSerializer): - us_statuses = UserStoryStatusSerializer(many=True, required=False) # User Stories - points = PointsSerializer(many=True, required=False) +class LightProjectSerializer(serializers.LightSerializer): + id = serpy.Field() + name = serpy.Field() + slug = serpy.Field() + description = serpy.Field() + created_date = serpy.Field() + modified_date = serpy.Field() + owner = serpy.MethodField() + members = serpy.MethodField() + total_milestones = serpy.Field() + total_story_points = serpy.Field() + is_backlog_activated = serpy.Field() + is_kanban_activated = serpy.Field() + is_wiki_activated = serpy.Field() + is_issues_activated = serpy.Field() + videoconferences = serpy.Field() + videoconferences_extra_data = serpy.Field() + creation_template = serpy.Field(attr="creation_template_id") + is_private = serpy.Field() + anon_permissions = serpy.Field() + public_permissions = serpy.Field() + is_featured = serpy.Field() + is_looking_for_people = serpy.Field() + looking_for_people_note = serpy.Field() + blocked_code = serpy.Field() + totals_updated_datetime = serpy.Field() + total_fans = serpy.Field() + total_fans_last_week = serpy.Field() + total_fans_last_month = serpy.Field() + total_fans_last_year = serpy.Field() + total_activity = serpy.Field() + total_activity_last_week = serpy.Field() + total_activity_last_month = serpy.Field() + total_activity_last_year = serpy.Field() - task_statuses = TaskStatusSerializer(many=True, required=False) # Tasks + tags = serpy.Field() + tags_colors = serpy.MethodField() - issue_statuses = IssueStatusSerializer(many=True, required=False) - issue_types = IssueTypeSerializer(many=True, required=False) - priorities = PrioritySerializer(many=True, required=False) # Issues - severities = SeveritySerializer(many=True, required=False) + default_points = serpy.Field(attr="default_points_id") + default_us_status = serpy.Field(attr="default_us_status_id") + default_task_status = serpy.Field(attr="default_task_status_id") + default_priority = serpy.Field(attr="default_priority_id") + default_severity = serpy.Field(attr="default_severity_id") + default_issue_status = serpy.Field(attr="default_issue_status_id") + default_issue_type = serpy.Field(attr="default_issue_type_id") - userstory_custom_attributes = UserStoryCustomAttributeSerializer(source="userstorycustomattributes", - many=True, required=False) - task_custom_attributes = TaskCustomAttributeSerializer(source="taskcustomattributes", - many=True, required=False) - issue_custom_attributes = IssueCustomAttributeSerializer(source="issuecustomattributes", - many=True, required=False) + my_permissions = serpy.MethodField() - roles = ProjectRoleSerializer(source="roles", many=True, read_only=True) - members = serializers.SerializerMethodField(method_name="get_members") - total_memberships = serializers.SerializerMethodField(method_name="get_total_memberships") - is_out_of_owner_limits = serializers.SerializerMethodField(method_name="get_is_out_of_owner_limits") + i_am_owner = serpy.MethodField() + i_am_admin = serpy.MethodField() + i_am_member = serpy.MethodField() + + notify_level = serpy.MethodField("get_notify_level") + total_closed_milestones = serpy.MethodField() + + is_watcher = serpy.MethodField() + total_watchers = serpy.MethodField() + + logo_small_url = serpy.MethodField() + logo_big_url = serpy.MethodField() + + is_fan = serpy.Field(attr="is_fan_attr") def get_members(self, obj): - qs = obj.memberships.filter(user__isnull=False) - qs = qs.extra(select={"complete_user_name":"concat(full_name, username)"}) - qs = qs.order_by("complete_user_name") - qs = qs.select_related("role", "user") - serializer = ProjectMemberSerializer(qs, many=True) - return serializer.data + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return [] + + return [m.get("id") for m in obj.members_attr if m["id"] is not None] + + def get_i_am_member(self, obj): + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return False + + if "request" in self.context: + user = self.context["request"].user + if not user.is_anonymous() and user.id in [m.get("id") for m in obj.members_attr if m["id"] is not None]: + return True + + return False + + def get_tags_colors(self, obj): + return dict(obj.tags_colors) + + def get_my_permissions(self, obj): + if "request" in self.context: + user = self.context["request"].user + return calculate_permissions( + is_authenticated = user.is_authenticated(), + is_superuser = user.is_superuser, + is_member = self.get_i_am_member(obj), + is_admin = self.get_i_am_admin(obj), + role_permissions = obj.my_role_permissions_attr, + anon_permissions = obj.anon_permissions, + public_permissions = obj.public_permissions) + return [] + + def get_owner(self, obj): + return ListUserBasicInfoSerializer(obj.owner).data + + def get_i_am_owner(self, obj): + if "request" in self.context: + return is_project_owner(self.context["request"].user, obj) + return False + + def get_i_am_admin(self, obj): + if "request" in self.context: + return is_project_admin(self.context["request"].user, obj) + return False + + def get_total_closed_milestones(self, obj): + assert hasattr(obj, "closed_milestones_attr"), "instance must have a closed_milestones_attr attribute" + return obj.closed_milestones_attr + + def get_is_watcher(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + np = self.get_notify_level(obj) + return np != None and np != NotifyLevel.none + + def get_total_watchers(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return 0 + + valid_notify_policies = [np for np in obj.notify_policies_attr if np["notify_level"] != NotifyLevel.none] + return len(valid_notify_policies) + + def get_notify_level(self, obj): + assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" + if obj.notify_policies_attr is None: + return None + + if "request" in self.context: + user = self.context["request"].user + for np in obj.notify_policies_attr: + if np["user_id"] == user.id: + return np["notify_level"] + + return None + + def get_logo_small_url(self, obj): + return services.get_logo_small_thumbnail_url(obj) + + def get_logo_big_url(self, obj): + return services.get_logo_big_thumbnail_url(obj) + + +class LightProjectDetailSerializer(LightProjectSerializer): + us_statuses = serpy.Field(attr="userstory_statuses_attr") + points = serpy.Field(attr="points_attr") + task_statuses = serpy.Field(attr="task_statuses_attr") + issue_statuses = serpy.Field(attr="issue_statuses_attr") + issue_types = serpy.Field(attr="issue_types_attr") + priorities = serpy.Field(attr="priorities_attr") + severities = serpy.Field(attr="severities_attr") + userstory_custom_attributes = serpy.Field(attr="userstory_custom_attributes_attr") + task_custom_attributes = serpy.Field(attr="task_custom_attributes_attr") + issue_custom_attributes = serpy.Field(attr="issue_custom_attributes_attr") + roles = serpy.Field(attr="roles_attr") + members = serpy.MethodField() + total_memberships = serpy.MethodField() + is_out_of_owner_limits = serpy.MethodField() + + #Admin fields + is_private_extra_info = serpy.MethodField() + max_memberships = serpy.MethodField() + issues_csv_uuid = serpy.Field() + tasks_csv_uuid = serpy.Field() + userstories_csv_uuid = serpy.Field() + transfer_token = serpy.Field() + + def to_value(self, instance): + # Name attributes must be translated + for attr in ["userstory_statuses_attr","points_attr", "task_statuses_attr", + "issue_statuses_attr", "issue_types_attr", "priorities_attr", + "severities_attr", "userstory_custom_attributes_attr", + "task_custom_attributes_attr","issue_custom_attributes_attr", "roles_attr"]: + + assert hasattr(instance, attr), "instance must have a {} attribute".format(attr) + val = getattr(instance, attr) + if val is None: + continue + + for elem in val: + elem["name"] = _(elem["name"]) + + ret = super().to_value(instance) + + admin_fields = [ + "is_private_extra_info", "max_memberships", "issues_csv_uuid", + "tasks_csv_uuid", "userstories_csv_uuid", "transfer_token" + ] + + is_admin_user = False + if "request" in self.context: + user = self.context["request"].user + is_admin_user = permissions_services.is_project_admin(user, instance) + + if not is_admin_user: + for admin_field in admin_fields: + del(ret[admin_field]) + + return ret + + def get_members(self, obj): + assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" + if obj.members_attr is None: + return [] + + ret = [] + for m in obj.members_attr: + m["full_name_display"] = m["full_name"] or m["username"] or m["email"] + del(m["email"]) + del(m["complete_user_name"]) + if not m["id"] is None: + ret.append(m) + + return ret def get_total_memberships(self, obj): - return services.get_total_project_memberships(obj) + if obj.members_attr is None: + return 0 + + return len(obj.members_attr) def get_is_out_of_owner_limits(self, obj): - return services.check_if_project_is_out_of_owner_limits(obj) - - -class ProjectDetailAdminSerializer(ProjectDetailSerializer): - is_private_extra_info = serializers.SerializerMethodField(method_name="get_is_private_extra_info") - max_memberships = serializers.SerializerMethodField(method_name="get_max_memberships") - - class Meta: - model = models.Project - read_only_fields = ("created_date", "modified_date", "slug", "blocked_code") - exclude = ("logo", "last_us_ref", "last_task_ref", "last_issue_ref") + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + return services.check_if_project_is_out_of_owner_limits(obj, + current_memberships = self.get_total_memberships(obj), + current_private_projects=obj.private_projects_same_owner_attr, + current_public_projects=obj.public_projects_same_owner_attr + ) def get_is_private_extra_info(self, obj): - return services.check_if_project_privacity_can_be_changed(obj) + assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" + assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" + return services.check_if_project_privacity_can_be_changed(obj, + current_memberships = self.get_total_memberships(obj), + current_private_projects=obj.private_projects_same_owner_attr, + current_public_projects=obj.public_projects_same_owner_attr + ) def get_max_memberships(self, obj): return services.get_max_memberships_for_project(obj) - ###################################################### ## Liked ###################################################### diff --git a/taiga/projects/services/projects.py b/taiga/projects/services/projects.py index f56a9941..2bd31d94 100644 --- a/taiga/projects/services/projects.py +++ b/taiga/projects/services/projects.py @@ -27,30 +27,45 @@ ERROR_MAX_PUBLIC_PROJECTS = 'max_public_projects' ERROR_MAX_PRIVATE_PROJECTS = 'max_private_projects' ERROR_PROJECT_WITHOUT_OWNER = 'project_without_owner' -def check_if_project_privacity_can_be_changed(project): +def check_if_project_privacity_can_be_changed(project, + current_memberships=None, + current_private_projects=None, + current_public_projects=None): """Return if the project privacity can be changed from private to public or viceversa. :param project: A project object. + :param current_memberships: Project total memberships, If None it will be calculated. + :param current_private_projects: total private projects owned by the project owner, If None it will be calculated. + :param current_public_projects: total public projects owned by the project owner, If None it will be calculated. :return: A dict like this {'can_be_updated': bool, 'reason': error message}. """ if project.owner is None: return {'can_be_updated': False, 'reason': ERROR_PROJECT_WITHOUT_OWNER} - if project.is_private: + if current_memberships is None: current_memberships = project.memberships.count() + + if project.is_private: max_memberships = project.owner.max_memberships_public_projects error_memberships_exceeded = ERROR_MAX_PUBLIC_PROJECTS_MEMBERSHIPS - current_projects = project.owner.owned_projects.filter(is_private=False).count() + if current_public_projects is None: + current_projects = project.owner.owned_projects.filter(is_private=False).count() + else: + current_projects = current_public_projects + max_projects = project.owner.max_public_projects error_project_exceeded = ERROR_MAX_PUBLIC_PROJECTS else: - current_memberships = project.memberships.count() max_memberships = project.owner.max_memberships_private_projects error_memberships_exceeded = ERROR_MAX_PRIVATE_PROJECTS_MEMBERSHIPS - current_projects = project.owner.owned_projects.filter(is_private=True).count() + if current_private_projects is None: + current_projects = project.owner.owned_projects.filter(is_private=True).count() + else: + current_projects = current_private_projects + max_projects = project.owner.max_private_projects error_project_exceeded = ERROR_MAX_PRIVATE_PROJECTS @@ -139,25 +154,43 @@ def check_if_project_can_be_transfered(project, new_owner): return (True, None) -def check_if_project_is_out_of_owner_limits(project): +def check_if_project_is_out_of_owner_limits(project, + current_memberships=None, + current_private_projects=None, + current_public_projects=None): + """Return if the project fits on its owner limits. :param project: A project object. + :param current_memberships: Project total memberships, If None it will be calculated. + :param current_private_projects: total private projects owned by the project owner, If None it will be calculated. + :param current_public_projects: total public projects owned by the project owner, If None it will be calculated. :return: bool """ if project.owner is None: return {'can_be_updated': False, 'reason': ERROR_PROJECT_WITHOUT_OWNER} - if project.is_private: + if current_memberships is None: current_memberships = project.memberships.count() + + if project.is_private: max_memberships = project.owner.max_memberships_private_projects - current_projects = project.owner.owned_projects.filter(is_private=True).count() + + if current_private_projects is None: + current_projects = project.owner.owned_projects.filter(is_private=True).count() + else: + current_projects = current_private_projects + max_projects = project.owner.max_private_projects else: - current_memberships = project.memberships.count() max_memberships = project.owner.max_memberships_public_projects - current_projects = project.owner.owned_projects.filter(is_private=False).count() + + if current_public_projects is None: + current_projects = project.owner.owned_projects.filter(is_private=False).count() + else: + current_projects = current_public_projects + max_projects = project.owner.max_public_projects if max_memberships is not None and current_memberships > max_memberships: diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py new file mode 100644 index 00000000..a05d8476 --- /dev/null +++ b/taiga/projects/utils.py @@ -0,0 +1,436 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# 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 +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +def attach_members(queryset, as_field="members_attr"): + """Attach a json members representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the members as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + users_user.id, + users_user.username, + users_user.full_name, + users_user.email, + concat(full_name, username) complete_user_name, + users_user.color, + users_user.photo, + users_user.is_active, + users_role.name role_name + + FROM projects_membership + LEFT JOIN users_user ON projects_membership.user_id = users_user.id + LEFT JOIN users_role ON users_role.id = projects_membership.role_id + WHERE projects_membership.project_id = {tbl}.id + ORDER BY complete_user_name) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_closed_milestones(queryset, as_field="closed_milestones_attr"): + """Attach a closed milestones counter to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the counter as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT COUNT(milestones_milestone.id) + FROM milestones_milestone + WHERE + milestones_milestone.project_id = {tbl}.id AND + milestones_milestone.closed = True + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_notify_policies(queryset, as_field="notify_policies_attr"): + """Attach a json notification policies representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the notification policies as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(notifications_notifypolicy)) + FROM notifications_notifypolicy + WHERE + notifications_notifypolicy.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_userstory_statuses(queryset, as_field="userstory_statuses_attr"): + """Attach a json userstory statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the userstory statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_userstorystatus)) + FROM projects_userstorystatus + WHERE + projects_userstorystatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_points(queryset, as_field="points_attr"): + """Attach a json points representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_points)) + FROM projects_points + WHERE + projects_points.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_task_statuses(queryset, as_field="task_statuses_attr"): + """Attach a json task statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the task statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_taskstatus)) + FROM projects_taskstatus + WHERE + projects_taskstatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_statuses(queryset, as_field="issue_statuses_attr"): + """Attach a json issue statuses representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the statuses as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_issuestatus)) + FROM projects_issuestatus + WHERE + projects_issuestatus.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_types(queryset, as_field="issue_types_attr"): + """Attach a json issue types representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the types as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_issuetype)) + FROM projects_issuetype + WHERE + projects_issuetype.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_priorities(queryset, as_field="priorities_attr"): + """Attach a json priorities representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the priorities as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_priority)) + FROM projects_priority + WHERE + projects_priority.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_severities(queryset, as_field="severities_attr"): + """Attach a json severities representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the severities as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(projects_severity)) + FROM projects_severity + WHERE + projects_severity.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_userstory_custom_attributes(queryset, as_field="userstory_custom_attributes_attr"): + """Attach a json userstory custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the userstory custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(custom_attributes_userstorycustomattribute)) + FROM custom_attributes_userstorycustomattribute + WHERE + custom_attributes_userstorycustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_task_custom_attributes(queryset, as_field="task_custom_attributes_attr"): + """Attach a json task custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the task custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(custom_attributes_taskcustomattribute)) + FROM custom_attributes_taskcustomattribute + WHERE + custom_attributes_taskcustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_issue_custom_attributes(queryset, as_field="issue_custom_attributes_attr"): + """Attach a json issue custom attributes representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the issue custom attributes as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(custom_attributes_issuecustomattribute)) + FROM custom_attributes_issuecustomattribute + WHERE + custom_attributes_issuecustomattribute.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_roles(queryset, as_field="roles_attr"): + """Attach a json roles representation to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the roles as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(users_role)) + FROM users_role + WHERE + users_role.project_id = {tbl}.id + """ + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_is_fan(queryset, user, as_field="is_fan_attr"): + """Attach a is fan boolean to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the boolean as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + if user is None or user.is_anonymous(): + sql = """SELECT false""" + else: + sql = """SELECT COUNT(likes_like.id) > 0 + FROM likes_like + INNER JOIN django_content_type + ON likes_like.content_type_id = django_content_type.id + WHERE + django_content_type.model = 'project' AND + django_content_type.app_label = 'projects' AND + likes_like.user_id = {user_id} AND + likes_like.object_id = {tbl}.id""" + + sql = sql.format(tbl=model._meta.db_table, user_id=user.id) + + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_my_role_permissions(queryset, user, as_field="my_role_permissions_attr"): + """Attach a permission array to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the permissions as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + if user is None or user.is_anonymous(): + sql = """SELECT '{}'""" + else: + sql = """SELECT users_role.permissions + FROM projects_membership + LEFT JOIN users_user ON projects_membership.user_id = users_user.id + LEFT JOIN users_role ON users_role.id = projects_membership.role_id + WHERE + projects_membership.project_id = {tbl}.id AND + users_user.id = {user_id}""" + + sql = sql.format(tbl=model._meta.db_table, user_id=user.id) + + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_private_projects_same_owner(queryset, user, as_field="private_projects_same_owner_attr"): + """Attach a private projects counter to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the counter as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + if user is None or user.is_anonymous(): + sql = """SELECT '0'""" + else: + sql = """SELECT COUNT(id) + FROM projects_project p_aux + WHERE + p_aux.is_private = True AND + p_aux.owner_id = {tbl}.owner_id""" + + sql = sql.format(tbl=model._meta.db_table, user_id=user.id) + + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_public_projects_same_owner(queryset, user, as_field="public_projects_same_owner_attr"): + """Attach a public projects counter to each object of the queryset. + + :param queryset: A Django projects queryset object. + :param as_field: Attach the counter as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + if user is None or user.is_anonymous(): + sql = """SELECT '0'""" + else: + sql = """SELECT COUNT(id) + FROM projects_project p_aux + WHERE + p_aux.is_private = False AND + p_aux.owner_id = {tbl}.owner_id""" + + sql = sql.format(tbl=model._meta.db_table, user_id=user.id) + + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None): + queryset = attach_members(queryset) + queryset = attach_closed_milestones(queryset) + queryset = attach_notify_policies(queryset) + queryset = attach_userstory_statuses(queryset) + queryset = attach_points(queryset) + queryset = attach_task_statuses(queryset) + queryset = attach_issue_statuses(queryset) + queryset = attach_issue_types(queryset) + queryset = attach_priorities(queryset) + queryset = attach_severities(queryset) + queryset = attach_userstory_custom_attributes(queryset) + queryset = attach_task_custom_attributes(queryset) + queryset = attach_issue_custom_attributes(queryset) + queryset = attach_roles(queryset) + queryset = attach_is_fan(queryset, user) + queryset = attach_my_role_permissions(queryset, user) + queryset = attach_private_projects_same_owner(queryset, user) + queryset = attach_public_projects_same_owner(queryset, user) + + return queryset diff --git a/taiga/users/models.py b/taiga/users/models.py index 264d1539..b9c60e4b 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -198,7 +198,7 @@ class User(AbstractBaseUser, PermissionsMixin): def _fill_cached_memberships(self): self._cached_memberships = {} - qs = self.memberships.prefetch_related("user", "project", "role") + qs = self.memberships.select_related("user", "project", "role") for membership in qs.all(): self._cached_memberships[membership.project.id] = membership diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 77e79542..949893b6 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -22,7 +22,7 @@ from django.apps import apps from taiga.base.utils import json from taiga.projects import choices as project_choices -from taiga.projects.serializers import ProjectDetailSerializer +from taiga.projects.serializers import ProjectSerializer from taiga.permissions.choices import MEMBERS_PERMISSIONS from tests import factories as f @@ -153,12 +153,12 @@ def test_project_update(client, data): data.project_owner ] - project_data = ProjectDetailSerializer(data.private_project2).data + project_data = ProjectSerializer(data.private_project2).data project_data["is_private"] = False results = helper_test_http_method(client, 'put', url, json.dumps(project_data), users) assert results == [401, 403, 403, 200] - project_data = ProjectDetailSerializer(data.blocked_project).data + project_data = ProjectSerializer(data.blocked_project).data project_data["is_private"] = False results = helper_test_http_method(client, 'put', blocked_url, json.dumps(project_data), users) assert results == [401, 403, 403, 451] diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 5cadc245..cc3b3cb1 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -625,7 +625,7 @@ def test_projects_user_order(client): #Testing user order url = reverse("projects-list") - url = "%s?member=%s&order_by=memberships__user_order" % (url, user.id) + url = "%s?member=%s&order_by=user_order" % (url, user.id) response = client.json.get(url) response_content = response.data assert response.status_code == 200 From 4864b9f95759398d6bc72b61327dd37fa99e8c96 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 29 Jun 2016 09:45:00 +0200 Subject: [PATCH 02/14] Splitting validators and serializers --- taiga/base/api/generics.py | 38 +- taiga/base/api/mixins.py | 39 +- taiga/base/api/serializers.py | 2 + taiga/base/api/settings.py | 2 + .../serializers.py => base/api/validators.py} | 13 +- taiga/base/fields.py | 84 ++- taiga/base/neighbors.py | 18 +- taiga/export_import/api.py | 5 +- taiga/projects/api.py | 56 +- taiga/projects/attachments/serializers.py | 9 +- taiga/projects/filters.py | 24 +- taiga/projects/history/api.py | 3 +- taiga/projects/history/models.py | 19 +- taiga/projects/history/serializers.py | 37 +- taiga/projects/history/services.py | 76 ++- taiga/projects/issues/api.py | 21 +- taiga/projects/issues/serializers.py | 106 +-- taiga/projects/issues/utils.py | 57 ++ taiga/projects/issues/validators.py | 43 ++ taiga/projects/milestones/api.py | 42 +- taiga/projects/milestones/serializers.py | 67 +- taiga/projects/milestones/utils.py | 44 +- taiga/projects/milestones/validators.py | 13 +- taiga/projects/mixins/serializers.py | 52 +- taiga/projects/notifications/mixins.py | 50 +- taiga/projects/notifications/utils.py | 21 +- taiga/projects/serializers.py | 610 +++++++----------- taiga/projects/tasks/api.py | 38 +- taiga/projects/tasks/serializers.py | 164 ++--- taiga/projects/tasks/utils.py | 39 ++ taiga/projects/tasks/validators.py | 38 +- taiga/projects/userstories/api.py | 61 +- taiga/projects/userstories/serializers.py | 298 +++------ taiga/projects/userstories/utils.py | 40 +- taiga/projects/userstories/validators.py | 81 +++ taiga/projects/utils.py | 4 +- taiga/projects/validators.py | 198 ++++++ taiga/projects/votes/mixins/serializers.py | 18 +- taiga/projects/votes/mixins/viewsets.py | 8 - taiga/projects/votes/utils.py | 26 +- taiga/projects/wiki/api.py | 9 +- taiga/projects/wiki/serializers.py | 33 +- taiga/projects/wiki/utils.py | 29 + taiga/projects/wiki/validators.py | 34 + taiga/searches/serializers.py | 63 +- taiga/searches/services.py | 11 +- taiga/timeline/api.py | 4 +- .../migrations/0005_auto_20160706_0723.py | 21 + taiga/timeline/models.py | 6 +- taiga/timeline/serializers.py | 18 +- taiga/timeline/service.py | 55 +- taiga/users/serializers.py | 36 +- taiga/webhooks/api.py | 2 + taiga/webhooks/serializers.py | 324 +++++----- taiga/webhooks/tasks.py | 1 - taiga/webhooks/validators.py | 26 + .../test_issues_resources.py | 54 +- .../test_milestones_resources.py | 62 +- .../test_projects_resource.py | 9 + .../test_tasks_resources.py | 143 ++-- .../test_userstories_resources.py | 122 ++-- .../test_webhooks_resources.py | 3 + tests/integration/test_milestones.py | 2 +- tests/integration/test_notifications.py | 43 +- tests/integration/test_userstories.py | 113 ++-- tests/integration/test_webhooks_issues.py | 5 +- tests/unit/test_serializer_mixins.py | 25 +- 67 files changed, 2077 insertions(+), 1740 deletions(-) rename taiga/{projects/likes/mixins/serializers.py => base/api/validators.py} (72%) create mode 100644 taiga/projects/issues/utils.py create mode 100644 taiga/projects/issues/validators.py create mode 100644 taiga/projects/tasks/utils.py create mode 100644 taiga/projects/wiki/utils.py create mode 100644 taiga/projects/wiki/validators.py create mode 100644 taiga/timeline/migrations/0005_auto_20160706_0723.py create mode 100644 taiga/webhooks/validators.py diff --git a/taiga/base/api/generics.py b/taiga/base/api/generics.py index 158d712d..31823945 100644 --- a/taiga/base/api/generics.py +++ b/taiga/base/api/generics.py @@ -62,6 +62,7 @@ class GenericAPIView(pagination.PaginationMixin, # or override `get_queryset()`/`get_serializer_class()`. queryset = None serializer_class = None + validator_class = None # This shortcut may be used instead of setting either or both # of the `queryset`/`serializer_class` attributes, although using @@ -79,6 +80,7 @@ class GenericAPIView(pagination.PaginationMixin, # The following attributes may be subject to change, # and should be considered private API. model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + model_validator_class = api_settings.DEFAULT_MODEL_VALIDATOR_CLASS ###################################### # These are pending deprecation... @@ -88,7 +90,7 @@ class GenericAPIView(pagination.PaginationMixin, slug_field = 'slug' allow_empty = True - def get_serializer_context(self): + def get_extra_context(self): """ Extra context provided to the serializer class. """ @@ -101,14 +103,24 @@ class GenericAPIView(pagination.PaginationMixin, def get_serializer(self, instance=None, data=None, files=None, many=False, partial=False): """ - Return the serializer instance that should be used for validating and - deserializing input, and for serializing output. + Return the serializer instance that should be used for deserializing + input, and for serializing output. """ serializer_class = self.get_serializer_class() - context = self.get_serializer_context() + context = self.get_extra_context() return serializer_class(instance, data=data, files=files, many=many, partial=partial, context=context) + def get_validator(self, instance=None, data=None, + files=None, many=False, partial=False): + """ + Return the validator instance that should be used for validating the + input, and for serializing output. + """ + validator_class = self.get_validator_class() + context = self.get_extra_context() + return validator_class(instance, data=data, files=files, + many=many, partial=partial, context=context) def filter_queryset(self, queryset, filter_backends=None): """ @@ -119,7 +131,7 @@ class GenericAPIView(pagination.PaginationMixin, method if you want to apply the configured filtering backend to the default queryset. """ - #NOTE TAIGA: Added filter_backends to overwrite the default behavior. + # NOTE TAIGA: Added filter_backends to overwrite the default behavior. backends = filter_backends or self.get_filter_backends() for backend in backends: @@ -160,6 +172,22 @@ class GenericAPIView(pagination.PaginationMixin, model = self.model return DefaultSerializer + def get_validator_class(self): + validator_class = self.validator_class + serializer_class = self.get_serializer_class() + + # Situations where the validator is the rest framework serializer + if validator_class is None and serializer_class is not None: + return serializer_class + + if validator_class is not None: + return validator_class + + class DefaultValidator(self.model_validator_class): + class Meta: + model = self.model + return DefaultValidator + def get_queryset(self): """ Get the list of items for this view. diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py index 89af6984..861d77ec 100644 --- a/taiga/base/api/mixins.py +++ b/taiga/base/api/mixins.py @@ -57,6 +57,7 @@ from .utils import get_object_or_404 from .. import exceptions as exc from ..decorators import model_pk_lock + def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): """ Given a model instance, and an optional pk and slug field, @@ -89,19 +90,21 @@ class CreateModelMixin: Create a model instance. """ def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.DATA, files=request.FILES) + validator = self.get_validator(data=request.DATA, files=request.FILES) - if serializer.is_valid(): - self.check_permissions(request, 'create', serializer.object) + if validator.is_valid(): + self.check_permissions(request, 'create', validator.object) - self.pre_save(serializer.object) - self.pre_conditions_on_save(serializer.object) - self.object = serializer.save(force_insert=True) + self.pre_save(validator.object) + self.pre_conditions_on_save(validator.object) + self.object = validator.save(force_insert=True) self.post_save(self.object, created=True) + instance = self.get_queryset().get(id=self.object.id) + serializer = self.get_serializer(instance) headers = self.get_success_headers(serializer.data) return response.Created(serializer.data, headers=headers) - return response.BadRequest(serializer.errors) + return response.BadRequest(validator.errors) def get_success_headers(self, data): try: @@ -171,28 +174,32 @@ class UpdateModelMixin: if self.object is None: raise Http404 - serializer = self.get_serializer(self.object, data=request.DATA, - files=request.FILES, partial=partial) + validator = self.get_validator(self.object, data=request.DATA, + files=request.FILES, partial=partial) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + if not validator.is_valid(): + return response.BadRequest(validator.errors) # Hooks try: - self.pre_save(serializer.object) - self.pre_conditions_on_save(serializer.object) + self.pre_save(validator.object) + self.pre_conditions_on_save(validator.object) except ValidationError as err: # full_clean on model instance may be called in pre_save, # so we have to handle eventual errors. return response.BadRequest(err.message_dict) if self.object is None: - self.object = serializer.save(force_insert=True) + self.object = validator.save(force_insert=True) self.post_save(self.object, created=True) + instance = self.get_queryset().get(id=self.object.id) + serializer = self.get_serializer(instance) return response.Created(serializer.data) - self.object = serializer.save(force_update=True) + self.object = validator.save(force_update=True) self.post_save(self.object, created=False) + instance = self.get_queryset().get(id=self.object.id) + serializer = self.get_serializer(instance) return response.Ok(serializer.data) def partial_update(self, request, *args, **kwargs): @@ -251,7 +258,7 @@ class BlockeableModelMixin: raise NotImplementedError("is_blocked must be overridden") def pre_conditions_blocked(self, obj): - #Raises permission exception + # Raises permission exception if obj is not None and self.is_blocked(obj): raise exc.Blocked(_("Blocked element")) diff --git a/taiga/base/api/serializers.py b/taiga/base/api/serializers.py index 601c1753..82565b26 100644 --- a/taiga/base/api/serializers.py +++ b/taiga/base/api/serializers.py @@ -1229,5 +1229,7 @@ class LightSerializer(serpy.Serializer): kwargs.pop("partial", None) kwargs.pop("files", None) context = kwargs.pop("context", {}) + view = kwargs.pop("view", {}) super().__init__(*args, **kwargs) self.context = context + self.view = view diff --git a/taiga/base/api/settings.py b/taiga/base/api/settings.py index 1a3d01ba..75d204c9 100644 --- a/taiga/base/api/settings.py +++ b/taiga/base/api/settings.py @@ -98,6 +98,8 @@ DEFAULTS = { # Genric view behavior "DEFAULT_MODEL_SERIALIZER_CLASS": "taiga.base.api.serializers.ModelSerializer", + "DEFAULT_MODEL_VALIDATOR_CLASS": + "taiga.base.api.validators.ModelValidator", "DEFAULT_FILTER_BACKENDS": (), # Throttling diff --git a/taiga/projects/likes/mixins/serializers.py b/taiga/base/api/validators.py similarity index 72% rename from taiga/projects/likes/mixins/serializers.py rename to taiga/base/api/validators.py index 84d63b4e..3a8d6922 100644 --- a/taiga/projects/likes/mixins/serializers.py +++ b/taiga/base/api/validators.py @@ -16,15 +16,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.base.api import serializers +from . import serializers -class FanResourceSerializerMixin(serializers.ModelSerializer): - is_fan = serializers.SerializerMethodField("get_is_fan") +class Validator(serializers.Serializer): + pass - def get_is_fan(self, obj): - if "request" in self.context: - user = self.context["request"].user - return user.is_authenticated() and user.is_fan(obj) - return False +class ModelValidator(serializers.ModelSerializer): + pass diff --git a/taiga/base/fields.py b/taiga/base/fields.py index 5e5c4b5a..f0cf4ee2 100644 --- a/taiga/base/fields.py +++ b/taiga/base/fields.py @@ -20,11 +20,13 @@ from django.forms import widgets from django.utils.translation import ugettext as _ from taiga.base.api import serializers +import serpy #################################################################### -## Serializer fields +# Serializer fields #################################################################### + class JsonField(serializers.WritableField): """ Json objects serializer. @@ -38,40 +40,6 @@ class JsonField(serializers.WritableField): return data -class I18NJsonField(JsonField): - """ - Json objects serializer. - """ - widget = widgets.Textarea - - def __init__(self, i18n_fields=(), *args, **kwargs): - super(I18NJsonField, self).__init__(*args, **kwargs) - self.i18n_fields = i18n_fields - - def translate_values(self, d): - i18n_d = {} - if d is None: - return d - - for key, value in d.items(): - if isinstance(value, dict): - i18n_d[key] = self.translate_values(value) - - if key in self.i18n_fields: - if isinstance(value, list): - i18n_d[key] = [e is not None and _(str(e)) or e for e in value] - if isinstance(value, str): - i18n_d[key] = value is not None and _(value) or value - else: - i18n_d[key] = value - - return i18n_d - - def to_native(self, obj): - i18n_obj = self.translate_values(obj) - return i18n_obj - - class PgArrayField(serializers.WritableField): """ PgArray objects serializer. @@ -104,3 +72,49 @@ class WatchersField(serializers.WritableField): def from_native(self, data): return data + + +class Field(serpy.Field): + pass + + +class MethodField(serpy.MethodField): + pass + + +class I18NField(serpy.Field): + def to_value(self, value): + ret = super(I18NField, self).to_value(value) + return _(ret) + + +class I18NJsonField(serpy.Field): + """ + Json objects serializer. + """ + def __init__(self, i18n_fields=(), *args, **kwargs): + super(I18NJsonField, self).__init__(*args, **kwargs) + self.i18n_fields = i18n_fields + + def translate_values(self, d): + i18n_d = {} + if d is None: + return d + + for key, value in d.items(): + if isinstance(value, dict): + i18n_d[key] = self.translate_values(value) + + if key in self.i18n_fields: + if isinstance(value, list): + i18n_d[key] = [e is not None and _(str(e)) or e for e in value] + if isinstance(value, str): + i18n_d[key] = value is not None and _(value) or value + else: + i18n_d[key] = value + + return i18n_d + + def to_native(self, obj): + i18n_obj = self.translate_values(obj) + return i18n_obj diff --git a/taiga/base/neighbors.py b/taiga/base/neighbors.py index a57d2eeb..c8733ade 100644 --- a/taiga/base/neighbors.py +++ b/taiga/base/neighbors.py @@ -23,6 +23,7 @@ from django.db import connection from django.core.exceptions import ObjectDoesNotExist from django.db.models.sql.datastructures import EmptyResultSet from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField Neighbor = namedtuple("Neighbor", "left right") @@ -71,7 +72,6 @@ def get_neighbors(obj, results_set=None): if row is None: return Neighbor(None, None) - obj_position = row[1] - 1 left_object_id = row[2] right_object_id = row[3] @@ -88,13 +88,19 @@ def get_neighbors(obj, results_set=None): return Neighbor(left, right) -class NeighborsSerializerMixin: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["neighbors"] = serializers.SerializerMethodField("get_neighbors") +class NeighborSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + + +class NeighborsSerializerMixin(serializers.LightSerializer): + neighbors = MethodField() def serialize_neighbor(self, neighbor): - raise NotImplementedError + if neighbor: + return NeighborSerializer(neighbor).data + return None def get_neighbors(self, obj): view, request = self.context.get("view", None), self.context.get("request", None) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index d8453ad5..eaff499d 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -34,6 +34,7 @@ from taiga.base import exceptions as exc from taiga.base import response from taiga.base.api.mixins import CreateModelMixin from taiga.base.api.viewsets import GenericViewSet +from taiga.projects import utils as project_utils from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task @@ -366,5 +367,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi return response.BadRequest({"error": e.message, "details": e.errors}) else: # On Success - response_data = ProjectSerializer(project).data + project_from_qs = project_utils.attach_extra_info(Project.objects.all()).get(id=project.id) + response_data = ProjectSerializer(project_from_qs).data + return response.Created(response_data) diff --git a/taiga/projects/api.py b/taiga/projects/api.py index c6fbbe0d..c441e419 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -23,9 +23,6 @@ from dateutil.relativedelta import relativedelta from django.apps import apps from django.conf import settings from django.core.exceptions import ValidationError -from django.db.models import signals, Prefetch -from django.db.models import Value as V -from django.db.models.functions import Coalesce from django.http import Http404 from django.utils.translation import ugettext as _ from django.utils import timezone @@ -45,8 +42,7 @@ from taiga.permissions import services as permissions_services from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.issues.models import Issue from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin -from taiga.projects.notifications.models import NotifyPolicy -from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin +from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.notifications.choices import NotifyLevel from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin from taiga.projects.mixins.ordering import BulkUpdateOrderMixin @@ -54,21 +50,24 @@ from taiga.projects.tasks.models import Task from taiga.projects.tagging.api import TagsColorsResourceMixin from taiga.projects.userstories.models import UserStory, RolePoints -from taiga.users import services as users_services from . import filters as project_filters from . import models from . import permissions from . import serializers +from . import validators from . import services from . import utils as project_utils ###################################################### -## Project +# Project ###################################################### -class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMixin, BlockeableDeleteMixin, + +class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, + BlockeableSaveMixin, BlockeableDeleteMixin, TagsColorsResourceMixin, ModelCrudViewSet): + validator_class = validators.ProjectValidator queryset = models.Project.objects.all() permission_classes = (permissions.ProjectPermission, ) filter_backends = (project_filters.UserOrderFilterBackend, @@ -132,12 +131,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix def get_serializer_class(self): if self.action == "list": - return serializers.LightProjectSerializer + return serializers.ProjectSerializer - if self.action in ["retrieve", "by_slug"]: - return serializers.LightProjectDetailSerializer - - return serializers.ProjectSerializer + return serializers.ProjectDetailSerializer @detail_route(methods=["POST"]) def change_logo(self, request, *args, **kwargs): @@ -200,11 +196,11 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix if self.request.user.is_anonymous(): return response.Unauthorized() - serializer = serializers.UpdateProjectOrderBulkSerializer(data=request.DATA, many=True) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.UpdateProjectOrderBulkValidator(data=request.DATA, many=True) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data services.update_projects_order_in_bulk(data, "user_order", request.user) return response.NoContent(data=None) @@ -346,7 +342,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, BlockeableSaveMix return response.BadRequest(_("The user must be already a project member")) reason = request.DATA.get('reason', None) - transfer_token = services.start_project_transfer(project, user, reason) + services.start_project_transfer(project, user, reason) return response.Ok() @detail_route(methods=["POST"]) @@ -455,6 +451,7 @@ class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, model = models.Points serializer_class = serializers.PointsSerializer + validator_class = validators.PointsValidator permission_classes = (permissions.PointsPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ('project',) @@ -471,6 +468,7 @@ class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, model = models.UserStoryStatus serializer_class = serializers.UserStoryStatusSerializer + validator_class = validators.UserStoryStatusValidator permission_classes = (permissions.UserStoryStatusPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ('project',) @@ -487,6 +485,7 @@ class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, model = models.TaskStatus serializer_class = serializers.TaskStatusSerializer + validator_class = validators.TaskStatusValidator permission_classes = (permissions.TaskStatusPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -503,6 +502,7 @@ class SeverityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, model = models.Severity serializer_class = serializers.SeveritySerializer + validator_class = validators.SeverityValidator permission_classes = (permissions.SeverityPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -518,6 +518,7 @@ class PriorityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, ModelCrudViewSet, BulkUpdateOrderMixin): model = models.Priority serializer_class = serializers.PrioritySerializer + validator_class = validators.PriorityValidator permission_classes = (permissions.PriorityPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -533,6 +534,7 @@ class IssueTypeViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, ModelCrudViewSet, BulkUpdateOrderMixin): model = models.IssueType serializer_class = serializers.IssueTypeSerializer + validator_class = validators.IssueTypeValidator permission_classes = (permissions.IssueTypePermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -548,6 +550,7 @@ class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, ModelCrudViewSet, BulkUpdateOrderMixin): model = models.IssueStatus serializer_class = serializers.IssueStatusSerializer + validator_class = validators.IssueStatusValidator permission_classes = (permissions.IssueStatusPermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -566,6 +569,7 @@ class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin, class ProjectTemplateViewSet(ModelCrudViewSet): model = models.ProjectTemplate serializer_class = serializers.ProjectTemplateSerializer + validator_class = validators.ProjectTemplateValidator permission_classes = (permissions.ProjectTemplatePermission,) def get_queryset(self): @@ -579,7 +583,9 @@ class ProjectTemplateViewSet(ModelCrudViewSet): 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,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project", "role") @@ -604,6 +610,12 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): 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, @@ -618,11 +630,11 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): - serializer = serializers.MembersBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.MembersBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data project = models.Project.objects.get(id=data["project_id"]) invitation_extra_text = data.get("invitation_extra_text", None) self.check_permissions(request, 'bulk_create', project) diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py index 6c5ee05b..45e6be45 100644 --- a/taiga/projects/attachments/serializers.py +++ b/taiga/projects/attachments/serializers.py @@ -19,14 +19,12 @@ from django.conf import settings from taiga.base.api import serializers +from taiga.base.fields import MethodField from taiga.base.utils.thumbnails import get_thumbnail_url from . import services from . import models -import json -import serpy - class AttachmentSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField("get_url") @@ -43,12 +41,11 @@ class AttachmentSerializer(serializers.ModelSerializer): def get_url(self, obj): return obj.attached_file.url - def get_thumbnail_card_url(self, obj): return services.get_card_image_thumbnail_url(obj) -class ListBasicAttachmentsInfoSerializerMixin(serpy.Serializer): +class BasicAttachmentsInfoSerializerMixin(serializers.LightSerializer): """ Assumptions: - The queryset has an attribute called "include_attachments" indicating if the attachments array should contain information @@ -56,7 +53,7 @@ class ListBasicAttachmentsInfoSerializerMixin(serpy.Serializer): - The method attach_basic_attachments has been used to include the necessary json data about the attachments in the "attachments_attr" column """ - attachments = serpy.MethodField() + attachments = MethodField() def get_attachments(self, obj): include_attachments = getattr(obj, "include_attachments", False) diff --git a/taiga/projects/filters.py b/taiga/projects/filters.py index cbb692b8..fe720f97 100644 --- a/taiga/projects/filters.py +++ b/taiga/projects/filters.py @@ -97,12 +97,12 @@ class QFilterBackend(FilterBackend): tsquery = "to_tsquery('english_nostop', %s)" tsquery_params = [to_tsquery(q)] tsvector = """ - setweight(to_tsvector('english_nostop', - coalesce(projects_project.name, '')), 'A') || - setweight(to_tsvector('english_nostop', - coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') || - setweight(to_tsvector('english_nostop', - coalesce(projects_project.description, '')), 'C') + setweight(to_tsvector('english_nostop', + coalesce(projects_project.name, '')), 'A') || + setweight(to_tsvector('english_nostop', + coalesce(inmutable_array_to_string(projects_project.tags), '')), 'B') || + setweight(to_tsvector('english_nostop', + coalesce(projects_project.description, '')), 'C') """ select = { @@ -111,7 +111,7 @@ class QFilterBackend(FilterBackend): } select_params = tsquery_params where = ["{tsvector} @@ {tsquery}".format(tsquery=tsquery, - tsvector=tsvector),] + tsvector=tsvector), ] params = tsquery_params order_by = ["-rank", ] @@ -142,11 +142,11 @@ class UserOrderFilterBackend(FilterBackend): model = queryset.model sql = """SELECT projects_membership.user_order - FROM projects_membership - WHERE - projects_membership.project_id = {tbl}.id AND - projects_membership.user_id = {user_id} - """ + FROM projects_membership + WHERE + projects_membership.project_id = {tbl}.id AND + projects_membership.user_id = {user_id} + """ sql = sql.format(tbl=model._meta.db_table, user_id=request.user.id) queryset = queryset.extra(select={"user_order": sql}) diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py index a4c8199e..2119239a 100644 --- a/taiga/projects/history/api.py +++ b/taiga/projects/history/api.py @@ -23,7 +23,6 @@ from django.utils import timezone from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import ReadOnlyListViewSet -from taiga.base.api.utils import get_object_or_404 from taiga.mdrender.service import render as mdrender from . import permissions @@ -38,7 +37,7 @@ class HistoryViewSet(ReadOnlyListViewSet): def get_content_type(self): app_name, model = self.content_type.split(".", 1) - return get_object_or_404(ContentType, app_label=app_name, model=model) + return ContentType.objects.get_by_natural_key(app_name, model) def get_queryset(self): ct = self.get_content_type() diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index 558c5c25..d5c023a9 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -33,7 +33,8 @@ from taiga.base.utils.diff import make_diff as make_diff_from_dicts # This keys has been removed from freeze_impl so we can have objects where the # previous diff has value for the attribute and we want to prevent their propagation -IGNORE_DIFF_FIELDS = [ "watchers", "description_diff", "content_diff", "blocked_note_diff"] +IGNORE_DIFF_FIELDS = ["watchers", "description_diff", "content_diff", "blocked_note_diff"] + def _generate_uuid(): return str(uuid.uuid1()) @@ -92,15 +93,15 @@ class HistoryEntry(models.Model): @cached_property def is_change(self): - return self.type == HistoryType.change + return self.type == HistoryType.change @cached_property def is_create(self): - return self.type == HistoryType.create + return self.type == HistoryType.create @cached_property def is_delete(self): - return self.type == HistoryType.delete + return self.type == HistoryType.delete @property def owner(self): @@ -185,7 +186,7 @@ class HistoryEntry(models.Model): role_name = resolve_value("roles", role_id) oldpoint_id = pointsold.get(role_id, None) points[role_name] = [resolve_value("points", oldpoint_id), - resolve_value("points", point_id)] + resolve_value("points", point_id)] # Process that removes points entries with # duplicate value. @@ -204,8 +205,8 @@ class HistoryEntry(models.Model): "deleted": [], } - oldattachs = {x["id"]:x for x in self.diff["attachments"][0]} - newattachs = {x["id"]:x for x in self.diff["attachments"][1]} + oldattachs = {x["id"]: x for x in self.diff["attachments"][0]} + newattachs = {x["id"]: x for x in self.diff["attachments"][1]} for aid in set(tuple(oldattachs.keys()) + tuple(newattachs.keys())): if aid in oldattachs and aid in newattachs: @@ -235,8 +236,8 @@ class HistoryEntry(models.Model): "deleted": [], } - oldcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][0] or []} - newcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][1] or []} + oldcustattrs = {x["id"]: x for x in self.diff["custom_attributes"][0] or []} + newcustattrs = {x["id"]: x for x in self.diff["custom_attributes"][1] or []} for aid in set(tuple(oldcustattrs.keys()) + tuple(newcustattrs.keys())): if aid in oldcustattrs and aid in newcustattrs: diff --git a/taiga/projects/history/serializers.py b/taiga/projects/history/serializers.py index f1a10481..8407810f 100644 --- a/taiga/projects/history/serializers.py +++ b/taiga/projects/history/serializers.py @@ -17,28 +17,31 @@ # along with this program. If not, see . from taiga.base.api import serializers -from taiga.base.fields import JsonField, I18NJsonField +from taiga.base.fields import I18NJsonField, Field, MethodField from taiga.users.services import get_photo_or_gravatar_url -from . import models + +HISTORY_ENTRY_I18N_FIELDS = ("points", "status", "severity", "priority", "type") -HISTORY_ENTRY_I18N_FIELDS=("points", "status", "severity", "priority", "type") - - -class HistoryEntrySerializer(serializers.ModelSerializer): - diff = JsonField() - snapshot = JsonField() - values = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS) - values_diff = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS) - user = serializers.SerializerMethodField("get_user") - delete_comment_user = JsonField() - comment_versions = JsonField() - - class Meta: - model = models.HistoryEntry - exclude = ("comment_versions",) +class HistoryEntrySerializer(serializers.LightSerializer): + id = Field() + user = MethodField() + created_at = Field() + type = Field() + key = Field() + diff = Field() + snapshot = Field() + values = Field() + values_diff = I18NJsonField() + comment = I18NJsonField() + comment_html = Field() + delete_comment_date = Field() + delete_comment_user = Field() + edit_comment_date = Field() + is_hidden = Field() + is_snapshot = Field() def get_user(self, entry): user = {"pk": None, "username": None, "name": None, "photo": None, "is_active": False} diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 71b5bcf8..764cca39 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -34,12 +34,9 @@ from collections import namedtuple from copy import deepcopy from functools import partial from functools import wraps -from functools import lru_cache from django.conf import settings from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType -from django.core.paginator import Paginator, InvalidPage from django.apps import apps from django.db import transaction as tx from django_pglocks import advisory_lock @@ -50,6 +47,21 @@ from taiga.base.utils.diff import make_diff as make_diff_from_dicts from .models import HistoryType +# Freeze implementatitions +from .freeze_impl import project_freezer +from .freeze_impl import milestone_freezer +from .freeze_impl import userstory_freezer +from .freeze_impl import issue_freezer +from .freeze_impl import task_freezer +from .freeze_impl import wikipage_freezer + + +from .freeze_impl import project_values +from .freeze_impl import milestone_values +from .freeze_impl import userstory_values +from .freeze_impl import issue_values +from .freeze_impl import task_values +from .freeze_impl import wikipage_values # Type that represents a freezed object FrozenObj = namedtuple("FrozenObj", ["key", "snapshot"]) @@ -71,7 +83,7 @@ _not_important_fields = { log = logging.getLogger("taiga.history") -def make_key_from_model_object(obj:object) -> str: +def make_key_from_model_object(obj: object) -> str: """ Create unique key from model instance. """ @@ -79,7 +91,7 @@ def make_key_from_model_object(obj:object) -> str: return "{0}:{1}".format(tn, obj.pk) -def get_model_from_key(key:str) -> object: +def get_model_from_key(key: str) -> object: """ Get model from key """ @@ -87,7 +99,7 @@ def get_model_from_key(key:str) -> object: return apps.get_model(class_name) -def get_pk_from_key(key:str) -> object: +def get_pk_from_key(key: str) -> object: """ Get pk from key """ @@ -95,7 +107,7 @@ def get_pk_from_key(key:str) -> object: return pk -def get_instance_from_key(key:str) -> object: +def get_instance_from_key(key: str) -> object: """ Get instance from key """ @@ -109,7 +121,7 @@ def get_instance_from_key(key:str) -> object: return None -def register_values_implementation(typename:str, fn=None): +def register_values_implementation(typename: str, fn=None): """ Register values implementation for specified typename. This function can be used as decorator. @@ -128,7 +140,7 @@ def register_values_implementation(typename:str, fn=None): return _wrapper -def register_freeze_implementation(typename:str, fn=None): +def register_freeze_implementation(typename: str, fn=None): """ Register freeze implementation for specified typename. This function can be used as decorator. @@ -149,7 +161,7 @@ def register_freeze_implementation(typename:str, fn=None): # Low level api -def freeze_model_instance(obj:object) -> FrozenObj: +def freeze_model_instance(obj: object) -> FrozenObj: """ Creates a new frozen object from model instance. @@ -179,7 +191,7 @@ def freeze_model_instance(obj:object) -> FrozenObj: return FrozenObj(key, snapshot) -def is_hidden_snapshot(obj:FrozenDiff) -> bool: +def is_hidden_snapshot(obj: FrozenDiff) -> bool: """ Check if frozen object is considered hidden or not. @@ -199,7 +211,7 @@ def is_hidden_snapshot(obj:FrozenDiff) -> bool: return False -def make_diff(oldobj:FrozenObj, newobj:FrozenObj) -> FrozenDiff: +def make_diff(oldobj: FrozenObj, newobj: FrozenObj) -> FrozenDiff: """ Compute a diff between two frozen objects. """ @@ -217,7 +229,7 @@ def make_diff(oldobj:FrozenObj, newobj:FrozenObj) -> FrozenDiff: return FrozenDiff(newobj.key, diff, newobj.snapshot) -def make_diff_values(typename:str, fdiff:FrozenDiff) -> dict: +def make_diff_values(typename: str, fdiff: FrozenDiff) -> dict: """ Given a typename and diff, build a values dict for it. If no implementation found for typename, warnig is raised in @@ -242,7 +254,7 @@ def _rebuild_snapshot_from_diffs(keysnapshot, partials): return result -def get_last_snapshot_for_key(key:str) -> FrozenObj: +def get_last_snapshot_for_key(key: str) -> FrozenObj: entry_model = apps.get_model("history", "HistoryEntry") # Search last snapshot @@ -271,17 +283,16 @@ def get_last_snapshot_for_key(key:str) -> FrozenObj: # Public api -def get_modified_fields(obj:object, last_modifications): +def get_modified_fields(obj: object, last_modifications): """ Get the modified fields for an object through his last modifications """ key = make_key_from_model_object(obj) entry_model = apps.get_model("history", "HistoryEntry") history_entries = (entry_model.objects - .filter(key=key) - .order_by("-created_at") - .values_list("diff", flat=True) - [0:last_modifications]) + .filter(key=key) + .order_by("-created_at") + .values_list("diff", flat=True)[0:last_modifications]) modified_fields = [] for history_entry in history_entries: @@ -291,7 +302,7 @@ def get_modified_fields(obj:object, last_modifications): @tx.atomic -def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): +def take_snapshot(obj: object, *, comment: str="", user=None, delete: bool=False): """ Given any model instance with registred content type, create new history entry of "change" type. @@ -301,7 +312,7 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): """ key = make_key_from_model_object(obj) - with advisory_lock(key) as acquired_key_lock: + with advisory_lock(key): typename = get_typename_for_model_class(obj.__class__) new_fobj = freeze_model_instance(obj) @@ -327,8 +338,8 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): # If diff and comment are empty, do # not create empty history entry if (not fdiff.diff and not comment - and old_fobj is not None - and entry_type != HistoryType.delete): + and old_fobj is not None + and entry_type != HistoryType.delete): return None @@ -358,7 +369,7 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): # High level query api -def get_history_queryset_by_model_instance(obj:object, types=(HistoryType.change,), +def get_history_queryset_by_model_instance(obj: object, types=(HistoryType.change,), include_hidden=False): """ Get one page of history for specified object. @@ -377,20 +388,12 @@ def prefetch_owners_in_history_queryset(qs): user_ids = [u["pk"] for u in qs.values_list("user", flat=True)] users = get_user_model().objects.filter(id__in=user_ids) users_by_id = {u.id: u for u in users} - for history_entry in qs: + for history_entry in qs: history_entry.prefetch_owner(users_by_id.get(history_entry.user["pk"], None)) return qs -# Freeze implementatitions -from .freeze_impl import project_freezer -from .freeze_impl import milestone_freezer -from .freeze_impl import userstory_freezer -from .freeze_impl import issue_freezer -from .freeze_impl import task_freezer -from .freeze_impl import wikipage_freezer - register_freeze_implementation("projects.project", project_freezer) register_freeze_implementation("milestones.milestone", milestone_freezer,) register_freeze_implementation("userstories.userstory", userstory_freezer) @@ -398,13 +401,6 @@ register_freeze_implementation("issues.issue", issue_freezer) register_freeze_implementation("tasks.task", task_freezer) register_freeze_implementation("wiki.wikipage", wikipage_freezer) -from .freeze_impl import project_values -from .freeze_impl import milestone_values -from .freeze_impl import userstory_values -from .freeze_impl import issue_values -from .freeze_impl import task_values -from .freeze_impl import wikipage_values - register_values_implementation("projects.project", project_values) register_values_implementation("milestones.milestone", milestone_values) register_values_implementation("userstories.userstory", userstory_values) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index 8da13476..f204fc13 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -34,14 +34,18 @@ from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin +from .utils import attach_extra_info + from . import models from . import services from . import permissions from . import serializers +from . import validators class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): + validator_class = validators.IssueValidator queryset = models.Issue.objects.all() permission_classes = (permissions.IssuePermission, ) filter_backends = (filters.CanViewIssuesFilterBackend, @@ -145,8 +149,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W def get_queryset(self): qs = super().get_queryset() qs = qs.select_related("owner", "assigned_to", "status", "project") - qs = self.attach_votes_attrs_to_queryset(qs) - return self.attach_watchers_attrs_to_queryset(qs) + qs = attach_extra_info(qs, user=self.request.user) + return qs def pre_save(self, obj): if not obj.id: @@ -181,8 +185,7 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W def by_ref(self, request): ref = request.QUERY_PARAMS.get("ref", None) project_id = request.QUERY_PARAMS.get("project", None) - issue = get_object_or_404(models.Issue, ref=ref, project_id=project_id) - return self.retrieve(request, pk=issue.pk) + return self.retrieve(request, project_id=project_id, ref=ref) @list_route(methods=["GET"]) def filters_data(self, request, *args, **kwargs): @@ -224,9 +227,9 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): - serializer = serializers.IssuesBulkSerializer(data=request.DATA) - if serializer.is_valid(): - data = serializer.data + validator = validators.IssuesBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data project = Project.objects.get(pk=data["project_id"]) self.check_permissions(request, 'bulk_create', project) if project.blocked_code is not None: @@ -237,11 +240,13 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W status=project.default_issue_status, severity=project.default_severity, priority=project.default_priority, type=project.default_issue_type, callback=self.post_save, precall=self.pre_save) + + issues = self.get_queryset().filter(id__in=[i.id for i in issues]) issues_serialized = self.get_serializer_class()(issues, many=True) return response.Ok(data=issues_serialized.data) - return response.BadRequest(serializer.errors) + return response.BadRequest(validator.errors) class IssueVotersViewSet(VotersViewSetMixin, ModelListViewSet): diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 099171a1..2b773b81 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -17,56 +17,52 @@ # along with this program. If not, see . from taiga.base.api import serializers -from taiga.base.fields import PgArrayField +from taiga.base.fields import Field, MethodField from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender -from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin -from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer -from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer -from taiga.projects.notifications.validators import WatchersValidator -from taiga.projects.tagging.fields import TagsAndTagsColorsField -from taiga.projects.serializers import BasicIssueStatusSerializer -from taiga.projects.validators import ProjectExistsValidator +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin -from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin - -from taiga.users.serializers import UserBasicInfoSerializer - -from . import models - -import serpy -class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, - serializers.ModelSerializer): - tags = TagsAndTagsColorsField(default=[], required=False) - external_reference = PgArrayField(required=False) - is_closed = serializers.Field(source="is_closed") - comment = serializers.SerializerMethodField("get_comment") - generated_user_stories = serializers.SerializerMethodField("get_generated_user_stories") - blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") - description_html = serializers.SerializerMethodField("get_description_html") - status_extra_info = BasicIssueStatusSerializer(source="status", required=False, read_only=True) - assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True) - owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True) +class IssueListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + severity = Field(attr="severity_id") + priority = Field(attr="priority_id") + type = Field(attr="type_id") + milestone = Field(attr="milestone_id") + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + external_reference = Field() + version = Field() + watchers = Field() + tags = Field() + is_closed = Field() - class Meta: - model = models.Issue - read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + +class IssueSerializer(IssueListSerializer): + comment = MethodField() + generated_user_stories = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() def get_comment(self, obj): # NOTE: This method and field is necessary to historical comments work return "" def get_generated_user_stories(self, obj): - return [{ - "id": us.id, - "ref": us.ref, - "subject": us.subject, - } for us in obj.generated_user_stories.all()] + assert hasattr(obj, "generated_user_stories_attr"), "instance must have a generated_user_stories_attr attribute" + return obj.generated_user_stories_attr def get_blocked_note_html(self, obj): return mdrender(obj.project, obj.blocked_note) @@ -75,39 +71,5 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa return mdrender(obj.project, obj.description) -class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, - ListStatusExtraInfoSerializerMixin, serializers.LightSerializer): - id = serpy.Field() - ref = serpy.Field() - severity = serpy.Field(attr="severity_id") - priority = serpy.Field(attr="priority_id") - type = serpy.Field(attr="type_id") - milestone = serpy.Field(attr="milestone_id") - project = serpy.Field(attr="project_id") - created_date = serpy.Field() - modified_date = serpy.Field() - finished_date = serpy.Field() - subject = serpy.Field() - external_reference = serpy.Field() - version = serpy.Field() - watchers = serpy.Field() - - class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer): - def serialize_neighbor(self, neighbor): - if neighbor: - return NeighborIssueSerializer(neighbor).data - return None - - -class NeighborIssueSerializer(serializers.ModelSerializer): - class Meta: - model = models.Issue - fields = ("id", "ref", "subject") - depth = 0 - - -class IssuesBulkSerializer(ProjectExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - bulk_issues = serializers.CharField() + pass diff --git a/taiga/projects/issues/utils.py b/taiga/projects/issues/utils.py new file mode 100644 index 00000000..2053d923 --- /dev/null +++ b/taiga/projects/issues/utils.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# 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 +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_generated_user_stories(queryset, as_field="generated_user_stories_attr"): + """Attach generated user stories json column to each object of the queryset. + + :param queryset: A Django issues queryset object. + :param as_field: Attach the generated user stories as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + userstories_userstory.id, + userstories_userstory.ref, + userstories_userstory.subject + FROM userstories_userstory + WHERE generated_from_issue_id = {tbl}.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset + + +def attach_extra_info(queryset, user=None): + queryset = attach_generated_user_stories(queryset) + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/issues/validators.py b/taiga/projects/issues/validators.py new file mode 100644 index 00000000..4c900c15 --- /dev/null +++ b/taiga/projects/issues/validators.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.fields import PgArrayField +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator + +from . import models + + +class IssueValidator(WatchersValidator, EditableWatchedResourceSerializer, + validators.ModelValidator): + + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Issue + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class IssuesBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_issues = serializers.CharField() diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index 1520f2c7..2e0047fc 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -17,7 +17,6 @@ # along with this program. If not, see . from django.apps import apps -from django.db.models import Prefetch from taiga.base import filters from taiga.base import response @@ -31,13 +30,9 @@ from taiga.base.utils.db import get_object_or_none from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.history.mixins import HistoryResourceMixin -from taiga.projects.votes.utils import attach_total_voters_to_queryset -from taiga.projects.votes.utils import attach_is_voter_to_queryset -from taiga.projects.notifications.utils import attach_watchers_to_queryset -from taiga.projects.notifications.utils import attach_is_watcher_to_queryset -from taiga.projects.userstories import utils as userstories_utils from . import serializers +from . import validators from . import models from . import permissions from . import utils as milestones_utils @@ -47,6 +42,8 @@ import datetime class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): + serializer_class = serializers.MilestoneSerializer + validator_class = validators.MilestoneValidator permission_classes = (permissions.MilestonePermission,) filter_backends = (filters.CanViewMilestonesFilterBackend,) filter_fields = ( @@ -56,12 +53,6 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ) queryset = models.Milestone.objects.all() - def get_serializer_class(self, *args, **kwargs): - if self.action == "list": - return serializers.MilestoneListSerializer - - return serializers.MilestoneSerializer - def list(self, request, *args, **kwargs): res = super().list(request, *args, **kwargs) self._add_taiga_info_headers() @@ -84,33 +75,8 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, def get_queryset(self): qs = super().get_queryset() - - # Userstories prefetching - UserStory = apps.get_model("userstories", "UserStory") - - us_qs = UserStory.objects.select_related("milestone", - "project", - "status", - "owner", - "assigned_to", - "generated_from_issue") - - us_qs = userstories_utils.attach_total_points(us_qs) - us_qs = userstories_utils.attach_role_points(us_qs) - us_qs = attach_total_voters_to_queryset(us_qs) - us_qs = self.attach_watchers_attrs_to_queryset(us_qs) - - if self.request.user.is_authenticated(): - us_qs = attach_is_voter_to_queryset(self.request.user, us_qs) - us_qs = attach_is_watcher_to_queryset(us_qs, self.request.user) - - qs = qs.prefetch_related(Prefetch("user_stories", queryset=us_qs)) - - # Milestones prefetching qs = qs.select_related("project", "owner") - qs = self.attach_watchers_attrs_to_queryset(qs) - qs = milestones_utils.attach_total_points(qs) - qs = milestones_utils.attach_closed_points(qs) + qs = milestones_utils.attach_extra_info(qs, user=self.request.user) qs = qs.order_by("-estimated_start") return qs diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index 724126fd..44b3e8f4 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -16,58 +16,29 @@ # 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 taiga.base.api import serializers -from taiga.base.utils import json -from taiga.projects.notifications.mixins import WatchedResourceModelSerializer -from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer -from taiga.projects.notifications.validators import WatchersValidator -from taiga.projects.mixins.serializers import ValidateDuplicatedNameInProjectMixin +from taiga.base.fields import Field, MethodField +from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.userstories.serializers import UserStoryListSerializer -from . import models -import serpy - - -class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, - ValidateDuplicatedNameInProjectMixin): - total_points = serializers.SerializerMethodField("get_total_points") - closed_points = serializers.SerializerMethodField("get_closed_points") - user_stories = serializers.SerializerMethodField("get_user_stories") - - class Meta: - model = models.Milestone - read_only_fields = ("id", "created_date", "modified_date") - - def get_total_points(self, obj): - return sum(obj.total_points.values()) - - def get_closed_points(self, obj): - return sum(obj.closed_points.values()) - - def get_user_stories(self, obj): - return UserStoryListSerializer(obj.user_stories.all(), many=True).data - - -class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.LightSerializer): - id = serpy.Field() - name = serpy.Field() - slug = serpy.Field() - owner = serpy.Field(attr="owner_id") - project = serpy.Field(attr="project_id") - estimated_start = serpy.Field() - estimated_finish = serpy.Field() - created_date = serpy.Field() - modified_date = serpy.Field() - closed = serpy.Field() - disponibility = serpy.Field() - order = serpy.Field() - watchers = serpy.Field() - user_stories = serpy.MethodField("get_user_stories") - total_points = serpy.MethodField() - closed_points = serpy.MethodField() +class MilestoneSerializer(WatchedResourceSerializer, serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + owner = Field(attr="owner_id") + project = Field(attr="project_id") + estimated_start = Field() + estimated_finish = Field() + created_date = Field() + modified_date = Field() + closed = Field() + disponibility = Field() + order = Field() + watchers = Field() + user_stories = MethodField() + total_points = MethodField() + closed_points = MethodField() def get_user_stories(self, obj): return UserStoryListSerializer(obj.user_stories.all(), many=True).data diff --git a/taiga/projects/milestones/utils.py b/taiga/projects/milestones/utils.py index a32d7684..b292b1bd 100644 --- a/taiga/projects/milestones/utils.py +++ b/taiga/projects/milestones/utils.py @@ -17,6 +17,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.apps import apps +from django.db.models import Prefetch + +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.userstories import utils as userstories_utils +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + def attach_total_points(queryset, as_field="total_points_attr"): """Attach total of point values to each object of the queryset. @@ -28,7 +38,7 @@ def attach_total_points(queryset, as_field="total_points_attr"): """ model = queryset.model sql = """SELECT SUM(projects_points.value) - FROM userstories_rolepoints + FROM userstories_rolepoints INNER JOIN userstories_userstory ON userstories_userstory.id = userstories_rolepoints.user_story_id INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id WHERE userstories_userstory.milestone_id = {tbl}.id""" @@ -48,7 +58,7 @@ def attach_closed_points(queryset, as_field="closed_points_attr"): """ model = queryset.model sql = """SELECT SUM(projects_points.value) - FROM userstories_rolepoints + FROM userstories_rolepoints INNER JOIN userstories_userstory ON userstories_userstory.id = userstories_rolepoints.user_story_id INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id WHERE userstories_userstory.milestone_id = {tbl}.id AND userstories_userstory.is_closed=True""" @@ -56,3 +66,33 @@ def attach_closed_points(queryset, as_field="closed_points_attr"): sql = sql.format(tbl=model._meta.db_table) queryset = queryset.extra(select={as_field: sql}) return queryset + + +def attach_extra_info(queryset, user=None): + # Userstories prefetching + UserStory = apps.get_model("userstories", "UserStory") + us_queryset = UserStory.objects.select_related("milestone", + "project", + "status", + "owner", + "assigned_to", + "generated_from_issue") + + us_queryset = userstories_utils.attach_total_points(us_queryset) + us_queryset = userstories_utils.attach_role_points(us_queryset) + us_queryset = attach_total_voters_to_queryset(us_queryset) + us_queryset = attach_watchers_to_queryset(us_queryset) + us_queryset = attach_total_watchers_to_queryset(us_queryset) + us_queryset = attach_is_voter_to_queryset(us_queryset, user) + us_queryset = attach_is_watcher_to_queryset(us_queryset, user) + + queryset = queryset.prefetch_related(Prefetch("user_stories", queryset=us_queryset)) + queryset = attach_total_points(queryset) + queryset = attach_closed_points(queryset) + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/milestones/validators.py b/taiga/projects/milestones/validators.py index 3648a672..8de3174c 100644 --- a/taiga/projects/milestones/validators.py +++ b/taiga/projects/milestones/validators.py @@ -19,14 +19,23 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.projects.validators import DuplicatedNameInProjectValidator +from taiga.projects.notifications.validators import WatchersValidator from . import models -class SprintExistsValidator: +class MilestoneExistsValidator: def validate_sprint_id(self, attrs, source): value = attrs[source] if not models.Milestone.objects.filter(pk=value).exists(): - msg = _("There's no sprint with that id") + msg = _("There's no milestone with that id") raise serializers.ValidationError(msg) return attrs + + +class MilestoneValidator(WatchersValidator, DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Milestone + read_only_fields = ("id", "created_date", "modified_date") diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py index a47d9bed..945c1119 100644 --- a/taiga/projects/mixins/serializers.py +++ b/taiga/projects/mixins/serializers.py @@ -17,34 +17,13 @@ # along with this program. If not, see . from taiga.base.api import serializers -from taiga.users.serializers import ListUserBasicInfoSerializer +from taiga.base.fields import Field, MethodField +from taiga.users.serializers import UserBasicInfoSerializer from django.utils.translation import ugettext as _ -import serpy -class ValidateDuplicatedNameInProjectMixin(serializers.ModelSerializer): - - def validate_name(self, attrs, source): - """ - Check the points name is not duplicated in the project on creation - """ - model = self.opts.model - qs = None - # If the object exists: - if self.object and attrs.get(source, None): - qs = model.objects.filter(project=self.object.project, name=attrs[source]).exclude(id=self.object.id) - - if not self.object and attrs.get("project", None) and attrs.get(source, None): - qs = model.objects.filter(project=attrs["project"], name=attrs[source]) - - if qs and qs.exists(): - raise serializers.ValidationError(_("Name duplicated for the project")) - - return attrs - - -class ListCachedUsersSerializerMixin(serpy.Serializer): +class CachedUsersSerializerMixin(serializers.LightSerializer): def to_value(self, instance): self._serialized_users = {} return super().to_value(instance) @@ -55,37 +34,40 @@ class ListCachedUsersSerializerMixin(serpy.Serializer): serialized_user = self._serialized_users.get(user.id, None) if serialized_user is None: - serializer_user = ListUserBasicInfoSerializer(user).data - self._serialized_users[user.id] = serializer_user + serialized_user = UserBasicInfoSerializer(user).data + self._serialized_users[user.id] = serialized_user return serialized_user -class ListOwnerExtraInfoSerializerMixin(ListCachedUsersSerializerMixin): - owner = serpy.Field(attr="owner_id") - owner_extra_info = serpy.MethodField() +class OwnerExtraInfoSerializerMixin(CachedUsersSerializerMixin): + owner = Field(attr="owner_id") + owner_extra_info = MethodField() def get_owner_extra_info(self, obj): return self.get_user_extra_info(obj.owner) -class ListAssignedToExtraInfoSerializerMixin(ListCachedUsersSerializerMixin): - assigned_to = serpy.Field(attr="assigned_to_id") - assigned_to_extra_info = serpy.MethodField() +class AssignedToExtraInfoSerializerMixin(CachedUsersSerializerMixin): + assigned_to = Field(attr="assigned_to_id") + assigned_to_extra_info = MethodField() def get_assigned_to_extra_info(self, obj): return self.get_user_extra_info(obj.assigned_to) -class ListStatusExtraInfoSerializerMixin(serpy.Serializer): - status = serpy.Field(attr="status_id") - status_extra_info = serpy.MethodField() +class StatusExtraInfoSerializerMixin(serializers.LightSerializer): + status = Field(attr="status_id") + status_extra_info = MethodField() def to_value(self, instance): self._serialized_status = {} return super().to_value(instance) def get_status_extra_info(self, obj): + if obj.status_id is None: + return None + serialized_status = self._serialized_status.get(obj.status_id, None) if serialized_status is None: serialized_status = { diff --git a/taiga/projects/notifications/mixins.py b/taiga/projects/notifications/mixins.py index 2cad1e97..e9dff950 100644 --- a/taiga/projects/notifications/mixins.py +++ b/taiga/projects/notifications/mixins.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import serpy - from functools import partial from operator import is_not @@ -28,16 +26,12 @@ from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import serializers from taiga.base.api.utils import get_object_or_404 -from taiga.base.fields import WatchersField +from taiga.base.fields import WatchersField, MethodField from taiga.projects.notifications import services -from taiga.projects.notifications.utils import (attach_watchers_to_queryset, - attach_is_watcher_to_queryset, - attach_total_watchers_to_queryset) from . serializers import WatcherSerializer - class WatchedResourceMixin: """ Rest Framework resource mixin for resources susceptible @@ -54,14 +48,6 @@ class WatchedResourceMixin: _not_notify = False - def attach_watchers_attrs_to_queryset(self, queryset): - queryset = attach_watchers_to_queryset(queryset) - queryset = attach_total_watchers_to_queryset(queryset) - if self.request.user.is_authenticated(): - queryset = attach_is_watcher_to_queryset(queryset, self.request.user) - - return queryset - @detail_route(methods=["POST"]) def watch(self, request, pk=None): obj = self.get_object() @@ -186,7 +172,10 @@ class WatchedModelMixin(object): return frozenset(filter(is_not_none, participants)) -class BaseWatchedResourceModelSerializer(object): +class WatchedResourceSerializer(serializers.LightSerializer): + is_watcher = MethodField() + total_watchers = MethodField() + def get_is_watcher(self, obj): # The "is_watcher" attribute is attached in the get_queryset of the viewset. if "request" in self.context: @@ -200,28 +189,18 @@ class BaseWatchedResourceModelSerializer(object): return getattr(obj, "total_watchers", 0) or 0 -class WatchedResourceModelSerializer(BaseWatchedResourceModelSerializer, serializers.ModelSerializer): - is_watcher = serializers.SerializerMethodField("get_is_watcher") - total_watchers = serializers.SerializerMethodField("get_total_watchers") - - -class ListWatchedResourceModelSerializer(BaseWatchedResourceModelSerializer, serpy.Serializer): - is_watcher = serpy.MethodField("get_is_watcher") - total_watchers = serpy.MethodField("get_total_watchers") - - -class EditableWatchedResourceModelSerializer(WatchedResourceModelSerializer): +class EditableWatchedResourceSerializer(serializers.ModelSerializer): watchers = WatchersField(required=False) def restore_object(self, attrs, instance=None): - #watchers is not a field from the model but can be attached in the get_queryset of the viewset. - #If that's the case we need to remove it before calling the super method - watcher_field = self.fields.pop("watchers", None) + # watchers is not a field from the model but can be attached in the get_queryset of the viewset. + # If that's the case we need to remove it before calling the super method + self.fields.pop("watchers", None) self.validate_watchers(attrs, "watchers") new_watcher_ids = attrs.pop("watchers", None) - obj = super(WatchedResourceModelSerializer, self).restore_object(attrs, instance) + obj = super(EditableWatchedResourceSerializer, self).restore_object(attrs, instance) - #A partial update can exclude the watchers field or if the new instance can still not be saved + # A partial update can exclude the watchers field or if the new instance can still not be saved if instance is None or new_watcher_ids is None: return obj @@ -230,7 +209,6 @@ class EditableWatchedResourceModelSerializer(WatchedResourceModelSerializer): adding_watcher_ids = list(new_watcher_ids.difference(old_watcher_ids)) removing_watcher_ids = list(old_watcher_ids.difference(new_watcher_ids)) - User = get_user_model() adding_users = get_user_model().objects.filter(id__in=adding_watcher_ids) removing_users = get_user_model().objects.filter(id__in=removing_watcher_ids) for user in adding_users: @@ -244,7 +222,7 @@ class EditableWatchedResourceModelSerializer(WatchedResourceModelSerializer): return obj def to_native(self, obj): - #if watchers wasn't attached via the get_queryset of the viewset we need to manually add it + # if watchers wasn't attached via the get_queryset of the viewset we need to manually add it if obj is not None: if not hasattr(obj, "watchers"): obj.watchers = [user.id for user in obj.get_watchers()] @@ -254,10 +232,10 @@ class EditableWatchedResourceModelSerializer(WatchedResourceModelSerializer): if user and user.is_authenticated(): obj.is_watcher = user.id in obj.watchers - return super(WatchedResourceModelSerializer, self).to_native(obj) + return super(WatchedResourceSerializer, self).to_native(obj) def save(self, **kwargs): - obj = super(EditableWatchedResourceModelSerializer, self).save(**kwargs) + obj = super(EditableWatchedResourceSerializer, self).save(**kwargs) self.fields["watchers"] = WatchersField(required=False) obj.watchers = [user.id for user in obj.get_watchers()] return obj diff --git a/taiga/projects/notifications/utils.py b/taiga/projects/notifications/utils.py index 00b98d63..ae6bd34c 100644 --- a/taiga/projects/notifications/utils.py +++ b/taiga/projects/notifications/utils.py @@ -53,15 +53,18 @@ def attach_is_watcher_to_queryset(queryset, user, as_field="is_watcher"): """ model = queryset.model type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) - sql = ("""SELECT CASE WHEN (SELECT count(*) - FROM notifications_watched - WHERE notifications_watched.content_type_id = {type_id} - AND notifications_watched.object_id = {tbl}.id - AND notifications_watched.user_id = {user_id}) > 0 - THEN TRUE - ELSE FALSE - END""") - sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + if user is None or user.is_anonymous(): + sql = """SELECT false""" + else: + sql = ("""SELECT CASE WHEN (SELECT count(*) + FROM notifications_watched + WHERE notifications_watched.content_type_id = {type_id} + AND notifications_watched.object_id = {tbl}.id + AND notifications_watched.user_id = {user_id}) > 0 + THEN TRUE + ELSE FALSE + END""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) qs = queryset.extra(select={as_field: sql}) return qs diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index c10a4810..96b8d0ba 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -16,135 +16,121 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import serpy - from django.utils.translation import ugettext as _ -from django.db.models import Q from taiga.base.api import serializers -from taiga.base.fields import JsonField -from taiga.base.fields import PgArrayField +from taiga.base.fields import Field, MethodField, I18NField from taiga.permissions import services as permissions_services from taiga.users.services import get_photo_or_gravatar_url from taiga.users.serializers import UserBasicInfoSerializer -from taiga.users.serializers import ProjectRoleSerializer -from taiga.users.serializers import ListUserBasicInfoSerializer -from taiga.users.validators import RoleExistsValidator -from taiga.permissions.services import get_user_project_permissions from taiga.permissions.services import calculate_permissions from taiga.permissions.services import is_project_admin, is_project_owner -from . import models from . import services -from .custom_attributes.serializers import UserStoryCustomAttributeSerializer -from .custom_attributes.serializers import TaskCustomAttributeSerializer -from .custom_attributes.serializers import IssueCustomAttributeSerializer -from .likes.mixins.serializers import FanResourceSerializerMixin -from .mixins.serializers import ValidateDuplicatedNameInProjectMixin from .notifications.choices import NotifyLevel -from .notifications.mixins import WatchedResourceModelSerializer -from .tagging.fields import TagsField -from .tagging.fields import TagsColorsField -from .validators import ProjectExistsValidator - -import serpy - -###################################################### -## Custom values for selectors -###################################################### - -class PointsSerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.Points - i18n_fields = ("name",) - - -class UserStoryStatusSerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.UserStoryStatus - i18n_fields = ("name",) - - -class BasicUserStoryStatusSerializer(serializers.ModelSerializer): - class Meta: - model = models.UserStoryStatus - i18n_fields = ("name",) - fields = ("name", "color") - - -class TaskStatusSerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.TaskStatus - i18n_fields = ("name",) - - -class BasicTaskStatusSerializerSerializer(serializers.ModelSerializer): - class Meta: - model = models.TaskStatus - i18n_fields = ("name",) - fields = ("name", "color") - - -class SeveritySerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.Severity - i18n_fields = ("name",) - - -class PrioritySerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.Priority - i18n_fields = ("name",) - - -class IssueStatusSerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.IssueStatus - i18n_fields = ("name",) - - -class BasicIssueStatusSerializer(serializers.ModelSerializer): - class Meta: - model = models.IssueStatus - i18n_fields = ("name",) - fields = ("name", "color") - - -class IssueTypeSerializer(ValidateDuplicatedNameInProjectMixin): - class Meta: - model = models.IssueType - i18n_fields = ("name",) ###################################################### -## Members +# Custom values for selectors ###################################################### -class MembershipSerializer(serializers.ModelSerializer): - role_name = serializers.CharField(source='role.name', required=False, read_only=True, i18n=True) - full_name = serializers.CharField(source='user.get_full_name', required=False, read_only=True) - user_email = serializers.EmailField(source='user.email', required=False, read_only=True) - is_user_active = serializers.BooleanField(source='user.is_active', required=False, - read_only=True) - email = serializers.EmailField(required=True) - color = serializers.CharField(source='user.color', required=False, read_only=True) - photo = serializers.SerializerMethodField("get_photo") - project_name = serializers.SerializerMethodField("get_project_name") - project_slug = serializers.SerializerMethodField("get_project_slug") - invited_by = UserBasicInfoSerializer(read_only=True) - is_owner = serializers.SerializerMethodField("get_is_owner") +class PointsSerializer(serializers.LightSerializer): + name = I18NField() + order = Field() + value = Field() + project = Field(attr="project_id") - 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", "user_email", "email") - def get_photo(self, project): - return get_photo_or_gravatar_url(project.user) +class UserStoryStatusSerializer(serializers.LightSerializer): + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + is_archived = Field() + color = Field() + wip_limit = Field() + project = Field(attr="project_id") + + +class TaskStatusSerializer(serializers.LightSerializer): + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + project = Field(attr="project_id") + + +class SeveritySerializer(serializers.LightSerializer): + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +class PrioritySerializer(serializers.LightSerializer): + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +class IssueStatusSerializer(serializers.LightSerializer): + name = I18NField() + slug = Field() + order = Field() + is_closed = Field() + color = Field() + project = Field(attr="project_id") + + +class IssueTypeSerializer(serializers.LightSerializer): + name = I18NField() + order = Field() + color = Field() + project = Field(attr="project_id") + + +###################################################### +# Members +###################################################### + +class MembershipSerializer(serializers.LightSerializer): + id = Field() + user = Field(attr="user_id") + project = Field(attr="project_id") + role = Field(attr="role_id") + is_admin = Field() + created_at = Field() + invited_by = Field(attr="invited_by_id") + invitation_extra_text = Field() + user_order = Field() + role_name = MethodField() + full_name = MethodField() + is_user_active = MethodField() + color = MethodField() + photo = MethodField() + project_name = MethodField() + project_slug = MethodField() + invited_by = UserBasicInfoSerializer() + is_owner = MethodField() + + def get_role_name(self, obj): + return obj.role.name if obj.role else None + + def get_full_name(self, obj): + return obj.user.get_full_name() if obj.user else None + + def get_is_user_active(self, obj): + return obj.user.is_active if obj.user else False + + def get_color(self, obj): + return obj.user.color if obj.user else None + + def get_photo(self, obj): + return get_photo_or_gravatar_url(obj.user) def get_project_name(self, obj): return obj.project.name if obj and obj.project else "" @@ -156,230 +142,84 @@ class MembershipSerializer(serializers.ModelSerializer): return (obj and obj.user_id and obj.project_id and obj.project.owner_id and obj.user_id == obj.project.owner_id) - def validate_email(self, attrs, source): - project = attrs.get("project", None) - if project is None: - project = self.object.project - - email = attrs[source] - - qs = models.Membership.objects.all() - - # If self.object is not None, the serializer is in update - # mode, and for it, it should exclude self. - if self.object: - qs = qs.exclude(pk=self.object.pk) - - qs = qs.filter(Q(project_id=project.id, user__email=email) | - Q(project_id=project.id, email=email)) - - if qs.count() > 0: - raise serializers.ValidationError(_("Email address is already taken")) - - return attrs - - def validate_role(self, attrs, source): - project = attrs.get("project", None) - if project is None: - project = self.object.project - - role = attrs[source] - - if project.roles.filter(id=role.id).count() == 0: - raise serializers.ValidationError(_("Invalid role for the project")) - - return attrs - - def validate_is_admin(self, attrs, source): - project = attrs.get("project", None) - if project is None: - project = self.object.project - - if (self.object and self.object.user): - if self.object.user.id == project.owner_id and attrs[source] != True: - raise serializers.ValidationError(_("The project owner must be admin.")) - - if not services.project_has_valid_admins(project, exclude_user=self.object.user): - raise serializers.ValidationError(_("At least one user must be an active admin for this project.")) - - return attrs - class MembershipAdminSerializer(MembershipSerializer): - 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",) + email = Field() + user_email = MethodField() + def get_user_email(self, obj): + return obj.user.email if obj.user else None -class MemberBulkSerializer(RoleExistsValidator, serializers.Serializer): - email = serializers.EmailField() - role_id = serializers.IntegerField() - - -class MembersBulkSerializer(ProjectExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - bulk_memberships = MemberBulkSerializer(many=True) - invitation_extra_text = serializers.CharField(required=False, max_length=255) - - -class ProjectMemberSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(source="user.id", read_only=True) - username = serializers.CharField(source='user.username', read_only=True) - full_name = serializers.CharField(source='user.full_name', read_only=True) - full_name_display = serializers.CharField(source='user.get_full_name', read_only=True) - color = serializers.CharField(source='user.color', read_only=True) - photo = serializers.SerializerMethodField("get_photo") - is_active = serializers.BooleanField(source='user.is_active', read_only=True) - role_name = serializers.CharField(source='role.name', read_only=True, i18n=True) - - class Meta: - model = models.Membership - exclude = ("project", "email", "created_at", "token", "invited_by", "invitation_extra_text", - "user_order") - - def get_photo(self, membership): - return get_photo_or_gravatar_url(membership.user) + # IMPORTANT: Maintain the MembershipSerializer Meta up to date + # with this info (excluding there user_email and email) ###################################################### -## Projects +# Projects ###################################################### -class ProjectSerializer(FanResourceSerializerMixin, WatchedResourceModelSerializer, - serializers.ModelSerializer): - anon_permissions = PgArrayField(required=False) - public_permissions = PgArrayField(required=False) - my_permissions = serializers.SerializerMethodField("get_my_permissions") +class ProjectSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + description = Field() + created_date = Field() + modified_date = Field() + owner = MethodField() + members = MethodField() + total_milestones = Field() + total_story_points = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + creation_template = Field(attr="creation_template_id") + is_private = Field() + anon_permissions = Field() + public_permissions = Field() + is_featured = Field() + is_looking_for_people = Field() + looking_for_people_note = Field() + blocked_code = Field() + totals_updated_datetime = Field() + total_fans = Field() + total_fans_last_week = Field() + total_fans_last_month = Field() + total_fans_last_year = Field() + total_activity = Field() + total_activity_last_week = Field() + total_activity_last_month = Field() + total_activity_last_year = Field() - owner = UserBasicInfoSerializer(read_only=True) - i_am_owner = serializers.SerializerMethodField("get_i_am_owner") - i_am_admin = serializers.SerializerMethodField("get_i_am_admin") - i_am_member = serializers.SerializerMethodField("get_i_am_member") + tags = Field() + tags_colors = MethodField() - tags = TagsField(default=[], required=False) - tags_colors = TagsColorsField(required=False, read_only=True) + default_points = Field(attr="default_points_id") + default_us_status = Field(attr="default_us_status_id") + default_task_status = Field(attr="default_task_status_id") + default_priority = Field(attr="default_priority_id") + default_severity = Field(attr="default_severity_id") + default_issue_status = Field(attr="default_issue_status_id") + default_issue_type = Field(attr="default_issue_type_id") - notify_level = serializers.SerializerMethodField("get_notify_level") - total_closed_milestones = serializers.SerializerMethodField("get_total_closed_milestones") - total_watchers = serializers.SerializerMethodField("get_total_watchers") + my_permissions = MethodField() - logo_small_url = serializers.SerializerMethodField("get_logo_small_url") - logo_big_url = serializers.SerializerMethodField("get_logo_big_url") + i_am_owner = MethodField() + i_am_admin = MethodField() + i_am_member = MethodField() - class Meta: - model = models.Project - read_only_fields = ("created_date", "modified_date", "slug", "blocked_code") - exclude = ("logo", "last_us_ref", "last_task_ref", "last_issue_ref", - "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid", - "transfer_token") + notify_level = MethodField() + total_closed_milestones = MethodField() - def get_my_permissions(self, obj): - if "request" in self.context: - return get_user_project_permissions(self.context["request"].user, obj) - return [] + is_watcher = MethodField() + total_watchers = MethodField() - def get_i_am_owner(self, obj): - if "request" in self.context: - return is_project_owner(self.context["request"].user, obj) - return False + logo_small_url = MethodField() + logo_big_url = MethodField() - def get_i_am_admin(self, obj): - if "request" in self.context: - return is_project_admin(self.context["request"].user, obj) - return False - - def get_i_am_member(self, obj): - if "request" in self.context: - user = self.context["request"].user - if not user.is_anonymous() and user.cached_membership_for_project(obj): - return True - return False - - def get_total_closed_milestones(self, obj): - return obj.milestones.filter(closed=True).count() - - def get_notify_level(self, obj): - if "request" in self.context: - user = self.context["request"].user - return user.is_authenticated() and user.get_notify_level(obj) - - return None - - def get_total_watchers(self, obj): - return obj.notify_policies.exclude(notify_level=NotifyLevel.none).count() - - def get_logo_small_url(self, obj): - return services.get_logo_small_thumbnail_url(obj) - - def get_logo_big_url(self, obj): - return services.get_logo_big_thumbnail_url(obj) - - -class LightProjectSerializer(serializers.LightSerializer): - id = serpy.Field() - name = serpy.Field() - slug = serpy.Field() - description = serpy.Field() - created_date = serpy.Field() - modified_date = serpy.Field() - owner = serpy.MethodField() - members = serpy.MethodField() - total_milestones = serpy.Field() - total_story_points = serpy.Field() - is_backlog_activated = serpy.Field() - is_kanban_activated = serpy.Field() - is_wiki_activated = serpy.Field() - is_issues_activated = serpy.Field() - videoconferences = serpy.Field() - videoconferences_extra_data = serpy.Field() - creation_template = serpy.Field(attr="creation_template_id") - is_private = serpy.Field() - anon_permissions = serpy.Field() - public_permissions = serpy.Field() - is_featured = serpy.Field() - is_looking_for_people = serpy.Field() - looking_for_people_note = serpy.Field() - blocked_code = serpy.Field() - totals_updated_datetime = serpy.Field() - total_fans = serpy.Field() - total_fans_last_week = serpy.Field() - total_fans_last_month = serpy.Field() - total_fans_last_year = serpy.Field() - total_activity = serpy.Field() - total_activity_last_week = serpy.Field() - total_activity_last_month = serpy.Field() - total_activity_last_year = serpy.Field() - - tags = serpy.Field() - tags_colors = serpy.MethodField() - - default_points = serpy.Field(attr="default_points_id") - default_us_status = serpy.Field(attr="default_us_status_id") - default_task_status = serpy.Field(attr="default_task_status_id") - default_priority = serpy.Field(attr="default_priority_id") - default_severity = serpy.Field(attr="default_severity_id") - default_issue_status = serpy.Field(attr="default_issue_status_id") - default_issue_type = serpy.Field(attr="default_issue_type_id") - - my_permissions = serpy.MethodField() - - i_am_owner = serpy.MethodField() - i_am_admin = serpy.MethodField() - i_am_member = serpy.MethodField() - - notify_level = serpy.MethodField("get_notify_level") - total_closed_milestones = serpy.MethodField() - - is_watcher = serpy.MethodField() - total_watchers = serpy.MethodField() - - logo_small_url = serpy.MethodField() - logo_big_url = serpy.MethodField() - - is_fan = serpy.Field(attr="is_fan_attr") + is_fan = Field(attr="is_fan_attr") def get_members(self, obj): assert hasattr(obj, "members_attr"), "instance must have a members_attr attribute" @@ -395,7 +235,8 @@ class LightProjectSerializer(serializers.LightSerializer): if "request" in self.context: user = self.context["request"].user - if not user.is_anonymous() and user.id in [m.get("id") for m in obj.members_attr if m["id"] is not None]: + user_ids = [m.get("id") for m in obj.members_attr if m["id"] is not None] + if not user.is_anonymous() and user.id in user_ids: return True return False @@ -407,17 +248,17 @@ class LightProjectSerializer(serializers.LightSerializer): if "request" in self.context: user = self.context["request"].user return calculate_permissions( - is_authenticated = user.is_authenticated(), - is_superuser = user.is_superuser, - is_member = self.get_i_am_member(obj), - is_admin = self.get_i_am_admin(obj), - role_permissions = obj.my_role_permissions_attr, - anon_permissions = obj.anon_permissions, - public_permissions = obj.public_permissions) + is_authenticated=user.is_authenticated(), + is_superuser=user.is_superuser, + is_member=self.get_i_am_member(obj), + is_admin=self.get_i_am_admin(obj), + role_permissions=obj.my_role_permissions_attr, + anon_permissions=obj.anon_permissions, + public_permissions=obj.public_permissions) return [] def get_owner(self, obj): - return ListUserBasicInfoSerializer(obj.owner).data + return UserBasicInfoSerializer(obj.owner).data def get_i_am_owner(self, obj): if "request" in self.context: @@ -436,7 +277,7 @@ class LightProjectSerializer(serializers.LightSerializer): def get_is_watcher(self, obj): assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" np = self.get_notify_level(obj) - return np != None and np != NotifyLevel.none + return np is not None and np != NotifyLevel.none def get_total_watchers(self, obj): assert hasattr(obj, "notify_policies_attr"), "instance must have a notify_policies_attr attribute" @@ -466,36 +307,36 @@ class LightProjectSerializer(serializers.LightSerializer): return services.get_logo_big_thumbnail_url(obj) -class LightProjectDetailSerializer(LightProjectSerializer): - us_statuses = serpy.Field(attr="userstory_statuses_attr") - points = serpy.Field(attr="points_attr") - task_statuses = serpy.Field(attr="task_statuses_attr") - issue_statuses = serpy.Field(attr="issue_statuses_attr") - issue_types = serpy.Field(attr="issue_types_attr") - priorities = serpy.Field(attr="priorities_attr") - severities = serpy.Field(attr="severities_attr") - userstory_custom_attributes = serpy.Field(attr="userstory_custom_attributes_attr") - task_custom_attributes = serpy.Field(attr="task_custom_attributes_attr") - issue_custom_attributes = serpy.Field(attr="issue_custom_attributes_attr") - roles = serpy.Field(attr="roles_attr") - members = serpy.MethodField() - total_memberships = serpy.MethodField() - is_out_of_owner_limits = serpy.MethodField() +class ProjectDetailSerializer(ProjectSerializer): + us_statuses = Field(attr="userstory_statuses_attr") + points = Field(attr="points_attr") + task_statuses = Field(attr="task_statuses_attr") + issue_statuses = Field(attr="issue_statuses_attr") + issue_types = Field(attr="issue_types_attr") + priorities = Field(attr="priorities_attr") + severities = Field(attr="severities_attr") + userstory_custom_attributes = Field(attr="userstory_custom_attributes_attr") + task_custom_attributes = Field(attr="task_custom_attributes_attr") + issue_custom_attributes = Field(attr="issue_custom_attributes_attr") + roles = Field(attr="roles_attr") + members = MethodField() + total_memberships = MethodField() + is_out_of_owner_limits = MethodField() - #Admin fields - is_private_extra_info = serpy.MethodField() - max_memberships = serpy.MethodField() - issues_csv_uuid = serpy.Field() - tasks_csv_uuid = serpy.Field() - userstories_csv_uuid = serpy.Field() - transfer_token = serpy.Field() + # Admin fields + is_private_extra_info = MethodField() + max_memberships = MethodField() + issues_csv_uuid = Field() + tasks_csv_uuid = Field() + userstories_csv_uuid = Field() + transfer_token = Field() def to_value(self, instance): # Name attributes must be translated - for attr in ["userstory_statuses_attr","points_attr", "task_statuses_attr", + for attr in ["userstory_statuses_attr", "points_attr", "task_statuses_attr", "issue_statuses_attr", "issue_types_attr", "priorities_attr", "severities_attr", "userstory_custom_attributes_attr", - "task_custom_attributes_attr","issue_custom_attributes_attr", "roles_attr"]: + "task_custom_attributes_attr", "issue_custom_attributes_attr", "roles_attr"]: assert hasattr(instance, attr), "instance must have a {} attribute".format(attr) val = getattr(instance, attr) @@ -547,8 +388,9 @@ class LightProjectDetailSerializer(LightProjectSerializer): def get_is_out_of_owner_limits(self, obj): assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" - return services.check_if_project_is_out_of_owner_limits(obj, - current_memberships = self.get_total_memberships(obj), + return services.check_if_project_is_out_of_owner_limits( + obj, + current_memberships=self.get_total_memberships(obj), current_private_projects=obj.private_projects_same_owner_attr, current_public_projects=obj.public_projects_same_owner_attr ) @@ -556,8 +398,9 @@ class LightProjectDetailSerializer(LightProjectSerializer): def get_is_private_extra_info(self, obj): assert hasattr(obj, "private_projects_same_owner_attr"), "instance must have a private_projects_same_owner_attr attribute" assert hasattr(obj, "public_projects_same_owner_attr"), "instance must have a public_projects_same_owner_attr attribute" - return services.check_if_project_privacity_can_be_changed(obj, - current_memberships = self.get_total_memberships(obj), + return services.check_if_project_privacity_can_be_changed( + obj, + current_memberships=self.get_total_memberships(obj), current_private_projects=obj.private_projects_same_owner_attr, current_public_projects=obj.public_projects_same_owner_attr ) @@ -565,41 +408,32 @@ class LightProjectDetailSerializer(LightProjectSerializer): def get_max_memberships(self, obj): return services.get_max_memberships_for_project(obj) -###################################################### -## Liked -###################################################### - -class LikedSerializer(serializers.ModelSerializer): - class Meta: - model = models.Project - fields = ['id', 'name', 'slug'] - - ###################################################### -## Project Templates +# Project Templates ###################################################### -class ProjectTemplateSerializer(serializers.ModelSerializer): - default_options = JsonField(required=False, label=_("Default options")) - us_statuses = JsonField(required=False, label=_("User story's statuses")) - points = JsonField(required=False, label=_("Points")) - task_statuses = JsonField(required=False, label=_("Task's statuses")) - issue_statuses = JsonField(required=False, label=_("Issue's statuses")) - issue_types = JsonField(required=False, label=_("Issue's types")) - priorities = JsonField(required=False, label=_("Priorities")) - severities = JsonField(required=False, label=_("Severities")) - roles = JsonField(required=False, label=_("Roles")) - - class Meta: - model = models.ProjectTemplate - read_only_fields = ("created_date", "modified_date") - i18n_fields = ("name", "description") - -###################################################### -## Project order bulk serializers -###################################################### - -class UpdateProjectOrderBulkSerializer(ProjectExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - order = serializers.IntegerField() +class ProjectTemplateSerializer(serializers.LightSerializer): + id = Field() + name = I18NField() + slug = Field() + description = I18NField() + order = Field() + created_date = Field() + modified_date = Field() + default_owner_role = Field() + is_backlog_activated = Field() + is_kanban_activated = Field() + is_wiki_activated = Field() + is_issues_activated = Field() + videoconferences = Field() + videoconferences_extra_data = Field() + default_options = Field() + us_statuses = Field() + points = Field() + task_statuses = Field() + issue_statuses = Field() + issue_types = Field() + priorities = Field() + severities = Field() + roles = Field() diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index 01ae057e..bec134c5 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -26,7 +26,6 @@ from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin -from taiga.projects.attachments.utils import attach_basic_attachments from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.models import Project, TaskStatus from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin @@ -38,10 +37,14 @@ from . import models from . import permissions from . import serializers from . import services +from . import validators +from . import utils as tasks_utils -class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, - TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): +class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, + WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin, + ModelCrudViewSet): + validator_class = validators.TaskValidator queryset = models.Task.objects.all() permission_classes = (permissions.TaskPermission,) filter_backends = (filters.CanViewTasksFilterBackend, @@ -74,17 +77,15 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa def get_queryset(self): qs = super().get_queryset() - qs = self.attach_votes_attrs_to_queryset(qs) qs = qs.select_related("milestone", "project", "status", "owner", "assigned_to") - qs = self.attach_watchers_attrs_to_queryset(qs) - if "include_attachments" in self.request.QUERY_PARAMS: - qs = attach_basic_attachments(qs) - qs = qs.extra(select={"include_attachments": "True"}) + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + qs = tasks_utils.attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments) return qs @@ -164,8 +165,7 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa def by_ref(self, request): ref = request.QUERY_PARAMS.get("ref", None) project_id = request.QUERY_PARAMS.get("project", None) - task = get_object_or_404(models.Task, ref=ref, project_id=project_id) - return self.retrieve(request, pk=task.pk) + return self.retrieve(request, project_id=project_id, ref=ref) @list_route(methods=["GET"]) def csv(self, request): @@ -182,9 +182,9 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): - serializer = serializers.TasksBulkSerializer(data=request.DATA) - if serializer.is_valid(): - data = serializer.data + validator = validators.TasksBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data project = Project.objects.get(id=data["project_id"]) self.check_permissions(request, 'bulk_create', project) if project.blocked_code is not None: @@ -194,18 +194,20 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa data["bulk_tasks"], milestone_id=data["sprint_id"], user_story_id=data["us_id"], status_id=data.get("status_id") or project.default_task_status_id, project=project, owner=request.user, callback=self.post_save, precall=self.pre_save) + + tasks = self.get_queryset().filter(id__in=[i.id for i in tasks]) tasks_serialized = self.get_serializer_class()(tasks, many=True) return response.Ok(tasks_serialized.data) - return response.BadRequest(serializer.errors) + return response.BadRequest(validator.errors) def _bulk_update_order(self, order_field, request, **kwargs): - serializer = serializers.UpdateTasksOrderBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.UpdateTasksOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data project = get_object_or_404(Project, pk=data["project_id"]) self.check_permissions(request, "bulk_update_order", project) diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index ac82c570..cd649424 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -16,101 +16,44 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.contrib.auth import get_user_model -from django.utils.translation import ugettext_lazy as _ - from taiga.base.api import serializers -from taiga.base.fields import PgArrayField +from taiga.base.fields import Field, MethodField from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender -from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin -from taiga.projects.milestones.validators import SprintExistsValidator -from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin -from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer -from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer -from taiga.projects.notifications.validators import WatchersValidator -from taiga.projects.serializers import BasicTaskStatusSerializerSerializer -from taiga.mdrender.service import render as mdrender -from taiga.projects.tagging.fields import TagsAndTagsColorsField -from taiga.projects.tasks.validators import TaskExistsValidator -from taiga.projects.validators import ProjectExistsValidator +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin -from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin - -from taiga.users.serializers import UserBasicInfoSerializer -from taiga.users.services import get_photo_or_gravatar_url -from taiga.users.services import get_big_photo_or_gravatar_url - -from . import models - -import serpy -class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, - serializers.ModelSerializer): - - tags = TagsAndTagsColorsField(default=[], required=False) - external_reference = PgArrayField(required=False) - comment = serializers.SerializerMethodField("get_comment") - milestone_slug = serializers.SerializerMethodField("get_milestone_slug") - blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") - description_html = serializers.SerializerMethodField("get_description_html") - is_closed = serializers.SerializerMethodField("get_is_closed") - status_extra_info = BasicTaskStatusSerializerSerializer(source="status", required=False, read_only=True) - assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True) - owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True) - - class Meta: - model = models.Task - read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') - - def get_comment(self, obj): - return "" - - def get_milestone_slug(self, obj): - if obj.milestone: - return obj.milestone.slug - else: - return None - - def get_blocked_note_html(self, obj): - return mdrender(obj.project, obj.blocked_note) - - def get_description_html(self, obj): - return mdrender(obj.project, obj.description) - - def get_is_closed(self, obj): - return obj.status is not None and obj.status.is_closed - - -class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, - ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin, +class TaskListSerializer(VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, serializers.LightSerializer): - id = serpy.Field() - user_story = serpy.Field(attr="user_story_id") - ref = serpy.Field() - project = serpy.Field(attr="project_id") - milestone = serpy.Field(attr="milestone_id") - milestone_slug = serpy.MethodField("get_milestone_slug") - created_date = serpy.Field() - modified_date = serpy.Field() - finished_date = serpy.Field() - subject = serpy.Field() - us_order = serpy.Field() - taskboard_order = serpy.Field() - is_iocaine = serpy.Field() - external_reference = serpy.Field() - version = serpy.Field() - watchers = serpy.Field() - is_blocked = serpy.Field() - blocked_note = serpy.Field() - tags = serpy.Field() - is_closed = serpy.MethodField() + id = Field() + user_story = Field(attr="user_story_id") + ref = Field() + project = Field(attr="project_id") + milestone = Field(attr="milestone_id") + milestone_slug = MethodField() + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + us_order = Field() + taskboard_order = Field() + is_iocaine = Field() + external_reference = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + tags = Field() + is_closed = MethodField() def get_milestone_slug(self, obj): return obj.milestone.slug if obj.milestone else None @@ -119,36 +62,21 @@ class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceMod return obj.status is not None and obj.status.is_closed +class TaskSerializer(TaskListSerializer): + comment = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() + + def get_comment(self, obj): + return "" + + def get_blocked_note_html(self, obj): + return mdrender(obj.project, obj.blocked_note) + + def get_description_html(self, obj): + return mdrender(obj.project, obj.description) + + class TaskNeighborsSerializer(NeighborsSerializerMixin, TaskSerializer): - def serialize_neighbor(self, neighbor): - if neighbor: - return NeighborTaskSerializer(neighbor).data - return None - - -class NeighborTaskSerializer(serializers.ModelSerializer): - class Meta: - model = models.Task - fields = ("id", "ref", "subject") - depth = 0 - - -class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator, - TaskExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - sprint_id = serializers.IntegerField() - status_id = serializers.IntegerField(required=False) - us_id = serializers.IntegerField(required=False) - bulk_tasks = serializers.CharField() - - -## Order bulk serializers - -class _TaskOrderBulkSerializer(TaskExistsValidator, serializers.Serializer): - task_id = serializers.IntegerField() - order = serializers.IntegerField() - - -class UpdateTasksOrderBulkSerializer(ProjectExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - bulk_tasks = _TaskOrderBulkSerializer(many=True) + pass diff --git a/taiga/projects/tasks/utils.py b/taiga/projects/tasks/utils.py new file mode 100644 index 00000000..d10dddab --- /dev/null +++ b/taiga/projects/tasks/utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# 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 +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/tasks/validators.py b/taiga/projects/tasks/validators.py index 4a100779..7f71636c 100644 --- a/taiga/projects/tasks/validators.py +++ b/taiga/projects/tasks/validators.py @@ -19,7 +19,13 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers - +from taiga.base.api import validators +from taiga.base.fields import PgArrayField +from taiga.projects.milestones.validators import MilestoneExistsValidator +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator from . import models @@ -30,3 +36,33 @@ class TaskExistsValidator: msg = _("There's no task with that id") raise serializers.ValidationError(msg) return attrs + + +class TaskValidator(WatchersValidator, EditableWatchedResourceSerializer, validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + + class Meta: + model = models.Task + read_only_fields = ('id', 'ref', 'created_date', 'modified_date', 'owner') + + +class TasksBulkValidator(ProjectExistsValidator, MilestoneExistsValidator, + TaskExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + sprint_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + us_id = serializers.IntegerField(required=False) + bulk_tasks = serializers.CharField() + + +# Order bulk validators + +class _TaskOrderBulkValidator(TaskExistsValidator, validators.Validator): + task_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateTasksOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_tasks = _TaskOrderBulkValidator(many=True) diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 87ecf18b..47487e88 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -16,12 +16,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from contextlib import closing -from collections import namedtuple - from django.apps import apps -from django.db import transaction, connection -from django.db.models.sql import datastructures +from django.db import transaction from django.utils.translation import ugettext as _ from django.http import HttpResponse @@ -36,7 +32,6 @@ from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelListViewSet from taiga.base.api.utils import get_object_or_404 -from taiga.projects.attachments.utils import attach_basic_attachments from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.services import take_snapshot from taiga.projects.milestones.models import Milestone @@ -45,21 +40,20 @@ from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.notifications.mixins import WatchersViewSetMixin from taiga.projects.occ import OCCResourceMixin from taiga.projects.tagging.api import TaggedResourceMixin -from taiga.projects.userstories.models import RolePoints from taiga.projects.votes.mixins.viewsets import VotedResourceMixin from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin -from taiga.projects.userstories.utils import attach_total_points -from taiga.projects.userstories.utils import attach_role_points -from taiga.projects.userstories.utils import attach_tasks +from taiga.projects.userstories.utils import attach_extra_info from . import models from . import permissions from . import serializers from . import services +from . import validators class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, TaggedResourceMixin, BlockedByProjectMixin, ModelCrudViewSet): + validator_class = validators.UserStoryValidator queryset = models.UserStory.objects.all() permission_classes = (permissions.UserStoryPermission,) filter_backends = (filters.CanViewUsFilterBackend, @@ -105,18 +99,11 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi "assigned_to", "generated_from_issue") - qs = self.attach_votes_attrs_to_queryset(qs) - qs = self.attach_watchers_attrs_to_queryset(qs) - qs = attach_total_points(qs) - qs = attach_role_points(qs) - - if "include_attachments" in self.request.QUERY_PARAMS: - qs = attach_basic_attachments(qs) - qs = qs.extra(select={"include_attachments": "True"}) - - if "include_tasks" in self.request.QUERY_PARAMS: - qs = attach_tasks(qs) - qs = qs.extra(select={"include_tasks": "True"}) + include_attachments = "include_attachments" in self.request.QUERY_PARAMS + include_tasks = "include_tasks" in self.request.QUERY_PARAMS + qs = attach_extra_info(qs, user=self.request.user, + include_attachments=include_attachments, + include_tasks=include_tasks) return qs @@ -239,8 +226,7 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi def by_ref(self, request): ref = request.QUERY_PARAMS.get("ref", None) project_id = request.QUERY_PARAMS.get("project", None) - userstory = get_object_or_404(models.UserStory, ref=ref, project_id=project_id) - return self.retrieve(request, pk=userstory.pk) + return self.retrieve(request, project_id=project_id, ref=ref) @list_route(methods=["GET"]) def csv(self, request): @@ -257,9 +243,9 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): - serializer = serializers.UserStoriesBulkSerializer(data=request.DATA) - if serializer.is_valid(): - data = serializer.data + validator = validators.UserStoriesBulkValidator(data=request.DATA) + if validator.is_valid(): + data = validator.data project = Project.objects.get(id=data["project_id"]) self.check_permissions(request, 'bulk_create', project) if project.blocked_code is not None: @@ -269,17 +255,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi data["bulk_stories"], project=project, owner=request.user, status_id=data.get("status_id") or project.default_us_status_id, callback=self.post_save, precall=self.pre_save) + + user_stories = self.get_queryset().filter(id__in=[i.id for i in user_stories]) user_stories_serialized = self.get_serializer_class()(user_stories, many=True) + return response.Ok(user_stories_serialized.data) - return response.BadRequest(serializer.errors) + return response.BadRequest(validator.errors) @list_route(methods=["POST"]) def bulk_update_milestone(self, request, **kwargs): - serializer = serializers.UpdateMilestoneBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.UpdateMilestoneBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data project = get_object_or_404(Project, pk=data["project_id"]) milestone = get_object_or_404(Milestone, pk=data["milestone_id"]) @@ -291,11 +280,11 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi return response.NoContent() def _bulk_update_order(self, order_field, request, **kwargs): - serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.UpdateUserStoriesOrderBulkValidator(data=request.DATA) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data project = get_object_or_404(Project, pk=data["project_id"]) self.check_permissions(request, "bulk_update_order", project) diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index 236a54d8..ef15eec6 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -16,96 +16,111 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from collections import ChainMap - -from django.contrib.auth import get_user_model -from django.utils.translation import ugettext_lazy as _ - from taiga.base.api import serializers -from taiga.base.api.utils import get_object_or_404 -from taiga.base.fields import PickledObjectField -from taiga.base.fields import PgArrayField +from taiga.base.fields import Field, MethodField from taiga.base.neighbors import NeighborsSerializerMixin -from taiga.base.utils import json from taiga.mdrender.service import render as mdrender -from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin -from taiga.projects.milestones.validators import SprintExistsValidator -from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin -from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin -from taiga.projects.models import Project, UserStoryStatus -from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer -from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer -from taiga.projects.notifications.validators import WatchersValidator -from taiga.projects.serializers import BasicUserStoryStatusSerializer -from taiga.projects.tagging.fields import TagsAndTagsColorsField -from taiga.projects.userstories.validators import UserStoryExistsValidator -from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator +from taiga.projects.attachments.serializers import BasicAttachmentsInfoSerializerMixin +from taiga.projects.mixins.serializers import OwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import AssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import StatusExtraInfoSerializerMixin +from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin -from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin - -from taiga.users.serializers import UserBasicInfoSerializer -from taiga.users.serializers import ListUserBasicInfoSerializer -from taiga.users.services import get_photo_or_gravatar_url -from taiga.users.services import get_big_photo_or_gravatar_url - -from . import models - -import serpy -class RolePointsField(serializers.WritableField): - def to_native(self, obj): - return {str(o.role.id): o.points.id for o in obj.all()} +class OriginIssueSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() - def from_native(self, obj): - if isinstance(obj, dict): - return obj - return json.loads(obj) + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) -class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin, - EditableWatchedResourceModelSerializer, serializers.ModelSerializer): - tags = TagsAndTagsColorsField(default=[], required=False) - external_reference = PgArrayField(required=False) - points = RolePointsField(source="role_points", required=False) - total_points = serializers.SerializerMethodField("get_total_points") - comment = serializers.SerializerMethodField("get_comment") - milestone_slug = serializers.SerializerMethodField("get_milestone_slug") - milestone_name = serializers.SerializerMethodField("get_milestone_name") - origin_issue = serializers.SerializerMethodField("get_origin_issue") - blocked_note_html = serializers.SerializerMethodField("get_blocked_note_html") - description_html = serializers.SerializerMethodField("get_description_html") - status_extra_info = BasicUserStoryStatusSerializer(source="status", required=False, read_only=True) - assigned_to_extra_info = UserBasicInfoSerializer(source="assigned_to", required=False, read_only=True) - owner_extra_info = UserBasicInfoSerializer(source="owner", required=False, read_only=True) - tribe_gig = PickledObjectField(required=False) +class UserStoryListSerializer( + VoteResourceSerializerMixin, WatchedResourceSerializer, + OwnerExtraInfoSerializerMixin, AssignedToExtraInfoSerializerMixin, + StatusExtraInfoSerializerMixin, BasicAttachmentsInfoSerializerMixin, + serializers.LightSerializer): - class Meta: - model = models.UserStory - depth = 0 - read_only_fields = ('created_date', 'modified_date', 'owner') + id = Field() + ref = Field() + milestone = Field(attr="milestone_id") + milestone_slug = MethodField() + milestone_name = MethodField() + project = Field(attr="project_id") + is_closed = Field() + points = MethodField() + backlog_order = Field() + sprint_order = Field() + kanban_order = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + generated_from_issue = Field(attr="generated_from_issue_id") + external_reference = Field() + tribe_gig = Field() + version = Field() + watchers = Field() + is_blocked = Field() + blocked_note = Field() + tags = Field() + total_points = MethodField() + comment = MethodField() + origin_issue = OriginIssueSerializer(attr="generated_from_issue") + + tasks = MethodField() + + def get_milestone_slug(self, obj): + return obj.milestone.slug if obj.milestone else None + + def get_milestone_name(self, obj): + return obj.milestone.name if obj.milestone else None def get_total_points(self, obj): - return obj.get_total_points() + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr + + def get_points(self, obj): + assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" + if obj.role_points_attr is None: + return {} + + return obj.role_points_attr + + def get_comment(self, obj): + return "" + + def get_tasks(self, obj): + include_tasks = getattr(obj, "include_tasks", False) + + if include_tasks: + assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute" + + if not include_tasks or obj.tasks_attr is None: + return [] + + return obj.tasks_attr + + +class UserStorySerializer(UserStoryListSerializer): + comment = MethodField() + origin_issue = MethodField() + blocked_note_html = MethodField() + description = Field() + description_html = MethodField() def get_comment(self, obj): # NOTE: This method and field is necessary to historical comments work return "" - def get_milestone_slug(self, obj): - if obj.milestone: - return obj.milestone.slug - else: - return None - - def get_milestone_name(self, obj): - if obj.milestone: - return obj.milestone.name - else: - return None - def get_origin_issue(self, obj): if obj.generated_from_issue: return { @@ -122,142 +137,5 @@ class UserStorySerializer(WatchersValidator, VoteResourceSerializerMixin, return mdrender(obj.project, obj.description) -class ListOriginIssueSerializer(serializers.LightSerializer): - id = serpy.Field() - ref = serpy.Field() - subject = serpy.Field() - - def to_value(self, instance): - if instance is None: - return None - - return super().to_value(instance) - - -class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, - ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin, - serializers.LightSerializer): - - id = serpy.Field() - ref = serpy.Field() - milestone = serpy.Field(attr="milestone_id") - milestone_slug = serpy.MethodField() - milestone_name = serpy.MethodField() - project = serpy.Field(attr="project_id") - is_closed = serpy.Field() - points = serpy.MethodField() - backlog_order = serpy.Field() - sprint_order = serpy.Field() - kanban_order = serpy.Field() - created_date = serpy.Field() - modified_date = serpy.Field() - finish_date = serpy.Field() - subject = serpy.Field() - client_requirement = serpy.Field() - team_requirement = serpy.Field() - generated_from_issue = serpy.Field(attr="generated_from_issue_id") - external_reference = serpy.Field() - tribe_gig = serpy.Field() - version = serpy.Field() - watchers = serpy.Field() - is_blocked = serpy.Field() - blocked_note = serpy.Field() - tags = serpy.Field() - total_points = serpy.MethodField() - comment = serpy.MethodField("get_comment") - origin_issue = ListOriginIssueSerializer(attr="generated_from_issue") - - tasks = serpy.MethodField() - - def get_milestone_slug(self, obj): - return obj.milestone.slug if obj.milestone else None - - def get_milestone_name(self, obj): - return obj.milestone.name if obj.milestone else None - - def get_total_points(self, obj): - assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" - return obj.total_points_attr - - def get_points(self, obj): - assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" - if obj.role_points_attr is None: - return {} - - return dict(ChainMap(*obj.role_points_attr)) - - def get_comment(self, obj): - return "" - - def get_tasks(self, obj): - include_tasks = getattr(obj, "include_tasks", False) - - if include_tasks: - assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute" - - if not include_tasks or obj.tasks_attr is None: - return [] - - return obj.tasks_attr - - class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): - def serialize_neighbor(self, neighbor): - if neighbor: - return NeighborUserStorySerializer(neighbor).data - return None - - -class NeighborUserStorySerializer(serializers.ModelSerializer): - class Meta: - model = models.UserStory - fields = ("id", "ref", "subject") - depth = 0 - - -class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, - serializers.Serializer): - project_id = serializers.IntegerField() - status_id = serializers.IntegerField(required=False) - bulk_stories = serializers.CharField() - - -## Order bulk serializers - -class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, serializers.Serializer): - us_id = serializers.IntegerField() - order = serializers.IntegerField() - - -class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, - serializers.Serializer): - project_id = serializers.IntegerField() - bulk_stories = _UserStoryOrderBulkSerializer(many=True) - - -## Milestone bulk serializers - -class _UserStoryMilestoneBulkSerializer(UserStoryExistsValidator, serializers.Serializer): - us_id = serializers.IntegerField() - - -class UpdateMilestoneBulkSerializer(ProjectExistsValidator, SprintExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - milestone_id = serializers.IntegerField() - bulk_stories = _UserStoryMilestoneBulkSerializer(many=True) - - def validate(self, data): - """ - All the userstories and the milestone are from the same project - """ - user_story_ids = [us["us_id"] for us in data["bulk_stories"]] - project = get_object_or_404(Project, pk=data["project_id"]) - - if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids): - raise serializers.ValidationError("all the user stories must be from the same project") - - if project.milestones.filter(id=data["milestone_id"]).count() != 1: - raise serializers.ValidationError("the milestone isn't valid for the project") - - return data + pass diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py index 809248f7..87b8e094 100644 --- a/taiga/projects/userstories/utils.py +++ b/taiga/projects/userstories/utils.py @@ -17,6 +17,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from taiga.projects.attachments.utils import attach_basic_attachments +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset +from taiga.projects.votes.utils import attach_total_voters_to_queryset +from taiga.projects.votes.utils import attach_is_voter_to_queryset + def attach_total_points(queryset, as_field="total_points_attr"): """Attach total of point values to each object of the queryset. @@ -28,7 +35,7 @@ def attach_total_points(queryset, as_field="total_points_attr"): """ model = queryset.model sql = """SELECT SUM(projects_points.value) - FROM userstories_rolepoints + FROM userstories_rolepoints INNER JOIN projects_points ON userstories_rolepoints.points_id = projects_points.id WHERE userstories_rolepoints.user_story_id = {tbl}.id""" @@ -46,10 +53,15 @@ def attach_role_points(queryset, as_field="role_points_attr"): :return: Queryset object with the additional `as_field` field. """ model = queryset.model - sql = """SELECT json_agg((userstories_rolepoints.role_id, userstories_rolepoints.points_id)) - FROM userstories_rolepoints + sql = """SELECT FORMAT('{{%%s}}', + STRING_AGG(format( + '"%%s":%%s', + TO_JSON(userstories_rolepoints.role_id), + TO_JSON(userstories_rolepoints.points_id) + ), ',') + )::json + FROM userstories_rolepoints WHERE userstories_rolepoints.user_story_id = {tbl}.id""" - sql = sql.format(tbl=model._meta.db_table) queryset = queryset.extra(select={as_field: sql}) return queryset @@ -82,3 +94,23 @@ def attach_tasks(queryset, as_field="tasks_attr"): sql = sql.format(tbl=model._meta.db_table) queryset = queryset.extra(select={as_field: sql}) return queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False, include_tasks=False): + queryset = attach_total_points(queryset) + queryset = attach_role_points(queryset) + + if include_attachments: + queryset = attach_basic_attachments(queryset) + queryset = queryset.extra(select={"include_attachments": "True"}) + + if include_tasks: + queryset = attach_tasks(queryset) + queryset = queryset.extra(select={"include_tasks": "True"}) + + queryset = attach_total_voters_to_queryset(queryset) + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_voter_to_queryset(queryset, user) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index 5ad5e7f4..4ea0b24a 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -19,9 +19,21 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers +from taiga.base.api import validators +from taiga.base.api.utils import get_object_or_404 +from taiga.base.fields import PgArrayField +from taiga.base.fields import PickledObjectField +from taiga.projects.milestones.validators import MilestoneExistsValidator +from taiga.projects.models import Project +from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer +from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.tagging.fields import TagsAndTagsColorsField +from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator from . import models +import json + class UserStoryExistsValidator: def validate_us_id(self, attrs, source): @@ -30,3 +42,72 @@ class UserStoryExistsValidator: msg = _("There's no user story with that id") raise serializers.ValidationError(msg) return attrs + + +class RolePointsField(serializers.WritableField): + def to_native(self, obj): + return {str(o.role.id): o.points.id for o in obj.all()} + + def from_native(self, obj): + if isinstance(obj, dict): + return obj + return json.loads(obj) + + +class UserStoryValidator(WatchersValidator, EditableWatchedResourceSerializer, validators.ModelValidator): + tags = TagsAndTagsColorsField(default=[], required=False) + external_reference = PgArrayField(required=False) + points = RolePointsField(source="role_points", required=False) + tribe_gig = PickledObjectField(required=False) + + class Meta: + model = models.UserStory + depth = 0 + read_only_fields = ('created_date', 'modified_date', 'owner') + + +class UserStoriesBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + status_id = serializers.IntegerField(required=False) + bulk_stories = serializers.CharField() + + +# Order bulk validators + +class _UserStoryOrderBulkValidator(UserStoryExistsValidator, validators.Validator): + us_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateUserStoriesOrderBulkValidator(ProjectExistsValidator, UserStoryStatusExistsValidator, + validators.Validator): + project_id = serializers.IntegerField() + bulk_stories = _UserStoryOrderBulkValidator(many=True) + + +# Milestone bulk validators + +class _UserStoryMilestoneBulkValidator(UserStoryExistsValidator, validators.Validator): + us_id = serializers.IntegerField() + + +class UpdateMilestoneBulkValidator(ProjectExistsValidator, MilestoneExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + milestone_id = serializers.IntegerField() + bulk_stories = _UserStoryMilestoneBulkValidator(many=True) + + def validate(self, data): + """ + All the userstories and the milestone are from the same project + """ + user_story_ids = [us["us_id"] for us in data["bulk_stories"]] + project = get_object_or_404(Project, pk=data["project_id"]) + + if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids): + raise serializers.ValidationError("all the user stories must be from the same project") + + if project.milestones.filter(id=data["milestone_id"]).count() != 1: + raise serializers.ValidationError("the milestone isn't valid for the project") + + return data diff --git a/taiga/projects/utils.py b/taiga/projects/utils.py index a05d8476..ee552136 100644 --- a/taiga/projects/utils.py +++ b/taiga/projects/utils.py @@ -375,7 +375,7 @@ def attach_private_projects_same_owner(queryset, user, as_field="private_project """ model = queryset.model if user is None or user.is_anonymous(): - sql = """SELECT '0'""" + sql = """SELECT 0""" else: sql = """SELECT COUNT(id) FROM projects_project p_aux @@ -399,7 +399,7 @@ def attach_public_projects_same_owner(queryset, user, as_field="public_projects_ """ model = queryset.model if user is None or user.is_anonymous(): - sql = """SELECT '0'""" + sql = """SELECT 0""" else: sql = """SELECT COUNT(id) FROM projects_project p_aux diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index 05866b66..c8ab21bb 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -16,11 +16,42 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +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.fields import JsonField +from taiga.base.fields import PgArrayField +from taiga.users.validators import RoleExistsValidator + +from .tagging.fields import TagsField from . import models +from . import services + + +class DuplicatedNameInProjectValidator: + + def validate_name(self, attrs, source): + """ + Check the points name is not duplicated in the project on creation + """ + model = self.opts.model + qs = None + # If the object exists: + if self.object and attrs.get(source, None): + qs = model.objects.filter( + project=self.object.project, + name=attrs[source]).exclude(id=self.object.id) + + if not self.object and attrs.get("project", None) and attrs.get(source, None): + qs = model.objects.filter(project=attrs["project"], name=attrs[source]) + + if qs and qs.exists(): + raise serializers.ValidationError(_("Name duplicated for the project")) + + return attrs class ProjectExistsValidator: @@ -48,3 +79,170 @@ class TaskStatusExistsValidator: msg = _("There's no task status with that id") raise serializers.ValidationError(msg) return attrs + + +###################################################### +# Custom values for selectors +###################################################### + +class PointsValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Points + + +class UserStoryStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.UserStoryStatus + + +class TaskStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.TaskStatus + + +class SeverityValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Severity + + +class PriorityValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.Priority + + +class IssueStatusValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.IssueStatus + + +class IssueTypeValidator(DuplicatedNameInProjectValidator, validators.ModelValidator): + class Meta: + model = models.IssueType + + +###################################################### +# Members +###################################################### + +class MembershipValidator(validators.ModelValidator): + email = serializers.EmailField(required=True) + + 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") + + def validate_email(self, attrs, source): + project = attrs.get("project", None) + if project is None: + project = self.object.project + + email = attrs[source] + + qs = models.Membership.objects.all() + + # If self.object is not None, the serializer is in update + # mode, and for it, it should exclude self. + if self.object: + qs = qs.exclude(pk=self.object.pk) + + qs = qs.filter(Q(project_id=project.id, user__email=email) | + Q(project_id=project.id, email=email)) + + if qs.count() > 0: + raise serializers.ValidationError(_("Email address is already taken")) + + return attrs + + def validate_role(self, attrs, source): + project = attrs.get("project", None) + if project is None: + project = self.object.project + + role = attrs[source] + + if project.roles.filter(id=role.id).count() == 0: + raise serializers.ValidationError(_("Invalid role for the project")) + + return attrs + + def validate_is_admin(self, attrs, source): + project = attrs.get("project", None) + if project is None: + project = self.object.project + + if (self.object and self.object.user): + if self.object.user.id == project.owner_id and not attrs[source]: + raise serializers.ValidationError(_("The project owner must be admin.")) + + if not services.project_has_valid_admins(project, exclude_user=self.object.user): + raise serializers.ValidationError( + _("At least one user must be an active admin for this project.") + ) + + return attrs + + +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",) + + +class MemberBulkValidator(RoleExistsValidator, validators.Validator): + email = serializers.EmailField() + role_id = serializers.IntegerField() + + +class MembersBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + bulk_memberships = MemberBulkValidator(many=True) + invitation_extra_text = serializers.CharField(required=False, max_length=255) + + +###################################################### +# Projects +###################################################### + +class ProjectValidator(validators.ModelValidator): + anon_permissions = PgArrayField(required=False) + public_permissions = PgArrayField(required=False) + tags = TagsField(default=[], required=False) + + class Meta: + model = models.Project + read_only_fields = ("created_date", "modified_date", "slug", "blocked_code", "owner") + + +###################################################### +# Project Templates +###################################################### + +class ProjectTemplateValidator(validators.ModelValidator): + default_options = JsonField(required=False, label=_("Default options")) + us_statuses = JsonField(required=False, label=_("User story's statuses")) + points = JsonField(required=False, label=_("Points")) + task_statuses = JsonField(required=False, label=_("Task's statuses")) + issue_statuses = JsonField(required=False, label=_("Issue's statuses")) + issue_types = JsonField(required=False, label=_("Issue's types")) + priorities = JsonField(required=False, label=_("Priorities")) + severities = JsonField(required=False, label=_("Severities")) + roles = JsonField(required=False, label=_("Roles")) + + class Meta: + model = models.ProjectTemplate + read_only_fields = ("created_date", "modified_date") + + +###################################################### +# Project order bulk serializers +###################################################### + +class UpdateProjectOrderBulkValidator(ProjectExistsValidator, validators.Validator): + project_id = serializers.IntegerField() + order = serializers.IntegerField() diff --git a/taiga/projects/votes/mixins/serializers.py b/taiga/projects/votes/mixins/serializers.py index fc7a988e..9f9d1049 100644 --- a/taiga/projects/votes/mixins/serializers.py +++ b/taiga/projects/votes/mixins/serializers.py @@ -16,12 +16,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import serpy - from taiga.base.api import serializers +from taiga.base.fields import MethodField -class BaseVoteResourceSerializerMixin(object): +class VoteResourceSerializerMixin(serializers.LightSerializer): + is_voter = MethodField() + total_voters = MethodField() + def get_is_voter(self, obj): # The "is_voted" attribute is attached in the get_queryset of the viewset. return getattr(obj, "is_voter", False) or False @@ -29,13 +31,3 @@ class BaseVoteResourceSerializerMixin(object): def get_total_voters(self, obj): # The "total_voters" attribute is attached in the get_queryset of the viewset. return getattr(obj, "total_voters", 0) or 0 - - -class VoteResourceSerializerMixin(BaseVoteResourceSerializerMixin, serializers.ModelSerializer): - is_voter = serializers.SerializerMethodField("get_is_voter") - total_voters = serializers.SerializerMethodField("get_total_voters") - - -class ListVoteResourceSerializerMixin(BaseVoteResourceSerializerMixin, serpy.Serializer): - is_voter = serpy.MethodField("get_is_voter") - total_voters = serpy.MethodField("get_total_voters") diff --git a/taiga/projects/votes/mixins/viewsets.py b/taiga/projects/votes/mixins/viewsets.py index 2456e375..50490ba7 100644 --- a/taiga/projects/votes/mixins/viewsets.py +++ b/taiga/projects/votes/mixins/viewsets.py @@ -39,14 +39,6 @@ class VotedResourceMixin: def pre_conditions_on_save(self, obj) """ - def attach_votes_attrs_to_queryset(self, queryset): - qs = attach_total_voters_to_queryset(queryset) - - if self.request.user.is_authenticated(): - qs = attach_is_voter_to_queryset(self.request.user, qs) - - return qs - @detail_route(methods=["POST"]) def upvote(self, request, pk=None): obj = self.get_object() diff --git a/taiga/projects/votes/utils.py b/taiga/projects/votes/utils.py index 291ee284..077abd46 100644 --- a/taiga/projects/votes/utils.py +++ b/taiga/projects/votes/utils.py @@ -48,7 +48,7 @@ def attach_total_voters_to_queryset(queryset, as_field="total_voters"): return qs -def attach_is_voter_to_queryset(user, queryset, as_field="is_voter"): +def attach_is_voter_to_queryset(queryset, user, as_field="is_voter"): """Attach is_vote boolean to each object of the queryset. Because of laziness of vote objects creation, this makes much simpler and more efficient to @@ -57,22 +57,26 @@ def attach_is_voter_to_queryset(user, queryset, as_field="is_voter"): (The other way was to do it in the serializer with some try/except blocks and additional queries) - :param user: A users.User object model :param queryset: A Django queryset object. + :param user: A users.User object model :param as_field: Attach the boolean as an attribute with this name. :return: Queryset object with the additional `as_field` field. """ model = queryset.model type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) - sql = ("""SELECT CASE WHEN (SELECT count(*) - FROM votes_vote - WHERE votes_vote.content_type_id = {type_id} - AND votes_vote.object_id = {tbl}.id - AND votes_vote.user_id = {user_id}) > 0 - THEN TRUE - ELSE FALSE - END""") - sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + if user is None or user.is_anonymous(): + sql = """SELECT false""" + else: + sql = ("""SELECT CASE WHEN (SELECT count(*) + FROM votes_vote + WHERE votes_vote.content_type_id = {type_id} + AND votes_vote.object_id = {tbl}.id + AND votes_vote.user_id = {user_id}) > 0 + THEN TRUE + ELSE FALSE + END""") + sql = sql.format(type_id=type.id, tbl=model._meta.db_table, user_id=user.id) + qs = queryset.extra(select={as_field: sql}) return qs diff --git a/taiga/projects/wiki/api.py b/taiga/projects/wiki/api.py index 807d86c6..d6a3d44a 100644 --- a/taiga/projects/wiki/api.py +++ b/taiga/projects/wiki/api.py @@ -24,7 +24,6 @@ from taiga.base import response from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin -from taiga.base.api.permissions import IsAuthenticated from taiga.base.api.utils import get_object_or_404 from taiga.base.decorators import list_route @@ -42,6 +41,8 @@ from taiga.projects.occ import OCCResourceMixin from . import models from . import permissions from . import serializers +from . import validators +from . import utils as wiki_utils class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, @@ -49,6 +50,7 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, model = models.WikiPage serializer_class = serializers.WikiPageSerializer + validator_class = validators.WikiPageValidator permission_classes = (permissions.WikiPagePermission,) filter_backends = (filters.CanViewWikiPagesFilterBackend,) filter_fields = ("project", "slug") @@ -56,7 +58,7 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, def get_queryset(self): qs = super().get_queryset() - qs = self.attach_watchers_attrs_to_queryset(qs) + qs = wiki_utils.attach_extra_info(qs, user=self.request.user) return qs @list_route(methods=["GET"]) @@ -100,6 +102,7 @@ class WikiWatchersViewSet(WatchersViewSetMixin, ModelListViewSet): class WikiLinkViewSet(BlockedByProjectMixin, ModelCrudViewSet): model = models.WikiLink serializer_class = serializers.WikiLinkSerializer + validator_class = validators.WikiLinkValidator permission_classes = (permissions.WikiLinkPermission,) filter_backends = (filters.CanViewWikiPagesFilterBackend,) filter_fields = ["project"] @@ -120,7 +123,7 @@ class WikiLinkViewSet(BlockedByProjectMixin, ModelCrudViewSet): wiki_page, created = models.WikiPage.objects.get_or_create( slug=wiki_link.href, project=wiki_link.project, - defaults={"owner": self.request.user,"last_modifier": self.request.user}) + defaults={"owner": self.request.user, "last_modifier": self.request.user}) if created: # Creaste the new history entre, sSet watcher for the new wiki page diff --git a/taiga/projects/wiki/serializers.py b/taiga/projects/wiki/serializers.py index 16de19df..a7e36c60 100644 --- a/taiga/projects/wiki/serializers.py +++ b/taiga/projects/wiki/serializers.py @@ -17,21 +17,26 @@ # along with this program. If not, see . from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField from taiga.projects.history import services as history_service -from taiga.projects.notifications.mixins import WatchedResourceModelSerializer -from taiga.projects.notifications.validators import WatchersValidator +from taiga.projects.notifications.mixins import WatchedResourceSerializer from taiga.mdrender.service import render as mdrender -from . import models +class WikiPageSerializer(WatchedResourceSerializer, serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + slug = Field() + content = Field() + owner = Field(attr="owner_id") + last_modifier = Field(attr="last_modifier_id") + created_date = Field() + modified_date = Field() -class WikiPageSerializer(WatchersValidator, WatchedResourceModelSerializer, serializers.ModelSerializer): - html = serializers.SerializerMethodField("get_html") - editions = serializers.SerializerMethodField("get_editions") + html = MethodField() + editions = MethodField() - class Meta: - model = models.WikiPage - read_only_fields = ('modified_date', 'created_date', 'owner') + version = Field() def get_html(self, obj): return mdrender(obj.project, obj.content) @@ -40,7 +45,9 @@ class WikiPageSerializer(WatchersValidator, WatchedResourceModelSerializer, seri return history_service.get_history_queryset_by_model_instance(obj).count() + 1 # +1 for creation -class WikiLinkSerializer(serializers.ModelSerializer): - class Meta: - model = models.WikiLink - read_only_fields = ('href',) +class WikiLinkSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + title = Field() + href = Field() + order = Field() diff --git a/taiga/projects/wiki/utils.py b/taiga/projects/wiki/utils.py new file mode 100644 index 00000000..ecbf7602 --- /dev/null +++ b/taiga/projects/wiki/utils.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# 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 +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.projects.notifications.utils import attach_watchers_to_queryset +from taiga.projects.notifications.utils import attach_total_watchers_to_queryset +from taiga.projects.notifications.utils import attach_is_watcher_to_queryset + + +def attach_extra_info(queryset, user=None, include_attachments=False): + queryset = attach_watchers_to_queryset(queryset) + queryset = attach_total_watchers_to_queryset(queryset) + queryset = attach_is_watcher_to_queryset(queryset, user) + return queryset diff --git a/taiga/projects/wiki/validators.py b/taiga/projects/wiki/validators.py new file mode 100644 index 00000000..033fac1b --- /dev/null +++ b/taiga/projects/wiki/validators.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import validators +from taiga.projects.notifications.validators import WatchersValidator + +from . import models + + +class WikiPageValidator(WatchersValidator, validators.ModelValidator): + class Meta: + model = models.WikiPage + read_only_fields = ('modified_date', 'created_date', 'owner') + + +class WikiLinkValidator(validators.ModelValidator): + class Meta: + model = models.WikiLink + read_only_fields = ('href',) diff --git a/taiga/searches/serializers.py b/taiga/searches/serializers.py index edc2d1ca..e96e1131 100644 --- a/taiga/searches/serializers.py +++ b/taiga/searches/serializers.py @@ -16,37 +16,48 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.projects.issues.serializers import IssueSerializer -from taiga.projects.userstories.serializers import UserStorySerializer -from taiga.projects.tasks.serializers import TaskSerializer -from taiga.projects.wiki.serializers import WikiPageSerializer - -from taiga.projects.issues.models import Issue -from taiga.projects.userstories.models import UserStory -from taiga.projects.tasks.models import Task -from taiga.projects.wiki.models import WikiPage +from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField -class IssueSearchResultsSerializer(IssueSerializer): - class Meta: - model = Issue - fields = ('id', 'ref', 'subject', 'status', 'assigned_to') +class IssueSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") -class TaskSearchResultsSerializer(TaskSerializer): - class Meta: - model = Task - fields = ('id', 'ref', 'subject', 'status', 'assigned_to') +class TaskSearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + assigned_to = Field(attr="assigned_to_id") -class UserStorySearchResultsSerializer(UserStorySerializer): - class Meta: - model = UserStory - fields = ('id', 'ref', 'subject', 'status', 'total_points', - 'milestone_name', 'milestone_slug') +class UserStorySearchResultsSerializer(serializers.LightSerializer): + id = Field() + ref = Field() + subject = Field() + status = Field(attr="status_id") + total_points = MethodField() + milestone_name = MethodField() + milestone_slug = MethodField() + + def get_milestone_name(self, obj): + return obj.milestone.name if obj.milestone else None + + def get_milestone_slug(self, obj): + return obj.milestone.slug if obj.milestone else None + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), \ + "instance must have a total_points_attr attribute" + + return obj.total_points_attr -class WikiPageSearchResultsSerializer(WikiPageSerializer): - class Meta: - model = WikiPage - fields = ('id', 'slug') +class WikiPageSearchResultsSerializer(serializers.LightSerializer): + id = Field() + slug = Field() diff --git a/taiga/searches/services.py b/taiga/searches/services.py index f393844f..4dcda86f 100644 --- a/taiga/searches/services.py +++ b/taiga/searches/services.py @@ -19,6 +19,7 @@ from django.apps import apps from django.conf import settings from taiga.base.utils.db import to_tsquery +from taiga.projects.userstories.utils import attach_total_points MAX_RESULTS = getattr(settings, "SEARCHES_MAX_RESULTS", 150) @@ -30,11 +31,13 @@ def search_user_stories(project, text): "coalesce(userstories_userstory.description, '')) " "@@ to_tsquery('english_nostop', %s)") - if text: - return (model_cls.objects.extra(where=[where_clause], params=[to_tsquery(text)]) - .filter(project_id=project.pk)[:MAX_RESULTS]) + queryset = model_cls.objects.filter(project_id=project.pk) - return model_cls.objects.filter(project_id=project.pk)[:MAX_RESULTS] + if text: + queryset = queryset.extra(where=[where_clause], params=[to_tsquery(text)]) + + queryset = attach_total_points(queryset) + return queryset[:MAX_RESULTS] def search_tasks(project, text): diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py index b0bf8e13..3e3bd6f4 100644 --- a/taiga/timeline/api.py +++ b/taiga/timeline/api.py @@ -18,10 +18,8 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.apps import apps from taiga.base import response -from taiga.base.api.utils import get_object_or_404 from taiga.base.api import ReadOnlyListViewSet from . import serializers @@ -36,7 +34,7 @@ class TimelineViewSet(ReadOnlyListViewSet): def get_content_type(self): app_name, model = self.content_type.split(".", 1) - return get_object_or_404(ContentType, app_label=app_name, model=model) + return ContentType.objects.get_by_natural_key(app_name, model) def get_queryset(self): ct = self.get_content_type() diff --git a/taiga/timeline/migrations/0005_auto_20160706_0723.py b/taiga/timeline/migrations/0005_auto_20160706_0723.py new file mode 100644 index 00000000..7ac9fa9c --- /dev/null +++ b/taiga/timeline/migrations/0005_auto_20160706_0723.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-07-06 07:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('timeline', '0004_auto_20150603_1312'), + ] + + operations = [ + migrations.AlterField( + model_name='timeline', + name='created', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + ] diff --git a/taiga/timeline/models.py b/taiga/timeline/models.py index c71188f7..ebee7da5 100644 --- a/taiga/timeline/models.py +++ b/taiga/timeline/models.py @@ -20,13 +20,12 @@ from django.db import models from django_pgjson.fields import JsonField from django.utils import timezone -from django.core.exceptions import ValidationError - from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey from taiga.projects.models import Project + class Timeline(models.Model): content_type = models.ForeignKey(ContentType, related_name="content_type_timelines") object_id = models.PositiveIntegerField() @@ -36,12 +35,11 @@ class Timeline(models.Model): project = models.ForeignKey(Project, null=True) data = JsonField() data_content_type = models.ForeignKey(ContentType, related_name="data_timelines") - created = models.DateTimeField(default=timezone.now) + created = models.DateTimeField(default=timezone.now, db_index=True) class Meta: index_together = [('content_type', 'object_id', 'namespace'), ] - # Register all implementations from .timeline_implementations import * diff --git a/taiga/timeline/serializers.py b/taiga/timeline/serializers.py index a6be6944..07b1985a 100644 --- a/taiga/timeline/serializers.py +++ b/taiga/timeline/serializers.py @@ -16,26 +16,32 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.apps import apps from django.contrib.auth import get_user_model -from django.forms import widgets from taiga.base.api import serializers -from taiga.base.fields import JsonField +from taiga.base.fields import Field, MethodField from taiga.users.services import get_photo_or_gravatar_url, get_big_photo_or_gravatar_url from . import models -from . import service -class TimelineSerializer(serializers.ModelSerializer): +class TimelineSerializer(serializers.LightSerializer): data = serializers.SerializerMethodField("get_data") + id = Field() + content_type = Field(attr="content_type_id") + object_id = Field() + namespace = Field() + event_type = Field() + project = Field(attr="project_id") + data = MethodField() + data_content_type = Field(attr="data_content_type_id") + created = Field() class Meta: model = models.Timeline def get_data(self, obj): - #Updates the data user info saved if the user exists + # Updates the data user info saved if the user exists if hasattr(obj, "_prefetched_user"): user = obj._prefetched_user else: diff --git a/taiga/timeline/service.py b/taiga/timeline/service.py index f99f795e..d3e81976 100644 --- a/taiga/timeline/service.py +++ b/taiga/timeline/service.py @@ -27,33 +27,32 @@ from functools import partial, wraps from taiga.base.utils.db import get_typename_for_model_class from taiga.celery import app -from taiga.users.services import get_photo_or_gravatar_url, get_big_photo_or_gravatar_url _timeline_impl_map = {} -def _get_impl_key_from_model(model:Model, event_type:str): +def _get_impl_key_from_model(model: Model, event_type: str): if issubclass(model, Model): typename = get_typename_for_model_class(model) return _get_impl_key_from_typename(typename, event_type) raise Exception("Not valid model parameter") -def _get_impl_key_from_typename(typename:str, event_type:str): +def _get_impl_key_from_typename(typename: str, event_type: str): if isinstance(typename, str): return "{0}.{1}".format(typename, event_type) raise Exception("Not valid typename parameter") -def build_user_namespace(user:object): +def build_user_namespace(user: object): return "{0}:{1}".format("user", user.id) -def build_project_namespace(project:object): +def build_project_namespace(project: object): return "{0}:{1}".format("project", project.id) -def _add_to_object_timeline(obj:object, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def _add_to_object_timeline(obj: object, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): assert isinstance(obj, Model), "obj must be a instance of Model" assert isinstance(instance, Model), "instance must be a instance of Model" from .models import Timeline @@ -75,12 +74,12 @@ def _add_to_object_timeline(obj:object, instance:object, event_type:str, created ) -def _add_to_objects_timeline(objects, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def _add_to_objects_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): for obj in objects: _add_to_object_timeline(obj, instance, event_type, created_datetime, namespace, extra_data) -def _push_to_timeline(objects, instance:object, event_type:str, created_datetime:object, namespace:str="default", extra_data:dict={}): +def _push_to_timeline(objects, instance: object, event_type: str, created_datetime: object, namespace: str="default", extra_data: dict={}): if isinstance(objects, Model): _add_to_object_timeline(objects, instance, event_type, created_datetime, namespace, extra_data) elif isinstance(objects, QuerySet) or isinstance(objects, list): @@ -111,10 +110,10 @@ def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id except projectModel.DoesNotExist: return - ## Project timeline + # Project timeline _push_to_timeline(project, obj, event_type, created_datetime, - namespace=build_project_namespace(project), - extra_data=extra_data) + namespace=build_project_namespace(project), + extra_data=extra_data) project.refresh_totals() @@ -122,14 +121,14 @@ def push_to_timelines(project_id, user_id, obj_app_label, obj_model_name, obj_id related_people = obj.get_related_people() _push_to_timeline(related_people, obj, event_type, created_datetime, - namespace=build_user_namespace(user), - extra_data=extra_data) + namespace=build_user_namespace(user), + extra_data=extra_data) else: # Actions not related with a project - ## - Me + # - Me _push_to_timeline(user, obj, event_type, created_datetime, - namespace=build_user_namespace(user), - extra_data=extra_data) + namespace=build_user_namespace(user), + extra_data=extra_data) def get_timeline(obj, namespace=None): @@ -141,7 +140,6 @@ def get_timeline(obj, namespace=None): if namespace is not None: timeline = timeline.filter(namespace=namespace) - timeline = timeline.select_related("project") timeline = timeline.order_by("-created", "-id") return timeline @@ -156,22 +154,22 @@ def filter_timeline_for_user(timeline, user): # Filtering private project with some public parts content_types = { - "view_project": ContentType.objects.get(app_label="projects", model="project"), - "view_milestones": ContentType.objects.get(app_label="milestones", model="milestone"), - "view_us": ContentType.objects.get(app_label="userstories", model="userstory"), - "view_tasks": ContentType.objects.get(app_label="tasks", model="task"), - "view_issues": ContentType.objects.get(app_label="issues", model="issue"), - "view_wiki_pages": ContentType.objects.get(app_label="wiki", model="wikipage"), - "view_wiki_links": ContentType.objects.get(app_label="wiki", model="wikilink"), + "view_project": ContentType.objects.get_by_natural_key("projects", "project"), + "view_milestones": ContentType.objects.get_by_natural_key("milestones", "milestone"), + "view_us": ContentType.objects.get_by_natural_key("userstories", "userstory"), + "view_tasks": ContentType.objects.get_by_natural_key("tasks", "task"), + "view_issues": ContentType.objects.get_by_natural_key("issues", "issue"), + "view_wiki_pages": ContentType.objects.get_by_natural_key("wiki", "wikipage"), + "view_wiki_links": ContentType.objects.get_by_natural_key("wiki", "wikilink"), } for content_type_key, content_type in content_types.items(): tl_filter |= Q(project__is_private=True, - project__anon_permissions__contains=[content_type_key], - data_content_type=content_type) + project__anon_permissions__contains=[content_type_key], + data_content_type=content_type) # There is no specific permission for seeing new memberships - membership_content_type = ContentType.objects.get(app_label="projects", model="membership") + membership_content_type = ContentType.objects.get_by_natural_key(app_label="projects", model="membership") tl_filter |= Q(project__is_private=True, project__anon_permissions__contains=["view_project"], data_content_type=membership_content_type) @@ -214,7 +212,7 @@ def get_project_timeline(project, accessing_user=None): return timeline -def register_timeline_implementation(typename:str, event_type:str, fn=None): +def register_timeline_implementation(typename: str, event_type: str, fn=None): assert isinstance(typename, str), "typename must be a string" assert isinstance(event_type, str), "event_type must be a string" @@ -231,7 +229,6 @@ def register_timeline_implementation(typename:str, event_type:str, fn=None): return _wrapper - def extract_project_info(instance): return { "id": instance.pk, diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 98903ec1..3d584c53 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -22,7 +22,8 @@ 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 +from taiga.base.fields import PgArrayField, Field, MethodField + from taiga.base.utils.thumbnails import get_thumbnail_url from taiga.projects.models import Project @@ -33,11 +34,10 @@ from .gravatar import get_gravatar_url from collections import namedtuple import re -import serpy ###################################################### -## User +# User ###################################################### class ContactProjectDetailSerializer(serializers.ModelSerializer): @@ -139,19 +139,13 @@ class UserAdminSerializer(UserSerializer): return user.owned_projects.filter(is_private=False).count() -class UserBasicInfoSerializer(UserSerializer): - class Meta: - model = User - fields = ("username", "full_name_display", "photo", "big_photo", "is_active", "id") - - -class ListUserBasicInfoSerializer(serpy.Serializer): - username = serpy.Field() - full_name_display = serpy.MethodField() - photo = serpy.MethodField() - big_photo = serpy.MethodField() - is_active = serpy.Field() - id = serpy.Field() +class UserBasicInfoSerializer(serializers.LightSerializer): + username = Field() + full_name_display = MethodField() + photo = MethodField() + big_photo = MethodField() + is_active = Field() + id = Field() def get_full_name_display(self, obj): return obj.get_full_name() @@ -162,6 +156,12 @@ class ListUserBasicInfoSerializer(serpy.Serializer): def get_big_photo(self, obj): return get_big_photo_or_gravatar_url(obj) + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + class RecoverySerializer(serializers.Serializer): token = serializers.CharField(max_length=200) @@ -177,7 +177,7 @@ class CancelAccountSerializer(serializers.Serializer): ###################################################### -## Role +# Role ###################################################### class RoleSerializer(serializers.ModelSerializer): @@ -201,7 +201,7 @@ class ProjectRoleSerializer(serializers.ModelSerializer): ###################################################### -## Like +# Like ###################################################### class HighLightedContentSerializer(serializers.Serializer): diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py index f15021a0..4648a73a 100644 --- a/taiga/webhooks/api.py +++ b/taiga/webhooks/api.py @@ -30,6 +30,7 @@ from taiga.base.decorators import detail_route from . import models from . import serializers +from . import validators from . import permissions from . import tasks @@ -37,6 +38,7 @@ from . import tasks class WebhookViewSet(BlockedByProjectMixin, ModelCrudViewSet): model = models.Webhook serializer_class = serializers.WebhookSerializer + validator_class = validators.WebhookValidator permission_classes = (permissions.WebhookPermission,) filter_backends = (filters.IsProjectAdminFilterBackend,) filter_fields = ("project",) diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index ee0d8308..624e107c 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -19,63 +19,55 @@ from django.core.exceptions import ObjectDoesNotExist from taiga.base.api import serializers -from taiga.base.fields import PgArrayField, JsonField - +from taiga.base.fields import Field, MethodField from taiga.front.templatetags.functions import resolve as resolve_front_url -from taiga.projects.history import models as history_models -from taiga.projects.issues import models as issue_models -from taiga.projects.milestones import models as milestone_models -from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer from taiga.projects.services import get_logo_big_thumbnail_url -from taiga.projects.tasks import models as task_models -from taiga.projects.tagging.fields import TagsField -from taiga.projects.userstories import models as us_models -from taiga.projects.wiki import models as wiki_models from taiga.users.gravatar import get_gravatar_url from taiga.users.services import get_photo_or_gravatar_url -from .models import Webhook, WebhookLog - ######################################################################## -## WebHooks +# WebHooks ######################################################################## -class WebhookSerializer(serializers.ModelSerializer): - logs_counter = serializers.SerializerMethodField("get_logs_counter") - - class Meta: - model = Webhook +class WebhookSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + name = Field() + url = Field() + key = Field() + logs_counter = MethodField() def get_logs_counter(self, obj): return obj.logs.count() -class WebhookLogSerializer(serializers.ModelSerializer): - request_data = JsonField() - request_headers = JsonField() - response_headers = JsonField() - - class Meta: - model = WebhookLog +class WebhookLogSerializer(serializers.LightSerializer): + id = Field() + webhook = Field(attr="webhook_id") + url = Field() + status = Field() + request_data = Field() + request_headers = Field() + response_data = Field() + response_headers = Field() + duration = Field() + created = Field() ######################################################################## -## User +# User ######################################################################## -class UserSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - permalink = serializers.SerializerMethodField("get_permalink") - gravatar_url = serializers.SerializerMethodField("get_gravatar_url") - username = serializers.SerializerMethodField("get_username") - full_name = serializers.SerializerMethodField("get_full_name") - photo = serializers.SerializerMethodField("get_photo") - - def get_pk(self, obj): - return obj.pk +class UserSerializer(serializers.LightSerializer): + id = Field(attr="pk") + permalink = MethodField() + gravatar_url = MethodField() + username = MethodField() + full_name = MethodField() + photo = MethodField() def get_permalink(self, obj): return resolve_front_url("user", obj.username) @@ -84,7 +76,7 @@ class UserSerializer(serializers.Serializer): return get_gravatar_url(obj.email) def get_username(self, obj): - return obj.get_username + return obj.get_username() def get_full_name(self, obj): return obj.get_full_name() @@ -92,18 +84,22 @@ class UserSerializer(serializers.Serializer): def get_photo(self, obj): return get_photo_or_gravatar_url(obj) + def to_value(self, instance): + if instance is None: + return None + + return super().to_value(instance) + + ######################################################################## -## Project +# Project ######################################################################## -class ProjectSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - permalink = serializers.SerializerMethodField("get_permalink") - name = serializers.SerializerMethodField("get_name") - logo_big_url = serializers.SerializerMethodField("get_logo_big_url") - - def get_pk(self, obj): - return obj.pk +class ProjectSerializer(serializers.LightSerializer): + id = Field(attr="pk") + permalink = MethodField() + name = MethodField() + logo_big_url = MethodField() def get_permalink(self, obj): return resolve_front_url("project", obj.slug) @@ -116,11 +112,11 @@ class ProjectSerializer(serializers.Serializer): ######################################################################## -## History Serializer +# History Serializer ######################################################################## -class HistoryDiffField(serializers.Field): - def to_native(self, value): +class HistoryDiffField(Field): + def to_value(self, value): # Tip: 'value' is the object returned by # taiga.projects.history.models.HistoryEntry.values_diff() @@ -137,21 +133,21 @@ class HistoryDiffField(serializers.Field): return ret -class HistoryEntrySerializer(serializers.ModelSerializer): - diff = HistoryDiffField(source="values_diff") - - class Meta: - model = history_models.HistoryEntry - exclude = ("id", "type", "key", "is_hidden", "is_snapshot", "snapshot", "user", "delete_comment_user", - "values", "created_at") +class HistoryEntrySerializer(serializers.LightSerializer): + comment = Field() + comment_html = Field() + delete_comment_date = Field() + comment_versions = Field() + edit_comment_date = Field() + diff = HistoryDiffField(attr="values_diff") ######################################################################## -## _Misc_ +# _Misc_ ######################################################################## -class CustomAttributesValuesWebhookSerializerMixin(serializers.ModelSerializer): - custom_attributes_values = serializers.SerializerMethodField("get_custom_attributes_values") +class CustomAttributesValuesWebhookSerializerMixin(serializers.LightSerializer): + custom_attributes_values = MethodField() def custom_attributes_queryset(self, project): raise NotImplementedError() @@ -161,13 +157,13 @@ class CustomAttributesValuesWebhookSerializerMixin(serializers.ModelSerializer): ret = {} for attr in custom_attributes: value = values.get(str(attr["id"]), None) - if value is not None: + if value is not None: ret[attr["name"]] = value return ret try: - values = obj.custom_attributes_values.attributes_values + values = obj.custom_attributes_values.attributes_values custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) @@ -175,10 +171,10 @@ class CustomAttributesValuesWebhookSerializerMixin(serializers.ModelSerializer): return None -class RolePointsSerializer(serializers.Serializer): - role = serializers.SerializerMethodField("get_role") - name = serializers.SerializerMethodField("get_name") - value = serializers.SerializerMethodField("get_value") +class RolePointsSerializer(serializers.LightSerializer): + role = MethodField() + name = MethodField() + value = MethodField() def get_role(self, obj): return obj.role.name @@ -190,16 +186,13 @@ class RolePointsSerializer(serializers.Serializer): return obj.points.value -class UserStoryStatusSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - slug = serializers.SerializerMethodField("get_slug") - color = serializers.SerializerMethodField("get_color") - is_closed = serializers.SerializerMethodField("get_is_closed") - is_archived = serializers.SerializerMethodField("get_is_archived") - - def get_pk(self, obj): - return obj.pk +class UserStoryStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() + is_archived = MethodField() def get_name(self, obj): return obj.name @@ -217,15 +210,12 @@ class UserStoryStatusSerializer(serializers.Serializer): return obj.is_archived -class TaskStatusSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - slug = serializers.SerializerMethodField("get_slug") - color = serializers.SerializerMethodField("get_color") - is_closed = serializers.SerializerMethodField("get_is_closed") - - def get_pk(self, obj): - return obj.pk +class TaskStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() def get_name(self, obj): return obj.name @@ -240,15 +230,12 @@ class TaskStatusSerializer(serializers.Serializer): return obj.is_closed -class IssueStatusSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - slug = serializers.SerializerMethodField("get_slug") - color = serializers.SerializerMethodField("get_color") - is_closed = serializers.SerializerMethodField("get_is_closed") - - def get_pk(self, obj): - return obj.pk +class IssueStatusSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + slug = MethodField() + color = MethodField() + is_closed = MethodField() def get_name(self, obj): return obj.name @@ -263,13 +250,10 @@ class IssueStatusSerializer(serializers.Serializer): return obj.is_closed -class IssueTypeSerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - color = serializers.SerializerMethodField("get_color") - - def get_pk(self, obj): - return obj.pk +class IssueTypeSerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() def get_name(self, obj): return obj.name @@ -278,13 +262,10 @@ class IssueTypeSerializer(serializers.Serializer): return obj.color -class PrioritySerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - color = serializers.SerializerMethodField("get_color") - - def get_pk(self, obj): - return obj.pk +class PrioritySerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() def get_name(self, obj): return obj.name @@ -293,13 +274,10 @@ class PrioritySerializer(serializers.Serializer): return obj.color -class SeveritySerializer(serializers.Serializer): - id = serializers.SerializerMethodField("get_pk") - name = serializers.SerializerMethodField("get_name") - color = serializers.SerializerMethodField("get_color") - - def get_pk(self, obj): - return obj.pk +class SeveritySerializer(serializers.LightSerializer): + id = Field(attr="pk") + name = MethodField() + color = MethodField() def get_name(self, obj): return obj.name @@ -309,57 +287,90 @@ class SeveritySerializer(serializers.Serializer): ######################################################################## -## Milestone +# Milestone ######################################################################## -class MilestoneSerializer(serializers.ModelSerializer): +class MilestoneSerializer(serializers.LightSerializer): + id = Field() + name = Field() + slug = Field() + estimated_start = Field() + estimated_finish = Field() + created_date = Field() + modified_date = Field() + closed = Field() + disponibility = Field() permalink = serializers.SerializerMethodField("get_permalink") project = ProjectSerializer() owner = UserSerializer() - class Meta: - model = milestone_models.Milestone - exclude = ("order", "watchers") - def get_permalink(self, obj): return resolve_front_url("taskboard", obj.project.slug, obj.slug) ######################################################################## -## User Story +# User Story ######################################################################## -class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, EditableWatchedResourceModelSerializer, - serializers.ModelSerializer): - permalink = serializers.SerializerMethodField("get_permalink") - tags = TagsField(default=[], required=False) - external_reference = PgArrayField(required=False) +class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() project = ProjectSerializer() + is_closed = Field() + created_date = Field() + modified_date = Field() + finish_date = Field() + subject = Field() + client_requirement = Field() + team_requirement = Field() + generated_from_issue = Field(attr="generated_from_issue_id") + external_reference = Field() + tribe_gig = Field() + watchers = MethodField() + is_blocked = Field() + blocked_note = Field() + tags = Field() + permalink = serializers.SerializerMethodField("get_permalink") owner = UserSerializer() assigned_to = UserSerializer() - points = RolePointsSerializer(source="role_points", many=True) + points = MethodField() status = UserStoryStatusSerializer() milestone = MilestoneSerializer() - class Meta: - model = us_models.UserStory - exclude = ("backlog_order", "sprint_order", "kanban_order", "version", "total_watchers", "is_watcher") - def get_permalink(self, obj): return resolve_front_url("userstory", obj.project.slug, obj.ref) def custom_attributes_queryset(self, project): return project.userstorycustomattributes.all() + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + + def get_points(self, obj): + return RolePointsSerializer(obj.role_points.all(), many=True).data + ######################################################################## -## Task +# Task ######################################################################## -class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, EditableWatchedResourceModelSerializer, - serializers.ModelSerializer): +class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + us_order = Field() + taskboard_order = Field() + is_iocaine = Field() + external_reference = Field() + watchers = MethodField() + is_blocked = Field() + blocked_note = Field() + description = Field() + tags = Field() permalink = serializers.SerializerMethodField("get_permalink") - tags = TagsField(default=[], required=False) project = ProjectSerializer() owner = UserSerializer() assigned_to = UserSerializer() @@ -367,25 +378,32 @@ class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, EditableWatch user_story = UserStorySerializer() milestone = MilestoneSerializer() - class Meta: - model = task_models.Task - exclude = ("version", "total_watchers", "is_watcher") - def get_permalink(self, obj): return resolve_front_url("task", obj.project.slug, obj.ref) def custom_attributes_queryset(self, project): return project.taskcustomattributes.all() + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + ######################################################################## -## Issue +# Issue ######################################################################## -class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, EditableWatchedResourceModelSerializer, - serializers.ModelSerializer): +class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.LightSerializer): + id = Field() + ref = Field() + created_date = Field() + modified_date = Field() + finished_date = Field() + subject = Field() + external_reference = Field() + watchers = MethodField() + description = Field() + tags = Field() permalink = serializers.SerializerMethodField("get_permalink") - tags = TagsField(default=[], required=False) project = ProjectSerializer() milestone = MilestoneSerializer() owner = UserSerializer() @@ -395,30 +413,30 @@ class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, EditableWatc priority = PrioritySerializer() severity = SeveritySerializer() - class Meta: - model = issue_models.Issue - exclude = ("version", "total_watchers", "is_watcher") - def get_permalink(self, obj): return resolve_front_url("issue", obj.project.slug, obj.ref) def custom_attributes_queryset(self, project): return project.issuecustomattributes.all() + def get_watchers(self, obj): + return list(obj.get_watchers().values_list("id", flat=True)) + ######################################################################## -## Wiki Page +# Wiki Page ######################################################################## -class WikiPageSerializer(serializers.ModelSerializer): +class WikiPageSerializer(serializers.LightSerializer): + id = Field() + slug = Field() + content = Field() + created_date = Field() + modified_date = Field() permalink = serializers.SerializerMethodField("get_permalink") project = ProjectSerializer() owner = UserSerializer() last_modifier = UserSerializer() - class Meta: - model = wiki_models.WikiPage - exclude = ("watchers", "total_watchers", "is_watcher", "version") - def get_permalink(self, obj): return resolve_front_url("wiki", obj.project.slug, obj.slug) diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py index 7990b928..334cd52d 100644 --- a/taiga/webhooks/tasks.py +++ b/taiga/webhooks/tasks.py @@ -149,5 +149,4 @@ def test_webhook(webhook_id, url, key, by, date): data['by'] = UserSerializer(by).data data['date'] = date data['data'] = {"test": "test"} - return _send_request(webhook_id, url, key, data) diff --git a/taiga/webhooks/validators.py b/taiga/webhooks/validators.py new file mode 100644 index 00000000..b95e2e64 --- /dev/null +++ b/taiga/webhooks/validators.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import validators + +from .models import Webhook + + +class WebhookValidator(validators.ModelValidator): + class Meta: + model = Webhook diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index d9b391f8..4f93ea0c 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -22,7 +22,11 @@ import uuid from django.core.urlresolvers import reverse from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.projects.issues.models import Issue from taiga.projects.issues.serializers import IssueSerializer +from taiga.projects.issues.utils import attach_extra_info as attach_issue_extra_info from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS from taiga.base.utils import json @@ -61,22 +65,29 @@ def data(): public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, issues_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, issues_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, issues_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, issues_csv_uuid=uuid.uuid4().hex, blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) m.public_membership = f.MembershipFactory(project=m.public_project, user=m.project_member_with_perms, @@ -129,24 +140,31 @@ def data(): priority__project=m.public_project, type__project=m.public_project, milestone__project=m.public_project) + m.public_issue = attach_issue_extra_info(Issue.objects.all()).get(id=m.public_issue.id) + m.private_issue1 = f.IssueFactory(project=m.private_project1, status__project=m.private_project1, severity__project=m.private_project1, priority__project=m.private_project1, type__project=m.private_project1, milestone__project=m.private_project1) + m.private_issue1 = attach_issue_extra_info(Issue.objects.all()).get(id=m.private_issue1.id) + m.private_issue2 = f.IssueFactory(project=m.private_project2, status__project=m.private_project2, severity__project=m.private_project2, priority__project=m.private_project2, type__project=m.private_project2, milestone__project=m.private_project2) + m.private_issue2 = attach_issue_extra_info(Issue.objects.all()).get(id=m.private_issue2.id) + m.blocked_issue = f.IssueFactory(project=m.blocked_project, status__project=m.blocked_project, severity__project=m.blocked_project, priority__project=m.blocked_project, type__project=m.blocked_project, milestone__project=m.blocked_project) + m.blocked_issue = attach_issue_extra_info(Issue.objects.all()).get(id=m.blocked_issue.id) return m @@ -443,24 +461,28 @@ def test_issue_put_update_with_project_change(client): project1.save() project2.save() - membership1 = f.MembershipFactory(project=project1, - user=user1, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership2 = f.MembershipFactory(project=project2, - user=user1, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership3 = f.MembershipFactory(project=project1, - user=user2, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership4 = f.MembershipFactory(project=project2, - user=user3, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) issue = f.IssueFactory.create(project=project1) + issue = attach_issue_extra_info(Issue.objects.all()).get(id=issue.id) url = reverse('issues-detail', kwargs={"pk": issue.pk}) diff --git a/tests/integration/resources_permissions/test_milestones_resources.py b/tests/integration/resources_permissions/test_milestones_resources.py index a1a06172..1a3ab5cb 100644 --- a/tests/integration/resources_permissions/test_milestones_resources.py +++ b/tests/integration/resources_permissions/test_milestones_resources.py @@ -22,8 +22,11 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info from taiga.projects.milestones.serializers import MilestoneSerializer from taiga.projects.milestones.models import Milestone +from taiga.projects.milestones.utils import attach_extra_info as attach_milestone_extra_info from taiga.projects.notifications.services import add_watcher from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS @@ -56,44 +59,55 @@ def data(): anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) - m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - m.private_membership1 = f.MembershipFactory(project=m.private_project1, - user=m.project_member_with_perms, - role__project=m.private_project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.private_project1, user=m.project_member_without_perms, role__project=m.private_project1, role__permissions=[]) - m.private_membership2 = f.MembershipFactory(project=m.private_project2, - user=m.project_member_with_perms, - role__project=m.private_project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.private_project2, user=m.project_member_without_perms, role__project=m.private_project2, role__permissions=[]) - m.blocked_membership = f.MembershipFactory(project=m.blocked_project, - user=m.project_member_with_perms, - role__project=m.blocked_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.blocked_project, user=m.project_member_without_perms, role__project=m.blocked_project, @@ -112,13 +126,17 @@ def data(): is_admin=True) f.MembershipFactory(project=m.blocked_project, - user=m.project_owner, - is_admin=True) + user=m.project_owner, + is_admin=True) m.public_milestone = f.MilestoneFactory(project=m.public_project) + m.public_milestone = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.public_milestone.id) m.private_milestone1 = f.MilestoneFactory(project=m.private_project1) + m.private_milestone1 = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.private_milestone1.id) m.private_milestone2 = f.MilestoneFactory(project=m.private_project2) + m.private_milestone2 = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.private_milestone2.id) m.blocked_milestone = f.MilestoneFactory(project=m.blocked_project) + m.blocked_milestone = attach_milestone_extra_info(Milestone.objects.all()).get(id=m.blocked_milestone.id) return m @@ -422,16 +440,16 @@ def test_milestone_watchers_list(client, data): def test_milestone_watchers_retrieve(client, data): add_watcher(data.public_milestone, data.project_owner) public_url = reverse('milestone-watchers-detail', kwargs={"resource_id": data.public_milestone.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_milestone1, data.project_owner) private_url1 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone1.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_milestone2, data.project_owner) private_url2 = reverse('milestone-watchers-detail', kwargs={"resource_id": data.private_milestone2.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.blocked_milestone, data.project_owner) blocked_url = reverse('milestone-watchers-detail', kwargs={"resource_id": data.blocked_milestone.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) users = [ None, diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 949893b6..1410c86d 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -22,8 +22,10 @@ from django.apps import apps from taiga.base.utils import json from taiga.projects import choices as project_choices +from taiga.projects import models as project_models from taiga.projects.serializers import ProjectSerializer from taiga.permissions.choices import MEMBERS_PERMISSIONS +from taiga.projects.utils import attach_extra_info from tests import factories as f from tests.utils import helper_test_http_method, helper_test_http_method_and_count @@ -45,19 +47,26 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=['view_project'], public_permissions=['view_project']) + m.public_project = attach_extra_info(project_models.Project.objects.all()).get(id=m.public_project.id) + m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=['view_project'], public_permissions=['view_project'], owner=m.project_owner) + m.private_project1 = attach_extra_info(project_models.Project.objects.all()).get(id=m.private_project1.id) + m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner) + m.private_project2 = attach_extra_info(project_models.Project.objects.all()).get(id=m.private_project2.id) + m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_extra_info(project_models.Project.objects.all()).get(id=m.blocked_project.id) f.RoleFactory(project=m.public_project) diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index 5eaf5243..4d6427dd 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -23,12 +23,16 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects import choices as project_choices +from taiga.projects.models import Project from taiga.projects.tasks.serializers import TaskSerializer +from taiga.projects.tasks.models import Task +from taiga.projects.tasks.utils import attach_extra_info as attach_task_extra_info +from taiga.projects.utils import attach_extra_info as attach_project_extra_info from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS from taiga.projects.occ import OCCResourceMixin from tests import factories as f -from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals +from tests.utils import helper_test_http_method, reconnect_signals from taiga.projects.votes.services import add_vote from taiga.projects.notifications.services import add_watcher @@ -38,10 +42,6 @@ import pytest pytestmark = pytest.mark.django_db -def setup_function(function): - disconnect_signals() - - def setup_function(function): reconnect_signals() @@ -61,47 +61,61 @@ def data(): public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, tasks_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, tasks_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, tasks_csv_uuid=uuid.uuid4().hex) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) + m.blocked_project = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, tasks_csv_uuid=uuid.uuid4().hex, blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) - m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - m.private_membership1 = f.MembershipFactory(project=m.private_project1, - user=m.project_member_with_perms, - role__project=m.private_project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - f.MembershipFactory(project=m.private_project1, - user=m.project_member_without_perms, - role__project=m.private_project1, - role__permissions=[]) - m.private_membership2 = f.MembershipFactory(project=m.private_project2, - user=m.project_member_with_perms, - role__project=m.private_project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - f.MembershipFactory(project=m.private_project2, - user=m.project_member_without_perms, - role__project=m.private_project2, - role__permissions=[]) - m.blocked_membership = f.MembershipFactory(project=m.blocked_project, - user=m.project_member_with_perms, - role__project=m.blocked_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project1, + user=m.project_member_without_perms, + role__project=m.private_project1, + role__permissions=[]) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory( + project=m.private_project2, + user=m.project_member_without_perms, + role__project=m.private_project2, + role__permissions=[]) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.blocked_project, user=m.project_member_without_perms, role__project=m.blocked_project, @@ -120,8 +134,8 @@ def data(): is_admin=True) f.MembershipFactory(project=m.blocked_project, - user=m.project_owner, - is_admin=True) + user=m.project_owner, + is_admin=True) milestone_public_task = f.MilestoneFactory(project=m.public_project) milestone_private_task1 = f.MilestoneFactory(project=m.private_project1) @@ -133,21 +147,28 @@ def data(): milestone=milestone_public_task, user_story__project=m.public_project, user_story__milestone=milestone_public_task) + m.public_task = attach_task_extra_info(Task.objects.all()).get(id=m.public_task.id) + m.private_task1 = f.TaskFactory(project=m.private_project1, status__project=m.private_project1, milestone=milestone_private_task1, user_story__project=m.private_project1, user_story__milestone=milestone_private_task1) + m.private_task1 = attach_task_extra_info(Task.objects.all()).get(id=m.private_task1.id) + m.private_task2 = f.TaskFactory(project=m.private_project2, status__project=m.private_project2, milestone=milestone_private_task2, user_story__project=m.private_project2, user_story__milestone=milestone_private_task2) + m.private_task2 = attach_task_extra_info(Task.objects.all()).get(id=m.private_task2.id) + m.blocked_task = f.TaskFactory(project=m.blocked_project, - status__project=m.blocked_project, - milestone=milestone_blocked_task, - user_story__project=m.blocked_project, - user_story__milestone=milestone_blocked_task) + status__project=m.blocked_project, + milestone=milestone_blocked_task, + user_story__project=m.blocked_project, + user_story__milestone=milestone_blocked_task) + m.blocked_task = attach_task_extra_info(Task.objects.all()).get(id=m.blocked_task.id) m.public_project.default_task_status = m.public_task.status m.public_project.save() @@ -404,24 +425,28 @@ def test_task_put_update_with_project_change(client): project1.save() project2.save() - membership1 = f.MembershipFactory(project=project1, - user=user1, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership2 = f.MembershipFactory(project=project2, - user=user1, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership3 = f.MembershipFactory(project=project1, - user=user2, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership4 = f.MembershipFactory(project=project2, - user=user3, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) task = f.TaskFactory.create(project=project1) + task = attach_task_extra_info(Task.objects.all()).get(id=task.id) url = reverse('tasks-detail', kwargs={"pk": task.pk}) @@ -739,17 +764,17 @@ def test_task_voters_list(client, data): def test_task_voters_retrieve(client, data): add_vote(data.public_task, data.project_owner) public_url = reverse('task-voters-detail', kwargs={"resource_id": data.public_task.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_vote(data.private_task1, data.project_owner) private_url1 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task1.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_vote(data.private_task2, data.project_owner) private_url2 = reverse('task-voters-detail', kwargs={"resource_id": data.private_task2.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_vote(data.blocked_task, data.project_owner) blocked_url = reverse('task-voters-detail', kwargs={"resource_id": data.blocked_task.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) users = [ None, @@ -844,17 +869,17 @@ def test_task_watchers_list(client, data): def test_task_watchers_retrieve(client, data): add_watcher(data.public_task, data.project_owner) public_url = reverse('task-watchers-detail', kwargs={"resource_id": data.public_task.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_task1, data.project_owner) private_url1 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task1.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_task2, data.project_owner) private_url2 = reverse('task-watchers-detail', kwargs={"resource_id": data.private_task2.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.blocked_task, data.project_owner) blocked_url = reverse('task-watchers-detail', kwargs={"resource_id": data.blocked_task.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) users = [ None, data.registered_user, diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index c9f95a31..4eb0c416 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -23,7 +23,11 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects import choices as project_choices +from taiga.projects.models import Project +from taiga.projects.utils import attach_extra_info as attach_project_extra_info +from taiga.projects.userstories.models import UserStory from taiga.projects.userstories.serializers import UserStorySerializer +from taiga.projects.userstories.utils import attach_extra_info as attach_userstory_extra_info from taiga.permissions.choices import MEMBERS_PERMISSIONS, ANON_PERMISSIONS from taiga.projects.occ import OCCResourceMixin @@ -61,47 +65,58 @@ def data(): public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, userstories_csv_uuid=uuid.uuid4().hex) + m.public_project = attach_project_extra_info(Project.objects.all()).get(id=m.public_project.id) + m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), owner=m.project_owner, userstories_csv_uuid=uuid.uuid4().hex) + m.private_project1 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project1.id) + m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], owner=m.project_owner, userstories_csv_uuid=uuid.uuid4().hex) - m.blocked_project = f.ProjectFactory(is_private=True, - anon_permissions=[], - public_permissions=[], - owner=m.project_owner, - userstories_csv_uuid=uuid.uuid4().hex, - blocked_code=project_choices.BLOCKED_BY_STAFF) + m.private_project2 = attach_project_extra_info(Project.objects.all()).get(id=m.private_project2.id) - m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - m.private_membership1 = f.MembershipFactory(project=m.private_project1, - user=m.project_member_with_perms, - role__project=m.private_project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.blocked_project = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex, + blocked_code=project_choices.BLOCKED_BY_STAFF) + m.blocked_project = attach_project_extra_info(Project.objects.all()).get(id=m.blocked_project.id) + + m.public_membership = f.MembershipFactory( + project=m.public_project, + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory( + project=m.private_project1, + user=m.project_member_with_perms, + role__project=m.private_project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.private_project1, user=m.project_member_without_perms, role__project=m.private_project1, role__permissions=[]) - m.private_membership2 = f.MembershipFactory(project=m.private_project2, - user=m.project_member_with_perms, - role__project=m.private_project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership2 = f.MembershipFactory( + project=m.private_project2, + user=m.project_member_with_perms, + role__project=m.private_project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.private_project2, user=m.project_member_without_perms, role__project=m.private_project2, role__permissions=[]) - m.blocked_membership = f.MembershipFactory(project=m.blocked_project, - user=m.project_member_with_perms, - role__project=m.blocked_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.blocked_membership = f.MembershipFactory( + project=m.blocked_project, + user=m.project_member_with_perms, + role__project=m.blocked_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) f.MembershipFactory(project=m.blocked_project, user=m.project_member_without_perms, role__project=m.blocked_project, @@ -120,8 +135,8 @@ def data(): is_admin=True) f.MembershipFactory(project=m.blocked_project, - user=m.project_owner, - is_admin=True) + user=m.project_owner, + is_admin=True) m.public_points = f.PointsFactory(project=m.public_project) m.private_points1 = f.PointsFactory(project=m.private_project1) @@ -144,15 +159,19 @@ def data(): user_story__milestone__project=m.private_project2, user_story__status__project=m.private_project2) m.blocked_role_points = f.RolePointsFactory(role=m.blocked_project.roles.all()[0], - points=m.blocked_points, - user_story__project=m.blocked_project, - user_story__milestone__project=m.blocked_project, - user_story__status__project=m.blocked_project) + points=m.blocked_points, + user_story__project=m.blocked_project, + user_story__milestone__project=m.blocked_project, + user_story__status__project=m.blocked_project) m.public_user_story = m.public_role_points.user_story + m.public_user_story = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.public_user_story.id) m.private_user_story1 = m.private_role_points1.user_story + m.private_user_story1 = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.private_user_story1.id) m.private_user_story2 = m.private_role_points2.user_story + m.private_user_story2 = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.private_user_story2.id) m.blocked_user_story = m.blocked_role_points.user_story + m.blocked_user_story = attach_userstory_extra_info(UserStory.objects.all()).get(id=m.blocked_user_story.id) return m @@ -380,24 +399,28 @@ def test_user_story_put_update_with_project_change(client): project1.save() project2.save() - membership1 = f.MembershipFactory(project=project1, - user=user1, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership2 = f.MembershipFactory(project=project2, - user=user1, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership3 = f.MembershipFactory(project=project1, - user=user2, - role__project=project1, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) - membership4 = f.MembershipFactory(project=project2, - user=user3, - role__project=project2, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + project1 = attach_project_extra_info(Project.objects.all()).get(id=project1.id) + project2 = attach_project_extra_info(Project.objects.all()).get(id=project2.id) + + f.MembershipFactory(project=project1, + user=user1, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user1, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project1, + user=user2, + role__project=project1, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + f.MembershipFactory(project=project2, + user=user3, + role__project=project2, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) us = f.UserStoryFactory.create(project=project1) + us = attach_userstory_extra_info(UserStory.objects.all()).get(id=us.id) url = reverse('userstories-detail', kwargs={"pk": us.pk}) @@ -592,7 +615,6 @@ def test_user_story_delete(client, data): assert results == [401, 403, 403, 451] - def test_user_story_action_bulk_create(client, data): url = reverse('userstories-bulk-create') @@ -746,7 +768,7 @@ def test_user_story_voters_retrieve(client, data): add_vote(data.blocked_user_story, data.project_owner) blocked_url = reverse('userstory-voters-detail', kwargs={"resource_id": data.blocked_user_story.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) users = [ None, data.registered_user, @@ -840,16 +862,16 @@ def test_userstory_watchers_list(client, data): def test_userstory_watchers_retrieve(client, data): add_watcher(data.public_user_story, data.project_owner) public_url = reverse('userstory-watchers-detail', kwargs={"resource_id": data.public_user_story.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_user_story1, data.project_owner) private_url1 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story1.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.private_user_story2, data.project_owner) private_url2 = reverse('userstory-watchers-detail', kwargs={"resource_id": data.private_user_story2.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) add_watcher(data.blocked_user_story, data.project_owner) blocked_url = reverse('userstory-watchers-detail', kwargs={"resource_id": data.blocked_user_story.pk, - "pk": data.project_owner.pk}) + "pk": data.project_owner.pk}) users = [ None, diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py index afc3597d..34d4cf00 100644 --- a/tests/integration/resources_permissions/test_webhooks_resources.py +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -242,16 +242,19 @@ def test_webhook_action_test(client, data): ] with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 results = helper_test_http_method(client, 'post', url1, None, users) assert results == [404, 404, 200] assert _send_request_mock.called is True with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 results = helper_test_http_method(client, 'post', url2, None, users) assert results == [404, 404, 404] assert _send_request_mock.called is False with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: + _send_request_mock.return_value = data.webhooklog1 results = helper_test_http_method(client, 'post', blocked_url, None, users) assert results == [404, 404, 451] assert _send_request_mock.called is False diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py index ad28cc16..18562a8e 100644 --- a/tests/integration/test_milestones.py +++ b/tests/integration/test_milestones.py @@ -43,7 +43,7 @@ def test_update_milestone_with_userstories_list(client): form_data = { "name": "test", - "user_stories": [UserStorySerializer(us).data] + "user_stories": [{"id": us.id}] } client.login(user) diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index 661b68ff..d8943e71 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -790,7 +790,7 @@ def test_watchers_assignation_for_issue(client): assert response.status_code == 400 issue = f.create_issue(project=project1, owner=user1) - data = dict(IssueSerializer(issue).data) + data = {} data["id"] = None data["version"] = None data["watchers"] = [user1.pk, user2.pk] @@ -802,8 +802,7 @@ def test_watchers_assignation_for_issue(client): # Test the impossible case when project is not # exists in create request, and validator works as expected issue = f.create_issue(project=project1, owner=user1) - data = dict(IssueSerializer(issue).data) - + data = {} data["id"] = None data["watchers"] = [user1.pk, user2.pk] data["project"] = None @@ -842,10 +841,11 @@ def test_watchers_assignation_for_task(client): assert response.status_code == 400 task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1) - data = dict(TaskSerializer(task).data) - data["id"] = None - data["version"] = None - data["watchers"] = [user1.pk, user2.pk] + data = { + "id": None, + "version": None, + "watchers": [user1.pk, user2.pk] + } url = reverse("tasks-list") response = client.json.post(url, json.dumps(data)) @@ -854,11 +854,11 @@ def test_watchers_assignation_for_task(client): # Test the impossible case when project is not # exists in create request, and validator works as expected task = f.create_task(project=project1, owner=user1, status__project=project1, milestone__project=project1) - data = dict(TaskSerializer(task).data) - - data["id"] = None - data["watchers"] = [user1.pk, user2.pk] - data["project"] = None + data = { + "id": None, + "watchers": [user1.pk, user2.pk], + "project": None + } url = reverse("tasks-list") response = client.json.post(url, json.dumps(data)) @@ -894,10 +894,11 @@ def test_watchers_assignation_for_us(client): assert response.status_code == 400 us = f.create_userstory(project=project1, owner=user1, status__project=project1) - data = dict(UserStorySerializer(us).data) - data["id"] = None - data["version"] = None - data["watchers"] = [user1.pk, user2.pk] + data = { + "id": None, + "version": None, + "watchers": [user1.pk, user2.pk] + } url = reverse("userstories-list") response = client.json.post(url, json.dumps(data)) @@ -906,11 +907,11 @@ def test_watchers_assignation_for_us(client): # Test the impossible case when project is not # exists in create request, and validator works as expected us = f.create_userstory(project=project1, owner=user1, status__project=project1) - data = dict(UserStorySerializer(us).data) - - data["id"] = None - data["watchers"] = [user1.pk, user2.pk] - data["project"] = None + data = { + "id": None, + "watchers": [user1.pk, user2.pk], + "project": None + } url = reverse("userstories-list") response = client.json.post(url, json.dumps(data)) diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 7eac9b06..10805f8e 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import copy import uuid import csv @@ -26,7 +25,6 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects.userstories import services, models -from taiga.projects.userstories.serializers import UserStorySerializer from .. import factories as f @@ -108,7 +106,7 @@ def test_create_userstory_without_default_values(client): client.login(user) response = client.json.post(url, json.dumps(data)) assert response.status_code == 201 - assert response.data['status'] == None + assert response.data['status'] is None def test_api_delete_userstory(client): @@ -211,7 +209,7 @@ def test_api_update_milestone_in_bulk_invalid_milestone(client): f.MembershipFactory.create(project=project, user=project.owner, is_admin=True) us1 = f.create_userstory(project=project) us2 = f.create_userstory(project=project) - m1 = f.MilestoneFactory.create(project=project) + f.MilestoneFactory.create(project=project) m2 = f.MilestoneFactory.create() url = reverse("userstories-bulk-update-milestone") @@ -262,48 +260,53 @@ def test_update_userstory_points(client): f.MembershipFactory.create(project=project, user=user1, role=role1, is_admin=True) f.MembershipFactory.create(project=project, user=user2, role=role2) - f.PointsFactory.create(project=project, value=None) - f.PointsFactory.create(project=project, value=1) + points1 = f.PointsFactory.create(project=project, value=None) + points2 = f.PointsFactory.create(project=project, value=1) points3 = f.PointsFactory.create(project=project, value=2) - us = f.UserStoryFactory.create(project=project,owner=user1, status__project=project, + us = f.UserStoryFactory.create(project=project, owner=user1, status__project=project, milestone__project=project) - usdata = UserStorySerializer(us).data url = reverse("userstories-detail", args=[us.pk]) client.login(user1) # invalid role - data = {} - data["version"] = usdata["version"] - data["points"] = copy.copy(usdata["points"]) - data["points"].update({"222222": points3.pk}) + data = { + "version": us.version, + "points": { + str(role1.pk): points1.pk, + str(role2.pk): points2.pk, + "222222": points3.pk + } + } response = client.json.patch(url, json.dumps(data)) assert response.status_code == 400 # invalid point - data = {} - data["version"] = usdata["version"] - data["points"] = copy.copy(usdata["points"]) - data["points"].update({str(role1.pk): "999999"}) + data = { + "version": us.version, + "points": { + str(role1.pk): 999999, + str(role2.pk): points2.pk + } + } response = client.json.patch(url, json.dumps(data)) assert response.status_code == 400 # Api should save successful - data = {} - data["version"] = usdata["version"] - data["points"] = copy.copy(usdata["points"]) - data["points"].update({str(role1.pk): points3.pk}) + data = { + "version": us.version, + "points": { + str(role1.pk): points3.pk, + str(role2.pk): points2.pk + } + } response = client.json.patch(url, json.dumps(data)) - us = models.UserStory.objects.get(pk=us.pk) - usdatanew = UserStorySerializer(us).data - assert response.status_code == 200, str(response.content) - assert response.data["points"] == usdatanew['points'] - assert response.data["points"] != usdata['points'] + assert response.data["points"][str(role1.pk)] == points3.pk def test_update_userstory_rolepoints_on_add_new_role(client): @@ -438,32 +441,32 @@ def test_api_filters_data(client): # | 9 | user2 | user3 | tag0 | # ------------------------------------------------------ - user_story0 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, - status=status3, tags=[tag1]) - user_story1 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, - status=status3, tags=[tag2]) - user_story2 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, - status=status1, tags=[tag1, tag2]) - user_story3 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, - status=status0, tags=[tag3]) - user_story4 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, - status=status0, tags=[tag1, tag2, tag3]) - user_story5 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, - status=status2, tags=[tag3]) - user_story6 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, - status=status3, tags=[tag1, tag2]) - user_story7 = f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, - status=status0, tags=[tag3]) - user_story8 = f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, - status=status3, tags=[tag1]) - user_story9 = f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, - status=status1, tags=[tag0]) + f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + status=status3, tags=[tag1]) + f.UserStoryFactory.create(project=project, owner=user1, assigned_to=None, + status=status3, tags=[tag2]) + f.UserStoryFactory.create(project=project, owner=user3, assigned_to=None, + status=status1, tags=[tag1, tag2]) + f.UserStoryFactory.create(project=project, owner=user2, assigned_to=None, + status=status0, tags=[tag3]) + f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user1, + status=status0, tags=[tag1, tag2, tag3]) + f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user1, + status=status2, tags=[tag3]) + f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user1, + status=status3, tags=[tag1, tag2]) + f.UserStoryFactory.create(project=project, owner=user1, assigned_to=user2, + status=status0, tags=[tag3]) + f.UserStoryFactory.create(project=project, owner=user3, assigned_to=user2, + status=status3, tags=[tag1]) + f.UserStoryFactory.create(project=project, owner=user2, assigned_to=user3, + status=status1, tags=[tag0]) url = reverse("userstories-filters-data") + "?project={}".format(project.id) client.login(user1) - ## No filter + # No filter response = client.get(url) assert response.status_code == 200 @@ -471,7 +474,7 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 4 assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 3 - assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 4 + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 4 assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 3 assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 1 @@ -486,7 +489,7 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 4 assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 4 - ## Filter ((status0 or status3) + # Filter ((status0 or status3) response = client.get(url + "&status={},{}".format(status3.id, status0.id)) assert response.status_code == 200 @@ -494,7 +497,7 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 3 assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 - assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 3 + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 3 assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 2 assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 @@ -509,7 +512,7 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['name'] == tag2, response.data["tags"]))["count"] == 3 assert next(filter(lambda i: i['name'] == tag3, response.data["tags"]))["count"] == 3 - ## Filter ((tag1 and tag2) and (user1 or user2)) + # Filter ((tag1 and tag2) and (user1 or user2)) response = client.get(url + "&tags={},{}&owner={},{}".format(tag1, tag2, user1.id, user2.id)) assert response.status_code == 200 @@ -517,7 +520,7 @@ def test_api_filters_data(client): assert next(filter(lambda i: i['id'] == user2.id, response.data["owners"]))["count"] == 1 assert next(filter(lambda i: i['id'] == user3.id, response.data["owners"]))["count"] == 1 - assert next(filter(lambda i: i['id'] == None, response.data["assigned_to"]))["count"] == 0 + assert next(filter(lambda i: i['id'] is None, response.data["assigned_to"]))["count"] == 0 assert next(filter(lambda i: i['id'] == user1.id, response.data["assigned_to"]))["count"] == 2 assert next(filter(lambda i: i['id'] == user2.id, response.data["assigned_to"]))["count"] == 0 assert next(filter(lambda i: i['id'] == user3.id, response.data["assigned_to"]))["count"] == 0 @@ -556,7 +559,7 @@ def test_custom_fields_csv_generation(): attr = f.UserStoryCustomAttributeFactory.create(project=project, name="attr1", description="desc") us = f.UserStoryFactory.create(project=project) attr_values = us.custom_attributes_values - attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.attributes_values = {str(attr.id): "val1"} attr_values.save() queryset = project.user_stories.all() data = services.userstories_to_csv(project, queryset) @@ -595,7 +598,7 @@ def test_update_userstory_update_watchers(client): client.login(user=us.owner) url = reverse("userstories-detail", kwargs={"pk": us.pk}) - data = {"watchers": [watching_user.id], "version":1} + data = {"watchers": [watching_user.id], "version": 1} response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200 @@ -614,7 +617,7 @@ def test_update_userstory_remove_watchers(client): client.login(user=us.owner) url = reverse("userstories-detail", kwargs={"pk": us.pk}) - data = {"watchers": [], "version":1} + data = {"watchers": [], "version": 1} response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200 @@ -634,7 +637,7 @@ def test_update_userstory_update_tribe_gig(client): "id": 2, "title": "This is a gig test title" }, - "version":1 + "version": 1 } client.login(user=us.owner) diff --git a/tests/integration/test_webhooks_issues.py b/tests/integration/test_webhooks_issues.py index 491ec5b4..8789408d 100644 --- a/tests/integration/test_webhooks_issues.py +++ b/tests/integration/test_webhooks_issues.py @@ -19,7 +19,6 @@ import pytest from unittest.mock import patch -from unittest.mock import Mock from .. import factories as f @@ -29,8 +28,6 @@ from taiga.projects.history import services pytestmark = pytest.mark.django_db(transaction=True) -from taiga.base.utils import json - def test_webhooks_when_create_issue(settings): settings.WEBHOOKS_ENABLED = True project = f.ProjectFactory() @@ -79,7 +76,7 @@ def test_webhooks_when_update_issue(settings): assert data["data"]["subject"] == obj.subject assert data["change"]["comment"] == "test_comment" assert data["change"]["diff"]["subject"]["to"] == data["data"]["subject"] - assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] + assert data["change"]["diff"]["subject"]["from"] != data["data"]["subject"] def test_webhooks_when_delete_issue(settings): diff --git a/tests/unit/test_serializer_mixins.py b/tests/unit/test_serializer_mixins.py index 349a912c..cc88552f 100644 --- a/tests/unit/test_serializer_mixins.py +++ b/tests/unit/test_serializer_mixins.py @@ -19,46 +19,43 @@ import pytest -from .. import factories as f from django.db import models -from taiga.projects.mixins.serializers import ValidateDuplicatedNameInProjectMixin -from taiga.projects.models import Project +from taiga.base.api.validators import ModelValidator +from taiga.projects.validators import DuplicatedNameInProjectValidator pytestmark = pytest.mark.django_db(transaction=True) -import factory - class AuxProjectModel(models.Model): pass + class AuxModelWithNameAttribute(models.Model): name = models.CharField(max_length=255, null=False, blank=False) project = models.ForeignKey(AuxProjectModel, null=False, blank=False) -class AuxSerializer(ValidateDuplicatedNameInProjectMixin): +class AuxValidator(DuplicatedNameInProjectValidator, ModelValidator): class Meta: model = AuxModelWithNameAttribute - def test_duplicated_name_validation(): project = AuxProjectModel.objects.create() - instance_1 = AuxModelWithNameAttribute.objects.create(name="1", project=project) + AuxModelWithNameAttribute.objects.create(name="1", project=project) instance_2 = AuxModelWithNameAttribute.objects.create(name="2", project=project) # No duplicated_name - serializer = AuxSerializer(data={"name": "3", "project": project.id}) + validator = AuxValidator(data={"name": "3", "project": project.id}) - assert serializer.is_valid() + assert validator.is_valid() # Create duplicated_name - serializer = AuxSerializer(data={"name": "1", "project": project.id}) + validator = AuxValidator(data={"name": "1", "project": project.id}) - assert not serializer.is_valid() + assert not validator.is_valid() # Update name to existing one - serializer = AuxSerializer(data={"id": instance_2.id, "name": "1","project": project.id}) + validator = AuxValidator(data={"id": instance_2.id, "name": "1", "project": project.id}) - assert not serializer.is_valid() + assert not validator.is_valid() From d7a979d23c4ad900e5a70ee9568129e23e84f217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 17:08:14 +0200 Subject: [PATCH 03/14] Migrating external apps --- taiga/external_apps/api.py | 10 +++--- taiga/external_apps/serializers.py | 45 +++++++++++-------------- taiga/external_apps/validators.py | 54 ++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 30 deletions(-) create mode 100644 taiga/external_apps/validators.py diff --git a/taiga/external_apps/api.py b/taiga/external_apps/api.py index 931337a8..8ded55d5 100644 --- a/taiga/external_apps/api.py +++ b/taiga/external_apps/api.py @@ -17,6 +17,7 @@ # along with this program. If not, see . from . import serializers +from . import validators from . import models from . import permissions from . import services @@ -27,12 +28,12 @@ from taiga.base.api import ModelCrudViewSet, ModelRetrieveViewSet from taiga.base.api.utils import get_object_or_404 from taiga.base.decorators import list_route, detail_route -from django.db import transaction from django.utils.translation import ugettext_lazy as _ class Application(ModelRetrieveViewSet): serializer_class = serializers.ApplicationSerializer + validator_class = validators.ApplicationValidator permission_classes = (permissions.ApplicationPermission,) model = models.Application @@ -61,6 +62,7 @@ class Application(ModelRetrieveViewSet): class ApplicationToken(ModelCrudViewSet): serializer_class = serializers.ApplicationTokenSerializer + validator_class = validators.ApplicationTokenValidator permission_classes = (permissions.ApplicationTokenPermission,) def get_queryset(self): @@ -87,9 +89,9 @@ class ApplicationToken(ModelCrudViewSet): auth_code = request.DATA.get("auth_code", None) state = request.DATA.get("state", None) application_token = get_object_or_404(models.ApplicationToken, - application__id=application_id, - auth_code=auth_code, - state=state) + application__id=application_id, + auth_code=auth_code, + state=state) application_token.generate_token() application_token.save() diff --git a/taiga/external_apps/serializers.py b/taiga/external_apps/serializers.py index 095465fd..12ed3bab 100644 --- a/taiga/external_apps/serializers.py +++ b/taiga/external_apps/serializers.py @@ -16,9 +16,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json - from taiga.base.api import serializers +from taiga.base.fields import Field from . import models from . import services @@ -26,33 +25,27 @@ from . import services from django.utils.translation import ugettext as _ -class ApplicationSerializer(serializers.ModelSerializer): - class Meta: - model = models.Application - fields = ("id", "name", "web", "description", "icon_url") +class ApplicationSerializer(serializers.LightSerializer): + id = Field() + name = Field() + web = Field() + description = Field() + icon_url = Field() -class ApplicationTokenSerializer(serializers.ModelSerializer): - cyphered_token = serializers.CharField(source="cyphered_token", read_only=True) - next_url = serializers.CharField(source="next_url", read_only=True) - application = ApplicationSerializer(read_only=True) - - class Meta: - model = models.ApplicationToken - fields = ("user", "id", "application", "auth_code", "next_url") +class ApplicationTokenSerializer(serializers.LightSerializer): + id = Field() + user = Field(attr="user_id") + application = ApplicationSerializer() + auth_code = Field() + next_url = Field() -class AuthorizationCodeSerializer(serializers.ModelSerializer): - next_url = serializers.CharField(source="next_url", read_only=True) - class Meta: - model = models.ApplicationToken - fields = ("auth_code", "state", "next_url") +class AuthorizationCodeSerializer(serializers.LightSerializer): + state = Field() + auth_code = Field() + next_url = Field() -class AccessTokenSerializer(serializers.ModelSerializer): - cyphered_token = serializers.CharField(source="cyphered_token", read_only=True) - next_url = serializers.CharField(source="next_url", read_only=True) - - class Meta: - model = models.ApplicationToken - fields = ("cyphered_token", ) +class AccessTokenSerializer(serializers.LightSerializer): + cyphered_token = Field() diff --git a/taiga/external_apps/validators.py b/taiga/external_apps/validators.py new file mode 100644 index 00000000..b2f2354d --- /dev/null +++ b/taiga/external_apps/validators.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import serializers + +from . import models +from taiga.base.api import validators + + +class ApplicationValidator(validators.ModelValidator): + class Meta: + model = models.Application + fields = ("id", "name", "web", "description", "icon_url") + + +class ApplicationTokenValidator(validators.ModelValidator): + cyphered_token = serializers.CharField(source="cyphered_token", read_only=True) + next_url = serializers.CharField(source="next_url", read_only=True) + application = ApplicationValidator(read_only=True) + + class Meta: + model = models.ApplicationToken + fields = ("user", "id", "application", "auth_code", "next_url") + + +class AuthorizationCodeValidator(validators.ModelValidator): + next_url = serializers.CharField(source="next_url", read_only=True) + class Meta: + model = models.ApplicationToken + fields = ("auth_code", "state", "next_url") + + +class AccessTokenValidator(validators.ModelValidator): + cyphered_token = serializers.CharField(source="cyphered_token", read_only=True) + next_url = serializers.CharField(source="next_url", read_only=True) + + class Meta: + model = models.ApplicationToken + fields = ("cyphered_token", ) From 39075288acf8e714326dc0446335436c8c67e30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 17:49:32 +0200 Subject: [PATCH 04/14] Migrating user storage --- taiga/userstorage/api.py | 18 +++++++++++------- taiga/userstorage/serializers.py | 16 ++++++---------- taiga/userstorage/validators.py | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 taiga/userstorage/validators.py diff --git a/taiga/userstorage/api.py b/taiga/userstorage/api.py index 62575d2b..5b097e71 100644 --- a/taiga/userstorage/api.py +++ b/taiga/userstorage/api.py @@ -17,14 +17,15 @@ # along with this program. If not, see . from django.utils.translation import ugettext as _ -from django.db import IntegrityError from taiga.base.api import ModelCrudViewSet +from taiga.base.api.serializers import ValidationError from taiga.base import exceptions as exc from . import models from . import filters from . import serializers +from . import validators from . import permissions @@ -32,6 +33,7 @@ class StorageEntriesViewSet(ModelCrudViewSet): model = models.StorageEntry filter_backends = (filters.StorageEntriesFilterBackend,) serializer_class = serializers.StorageEntrySerializer + validator_class = validators.StorageEntryValidator permission_classes = [permissions.StorageEntriesPermission] lookup_field = "key" @@ -45,9 +47,11 @@ class StorageEntriesViewSet(ModelCrudViewSet): obj.owner = self.request.user def create(self, *args, **kwargs): - try: - return super().create(*args, **kwargs) - except IntegrityError: - key = self.request.DATA.get("key", None) - raise exc.IntegrityError(_("Duplicate key value violates unique constraint. " - "Key '{}' already exists.").format(key)) + key = self.request.DATA.get("key", None) + if (key and self.request.user.is_authenticated() and + self.request.user.storage_entries.filter(key=key).exists()): + raise exc.BadRequest( + _("Duplicate key value violates unique constraint. " + "Key '{}' already exists.").format(key) + ) + return super().create(*args, **kwargs) diff --git a/taiga/userstorage/serializers.py b/taiga/userstorage/serializers.py index 5fd97692..38765f19 100644 --- a/taiga/userstorage/serializers.py +++ b/taiga/userstorage/serializers.py @@ -17,15 +17,11 @@ # along with this program. If not, see . from taiga.base.api import serializers -from taiga.base.fields import JsonField - -from . import models +from taiga.base.fields import Field -class StorageEntrySerializer(serializers.ModelSerializer): - value = JsonField(label="value") - - class Meta: - model = models.StorageEntry - fields = ("key", "value", "created_date", "modified_date") - read_only_fields = ("created_date", "modified_date") +class StorageEntrySerializer(serializers.LightSerializer): + key = Field() + value = Field() + created_date = Field() + modified_date = Field() diff --git a/taiga/userstorage/validators.py b/taiga/userstorage/validators.py new file mode 100644 index 00000000..615b88d7 --- /dev/null +++ b/taiga/userstorage/validators.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import validators + +from . import models + + +class StorageEntryValidator(validators.ModelValidator): + class Meta: + model = models.StorageEntry + fields = ("key", "value") 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 05/14] 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 From cbec0caca24840e419c59eeeab63afb9c3671a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 19:59:49 +0200 Subject: [PATCH 06/14] Migrating tagging validators --- taiga/projects/tagging/api.py | 36 +++++++++---------- .../tagging/{serializers.py => validators.py} | 27 +++++++------- 2 files changed, 31 insertions(+), 32 deletions(-) rename taiga/projects/tagging/{serializers.py => validators.py} (77%) diff --git a/taiga/projects/tagging/api.py b/taiga/projects/tagging/api.py index c2dbd38a..db57b946 100644 --- a/taiga/projects/tagging/api.py +++ b/taiga/projects/tagging/api.py @@ -21,7 +21,7 @@ from taiga.base.decorators import detail_route from taiga.base.utils.collections import OrderedSet from . import services -from . import serializers +from . import validators class TagsColorsResourceMixin: @@ -38,27 +38,26 @@ class TagsColorsResourceMixin: self.check_permissions(request, "create_tag", project) self._raise_if_blocked(project) - serializer = serializers.CreateTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.CreateTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data services.create_tag(project, data.get("tag"), data.get("color")) return response.Ok() - @detail_route(methods=["POST"]) def edit_tag(self, request, pk=None): project = self.get_object() self.check_permissions(request, "edit_tag", project) self._raise_if_blocked(project) - serializer = serializers.EditTagTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.EditTagTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data services.edit_tag(project, data.get("from_tag"), to_tag=data.get("to_tag", None), @@ -66,18 +65,17 @@ class TagsColorsResourceMixin: return response.Ok() - @detail_route(methods=["POST"]) def delete_tag(self, request, pk=None): project = self.get_object() self.check_permissions(request, "delete_tag", project) self._raise_if_blocked(project) - serializer = serializers.DeleteTagSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.DeleteTagValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data services.delete_tag(project, data.get("tag")) return response.Ok() @@ -88,11 +86,11 @@ class TagsColorsResourceMixin: self.check_permissions(request, "mix_tags", project) self._raise_if_blocked(project) - serializer = serializers.MixTagsSerializer(data=request.DATA, project=project) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = validators.MixTagsValidator(data=request.DATA, project=project) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - data = serializer.data + data = validator.data services.mix_tags(project, data.get("from_tags"), data.get("to_tag")) return response.Ok() diff --git a/taiga/projects/tagging/serializers.py b/taiga/projects/tagging/validators.py similarity index 77% rename from taiga/projects/tagging/serializers.py rename to taiga/projects/tagging/validators.py index dc25b73a..595a5a3f 100644 --- a/taiga/projects/tagging/serializers.py +++ b/taiga/projects/tagging/validators.py @@ -19,6 +19,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers +from taiga.base.api import validators from . import services from . import fields @@ -26,7 +27,7 @@ from . import fields import re -class ProjectTagSerializer(serializers.Serializer): +class ProjectTagValidator(validators.Validator): def __init__(self, *args, **kwargs): # Don't pass the extra project arg self.project = kwargs.pop("project") @@ -35,26 +36,26 @@ class ProjectTagSerializer(serializers.Serializer): super().__init__(*args, **kwargs) -class CreateTagSerializer(ProjectTagSerializer): +class CreateTagValidator(ProjectTagValidator): tag = serializers.CharField() color = serializers.CharField(required=False) def validate_tag(self, attrs, source): tag = attrs.get(source, None) if services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag exists.")) + raise validators.ValidationError(_("The tag exists.")) return attrs def validate_color(self, attrs, source): color = attrs.get(source, None) if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): - raise serializers.ValidationError(_("The color is not a valid HEX color.")) + raise validators.ValidationError(_("The color is not a valid HEX color.")) return attrs -class EditTagTagSerializer(ProjectTagSerializer): +class EditTagTagValidator(ProjectTagValidator): from_tag = serializers.CharField() to_tag = serializers.CharField(required=False) color = serializers.CharField(required=False) @@ -62,37 +63,37 @@ class EditTagTagSerializer(ProjectTagSerializer): def validate_from_tag(self, attrs, source): tag = attrs.get(source, None) if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) + raise validators.ValidationError(_("The tag doesn't exist.")) return attrs def validate_to_tag(self, attrs, source): tag = attrs.get(source, None) if services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag exists yet")) + raise validators.ValidationError(_("The tag exists yet")) return attrs def validate_color(self, attrs, source): color = attrs.get(source, None) if not re.match('^\#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$', color): - raise serializers.ValidationError(_("The color is not a valid HEX color.")) + raise validators.ValidationError(_("The color is not a valid HEX color.")) return attrs -class DeleteTagSerializer(ProjectTagSerializer): +class DeleteTagValidator(ProjectTagValidator): tag = serializers.CharField() def validate_tag(self, attrs, source): tag = attrs.get(source, None) if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) + raise validators.ValidationError(_("The tag doesn't exist.")) return attrs -class MixTagsSerializer(ProjectTagSerializer): +class MixTagsValidator(ProjectTagValidator): from_tags = fields.TagsField() to_tag = serializers.CharField() @@ -100,13 +101,13 @@ class MixTagsSerializer(ProjectTagSerializer): tags = attrs.get(source, None) for tag in tags: if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) + raise validators.ValidationError(_("The tag doesn't exist.")) return attrs def validate_to_tag(self, attrs, source): tag = attrs.get(source, None) if not services.tag_exist_for_project_elements(self.project, tag): - raise serializers.ValidationError(_("The tag doesn't exist.")) + raise validators.ValidationError(_("The tag doesn't exist.")) return attrs From a51ca8c85af5d95b8d39d175ecddc32f25010b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 20:29:59 +0200 Subject: [PATCH 07/14] Migrating Likes and votes serializers --- taiga/projects/likes/serializers.py | 14 +++++++------- taiga/projects/votes/serializers.py | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/taiga/projects/likes/serializers.py b/taiga/projects/likes/serializers.py index 6a654705..ef058e70 100644 --- a/taiga/projects/likes/serializers.py +++ b/taiga/projects/likes/serializers.py @@ -17,14 +17,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.contrib.auth import get_user_model - from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField -class FanSerializer(serializers.ModelSerializer): - full_name = serializers.CharField(source='get_full_name', required=False) +class FanSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = MethodField() - class Meta: - model = get_user_model() - fields = ('id', 'username', 'full_name') + def get_full_name(self, obj): + return obj.get_full_name() diff --git a/taiga/projects/votes/serializers.py b/taiga/projects/votes/serializers.py index eb47c9ef..b97bd3bf 100644 --- a/taiga/projects/votes/serializers.py +++ b/taiga/projects/votes/serializers.py @@ -17,14 +17,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.contrib.auth import get_user_model - from taiga.base.api import serializers +from taiga.base.fields import Field, MethodField -class VoterSerializer(serializers.ModelSerializer): - full_name = serializers.CharField(source='get_full_name', required=False) +class VoterSerializer(serializers.LightSerializer): + id = Field() + username = Field() + full_name = MethodField() - class Meta: - model = get_user_model() - fields = ('id', 'username', 'full_name') + def get_full_name(self, obj): + return obj.get_full_name() From 4f5a4f13145c97d5c4dfa7ce1f5bc2122da112e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 21:22:49 +0200 Subject: [PATCH 08/14] Migrating references validators --- taiga/projects/references/api.py | 10 +++++----- .../references/{serializers.py => validators.py} | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) rename taiga/projects/references/{serializers.py => validators.py} (95%) diff --git a/taiga/projects/references/api.py b/taiga/projects/references/api.py index 42d7f5a6..ff114ac6 100644 --- a/taiga/projects/references/api.py +++ b/taiga/projects/references/api.py @@ -24,7 +24,7 @@ from taiga.base.api import viewsets from taiga.base.api.utils import get_object_or_404 from taiga.permissions.services import user_has_perm -from .serializers import ResolverSerializer +from .validators import ResolverValidator from . import permissions @@ -32,11 +32,11 @@ class ResolverViewSet(viewsets.ViewSet): permission_classes = (permissions.ResolverPermission,) def list(self, request, **kwargs): - serializer = ResolverSerializer(data=request.QUERY_PARAMS) - if not serializer.is_valid(): - raise exc.BadRequest(serializer.errors) + validator = ResolverValidator(data=request.QUERY_PARAMS) + if not validator.is_valid(): + raise exc.BadRequest(validator.errors) - data = serializer.data + data = validator.data project_model = apps.get_model("projects", "Project") project = get_object_or_404(project_model, slug=data["project"]) diff --git a/taiga/projects/references/serializers.py b/taiga/projects/references/validators.py similarity index 95% rename from taiga/projects/references/serializers.py rename to taiga/projects/references/validators.py index fb9ad177..5fcefee8 100644 --- a/taiga/projects/references/serializers.py +++ b/taiga/projects/references/validators.py @@ -17,9 +17,10 @@ # along with this program. If not, see . from taiga.base.api import serializers +from taiga.base.api import validators -class ResolverSerializer(serializers.Serializer): +class ResolverValidator(validators.Validator): project = serializers.CharField(max_length=512, required=True) milestone = serializers.CharField(max_length=512, required=False) us = serializers.IntegerField(required=False) From 04102e3b9f417cc2d06e33c065d1166054d1145a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 23:25:42 +0200 Subject: [PATCH 09/14] Migrating attachments serializers --- taiga/base/fields.py | 17 +++++++++--- taiga/projects/attachments/api.py | 2 ++ taiga/projects/attachments/serializers.py | 31 ++++++++++++--------- taiga/projects/attachments/validators.py | 33 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 taiga/projects/attachments/validators.py diff --git a/taiga/base/fields.py b/taiga/base/fields.py index f0cf4ee2..30be6b60 100644 --- a/taiga/base/fields.py +++ b/taiga/base/fields.py @@ -22,9 +22,11 @@ from taiga.base.api import serializers import serpy + #################################################################### -# Serializer fields +# DRF Serializer fields (OLD) #################################################################### +# NOTE: This should be in other place, for example taiga.base.api.serializers class JsonField(serializers.WritableField): @@ -74,6 +76,10 @@ class WatchersField(serializers.WritableField): return data +#################################################################### +# Serpy fields (NEW) +#################################################################### + class Field(serpy.Field): pass @@ -82,13 +88,13 @@ class MethodField(serpy.MethodField): pass -class I18NField(serpy.Field): +class I18NField(Field): def to_value(self, value): ret = super(I18NField, self).to_value(value) return _(ret) -class I18NJsonField(serpy.Field): +class I18NJsonField(Field): """ Json objects serializer. """ @@ -118,3 +124,8 @@ class I18NJsonField(serpy.Field): def to_native(self, obj): i18n_obj = self.translate_values(obj) return i18n_obj + + +class FileField(Field): + def to_value(self, value): + return value.name diff --git a/taiga/projects/attachments/api.py b/taiga/projects/attachments/api.py index f7b223e2..27f7ebe1 100644 --- a/taiga/projects/attachments/api.py +++ b/taiga/projects/attachments/api.py @@ -34,6 +34,7 @@ from taiga.projects.history.mixins import HistoryResourceMixin from . import permissions from . import serializers +from . import validators from . import models @@ -42,6 +43,7 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, model = models.Attachment serializer_class = serializers.AttachmentSerializer + validator_class = validators.AttachmentValidator filter_fields = ["project", "object_id"] content_type = None diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py index 45e6be45..ce8893b7 100644 --- a/taiga/projects/attachments/serializers.py +++ b/taiga/projects/attachments/serializers.py @@ -19,24 +19,29 @@ from django.conf import settings from taiga.base.api import serializers -from taiga.base.fields import MethodField +from taiga.base.fields import MethodField, Field, FileField from taiga.base.utils.thumbnails import get_thumbnail_url from . import services -from . import models -class AttachmentSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField("get_url") - thumbnail_card_url = serializers.SerializerMethodField("get_thumbnail_card_url") - attached_file = serializers.FileField(required=True) - - class Meta: - model = models.Attachment - fields = ("id", "project", "owner", "name", "attached_file", "size", - "url", "thumbnail_card_url", "description", "is_deprecated", - "created_date", "modified_date", "object_id", "order", "sha1") - read_only_fields = ("owner", "created_date", "modified_date", "sha1") +class AttachmentSerializer(serializers.LightSerializer): + id = Field() + project = Field(attr="project_id") + owner = Field(attr="owner_id") + name = Field() + attached_file = FileField() + size = Field() + url = Field() + description = Field() + is_deprecated = Field() + created_date = Field() + modified_date = Field() + object_id = Field() + order = Field() + sha1 = Field() + url = MethodField("get_url") + thumbnail_card_url = MethodField("get_thumbnail_card_url") def get_url(self, obj): return obj.attached_file.url diff --git a/taiga/projects/attachments/validators.py b/taiga/projects/attachments/validators.py new file mode 100644 index 00000000..72355ce4 --- /dev/null +++ b/taiga/projects/attachments/validators.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from taiga.base.api import serializers +from taiga.base.api import validators + +from . import models + + +class AttachmentValidator(validators.ModelValidator): + attached_file = serializers.FileField(required=True) + + class Meta: + model = models.Attachment + fields = ("id", "project", "owner", "name", "attached_file", "size", + "description", "is_deprecated", "created_date", + "modified_date", "object_id", "order", "sha1") + read_only_fields = ("owner", "created_date", "modified_date", "sha1") From 8d86c42fa078adb58145a50469abdd4ddc8ba7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 5 Jul 2016 23:48:33 +0200 Subject: [PATCH 10/14] Migrating feedback serializers --- taiga/feedback/api.py | 14 +++++++------- taiga/feedback/{serializers.py => validators.py} | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) rename taiga/feedback/{serializers.py => validators.py} (91%) diff --git a/taiga/feedback/api.py b/taiga/feedback/api.py index c477b5eb..0f573b87 100644 --- a/taiga/feedback/api.py +++ b/taiga/feedback/api.py @@ -20,7 +20,7 @@ from taiga.base import response from taiga.base.api import viewsets from . import permissions -from . import serializers +from . import validators from . import services import copy @@ -28,7 +28,7 @@ import copy class FeedbackViewSet(viewsets.ViewSet): permission_classes = (permissions.FeedbackPermission,) - serializer_class = serializers.FeedbackEntrySerializer + validator_class = validators.FeedbackEntryValidator def create(self, request, **kwargs): self.check_permissions(request, "create", None) @@ -37,11 +37,11 @@ class FeedbackViewSet(viewsets.ViewSet): data.update({"full_name": request.user.get_full_name(), "email": request.user.email}) - serializer = self.serializer_class(data=data) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) + validator = self.validator_class(data=data) + if not validator.is_valid(): + return response.BadRequest(validator.errors) - self.object = serializer.save(force_insert=True) + self.object = validator.save(force_insert=True) extra = { "HTTP_HOST": request.META.get("HTTP_HOST", None), @@ -50,4 +50,4 @@ class FeedbackViewSet(viewsets.ViewSet): } services.send_feedback(self.object, extra, reply_to=[request.user.email]) - return response.Ok(serializer.data) + return response.Ok(validator.data) diff --git a/taiga/feedback/serializers.py b/taiga/feedback/validators.py similarity index 91% rename from taiga/feedback/serializers.py rename to taiga/feedback/validators.py index 1b5f1a3e..7b31ec88 100644 --- a/taiga/feedback/serializers.py +++ b/taiga/feedback/validators.py @@ -16,11 +16,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from taiga.base.api import serializers +from taiga.base.api import validators from . import models -class FeedbackEntrySerializer(serializers.ModelSerializer): +class FeedbackEntryValidator(validators.ModelValidator): class Meta: model = models.FeedbackEntry From 81454426f9b721fedac81e6b14bce0d9265265dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 6 Jul 2016 08:58:25 +0200 Subject: [PATCH 11/14] Migrating auth serializers --- taiga/auth/api.py | 27 ++++++++++---------- taiga/auth/{serializers.py => validators.py} | 13 +++++----- 2 files changed, 21 insertions(+), 19 deletions(-) rename taiga/auth/{serializers.py => validators.py} (81%) diff --git a/taiga/auth/api.py b/taiga/auth/api.py index 5d14d18f..df077b52 100644 --- a/taiga/auth/api.py +++ b/taiga/auth/api.py @@ -22,15 +22,16 @@ from enum import Enum from django.utils.translation import ugettext as _ from django.conf import settings +from taiga.base.api import validators from taiga.base.api import serializers from taiga.base.api import viewsets from taiga.base.decorators import list_route from taiga.base import exceptions as exc from taiga.base import response -from .serializers import PublicRegisterSerializer -from .serializers import PrivateRegisterForExistingUserSerializer -from .serializers import PrivateRegisterForNewUserSerializer +from .validators import PublicRegisterValidator +from .validators import PrivateRegisterForExistingUserValidator +from .validators import PrivateRegisterForNewUserValidator from .services import private_register_for_existing_user from .services import private_register_for_new_user @@ -44,7 +45,7 @@ from .permissions import AuthPermission def _parse_data(data:dict, *, cls): """ Generic function for parse user data using - specified serializer on `cls` keyword parameter. + specified validator on `cls` keyword parameter. Raises: RequestValidationError exception if some errors found when data is validated. @@ -52,21 +53,21 @@ def _parse_data(data:dict, *, cls): Returns the parsed data. """ - serializer = cls(data=data) - if not serializer.is_valid(): - raise exc.RequestValidationError(serializer.errors) - return serializer.data + validator = cls(data=data) + if not validator.is_valid(): + raise exc.RequestValidationError(validator.errors) + return validator.data # Parse public register data -parse_public_register_data = partial(_parse_data, cls=PublicRegisterSerializer) +parse_public_register_data = partial(_parse_data, cls=PublicRegisterValidator) # Parse private register data for existing user parse_private_register_for_existing_user_data = \ - partial(_parse_data, cls=PrivateRegisterForExistingUserSerializer) + partial(_parse_data, cls=PrivateRegisterForExistingUserValidator) # Parse private register data for new user parse_private_register_for_new_user_data = \ - partial(_parse_data, cls=PrivateRegisterForNewUserSerializer) + partial(_parse_data, cls=PrivateRegisterForNewUserValidator) class RegisterTypeEnum(Enum): @@ -81,10 +82,10 @@ def parse_register_type(userdata:dict) -> str: """ # Create adhoc inner serializer for avoid parse # manually the user data. - class _serializer(serializers.Serializer): + class _validator(validators.Validator): existing = serializers.BooleanField() - instance = _serializer(data=userdata) + instance = _validator(data=userdata) if not instance.is_valid(): raise exc.RequestValidationError(instance.errors) diff --git a/taiga/auth/serializers.py b/taiga/auth/validators.py similarity index 81% rename from taiga/auth/serializers.py rename to taiga/auth/validators.py index 8e8df4e2..6c3661ed 100644 --- a/taiga/auth/serializers.py +++ b/taiga/auth/validators.py @@ -16,16 +16,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.core import validators +from django.core import validators as core_validators from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ from taiga.base.api import serializers +from taiga.base.api import validators import re -class BaseRegisterSerializer(serializers.Serializer): +class BaseRegisterValidator(validators.Validator): full_name = serializers.CharField(max_length=256) email = serializers.EmailField(max_length=255) username = serializers.CharField(max_length=255) @@ -33,7 +34,7 @@ class BaseRegisterSerializer(serializers.Serializer): def validate_username(self, attrs, source): value = attrs[source] - validator = validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), "invalid") + validator = core_validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), "invalid") try: validator(value) @@ -43,15 +44,15 @@ class BaseRegisterSerializer(serializers.Serializer): return attrs -class PublicRegisterSerializer(BaseRegisterSerializer): +class PublicRegisterValidator(BaseRegisterValidator): pass -class PrivateRegisterForNewUserSerializer(BaseRegisterSerializer): +class PrivateRegisterForNewUserValidator(BaseRegisterValidator): token = serializers.CharField(max_length=255, required=True) -class PrivateRegisterForExistingUserSerializer(serializers.Serializer): +class PrivateRegisterForExistingUserValidator(validators.Validator): username = serializers.CharField(max_length=255) password = serializers.CharField(min_length=4) token = serializers.CharField(max_length=255, required=True) From 6e3eeb7cca20eac0a931b6e384c82230e6f0a772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 6 Jul 2016 09:55:24 +0200 Subject: [PATCH 12/14] Migrating custom fields serializer --- taiga/projects/custom_attributes/api.py | 7 + .../projects/custom_attributes/serializers.py | 122 +++------------ .../projects/custom_attributes/validators.py | 146 ++++++++++++++++++ 3 files changed, 174 insertions(+), 101 deletions(-) create mode 100644 taiga/projects/custom_attributes/validators.py diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py index 9bfc774f..2d05d186 100644 --- a/taiga/projects/custom_attributes/api.py +++ b/taiga/projects/custom_attributes/api.py @@ -32,6 +32,7 @@ from taiga.projects.occ.mixins import OCCResourceMixin from . import models from . import serializers +from . import validators from . import permissions from . import services @@ -43,6 +44,7 @@ from . import services class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): model = models.UserStoryCustomAttribute serializer_class = serializers.UserStoryCustomAttributeSerializer + validator_class = validators.UserStoryCustomAttributeValidator permission_classes = (permissions.UserStoryCustomAttributePermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -54,6 +56,7 @@ class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixi class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): model = models.TaskCustomAttribute serializer_class = serializers.TaskCustomAttributeSerializer + validator_class = validators.TaskCustomAttributeValidator permission_classes = (permissions.TaskCustomAttributePermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -65,6 +68,7 @@ class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, Mo class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet): model = models.IssueCustomAttribute serializer_class = serializers.IssueCustomAttributeSerializer + validator_class = validators.IssueCustomAttributeValidator permission_classes = (permissions.IssueCustomAttributePermission,) filter_backends = (filters.CanViewProjectFilterBackend,) filter_fields = ("project",) @@ -86,6 +90,7 @@ class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): model = models.UserStoryCustomAttributesValues serializer_class = serializers.UserStoryCustomAttributesValuesSerializer + validator_class = validators.UserStoryCustomAttributesValuesValidator permission_classes = (permissions.UserStoryCustomAttributesValuesPermission,) lookup_field = "user_story_id" content_object = "user_story" @@ -99,6 +104,7 @@ class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): class TaskCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): model = models.TaskCustomAttributesValues serializer_class = serializers.TaskCustomAttributesValuesSerializer + validator_class = validators.TaskCustomAttributesValuesValidator permission_classes = (permissions.TaskCustomAttributesValuesPermission,) lookup_field = "task_id" content_object = "task" @@ -112,6 +118,7 @@ class TaskCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): class IssueCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): model = models.IssueCustomAttributesValues serializer_class = serializers.IssueCustomAttributesValuesSerializer + validator_class = validators.IssueCustomAttributesValuesValidator permission_classes = (permissions.IssueCustomAttributesValuesPermission,) lookup_field = "issue_id" content_object = "issue" diff --git a/taiga/projects/custom_attributes/serializers.py b/taiga/projects/custom_attributes/serializers.py index 64a934f5..d4fc084e 100644 --- a/taiga/projects/custom_attributes/serializers.py +++ b/taiga/projects/custom_attributes/serializers.py @@ -17,131 +17,51 @@ # along with this program. If not, see . -from django.apps import apps -from django.utils.translation import ugettext_lazy as _ - -from taiga.base.fields import JsonField -from taiga.base.api.serializers import ValidationError -from taiga.base.api.serializers import ModelSerializer - -from . import models +from taiga.base.fields import JsonField, Field +from taiga.base.api import serializers ###################################################### # Custom Attribute Serializer ####################################################### -class BaseCustomAttributeSerializer(ModelSerializer): - class Meta: - read_only_fields = ('id',) - exclude = ('created_date', 'modified_date') - - def _validate_integrity_between_project_and_name(self, attrs, source): - """ - Check the name is not duplicated in the project. Check when: - - create a new one - - update the name - - update the project (move to another project) - """ - data_id = attrs.get("id", None) - data_name = attrs.get("name", None) - data_project = attrs.get("project", None) - - if self.object: - data_id = data_id or self.object.id - data_name = data_name or self.object.name - data_project = data_project or self.object.project - - model = self.Meta.model - qs = (model.objects.filter(project=data_project, name=data_name) - .exclude(id=data_id)) - if qs.exists(): - raise ValidationError(_("Already exists one with the same name.")) - - return attrs - - def validate_name(self, attrs, source): - return self._validate_integrity_between_project_and_name(attrs, source) - - def validate_project(self, attrs, source): - return self._validate_integrity_between_project_and_name(attrs, source) +class BaseCustomAttributeSerializer(serializers.LightSerializer): + name = Field() + description = Field() + type = Field() + order = Field() + project = Field(attr="project_id") + created_date = Field() + modified_date = Field() class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer): - class Meta(BaseCustomAttributeSerializer.Meta): - model = models.UserStoryCustomAttribute + pass class TaskCustomAttributeSerializer(BaseCustomAttributeSerializer): - class Meta(BaseCustomAttributeSerializer.Meta): - model = models.TaskCustomAttribute + pass class IssueCustomAttributeSerializer(BaseCustomAttributeSerializer): - class Meta(BaseCustomAttributeSerializer.Meta): - model = models.IssueCustomAttribute + pass ###################################################### # Custom Attribute Serializer ####################################################### - - -class BaseCustomAttributesValuesSerializer(ModelSerializer): - attributes_values = JsonField(source="attributes_values", label="attributes values") - _custom_attribute_model = None - _container_field = None - - class Meta: - exclude = ("id",) - - def validate_attributes_values(self, attrs, source): - # values must be a dict - data_values = attrs.get("attributes_values", None) - if self.object: - data_values = (data_values or self.object.attributes_values) - - if type(data_values) is not dict: - raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) - - # Values keys must be in the container object project - data_container = attrs.get(self._container_field, None) - if data_container: - project_id = data_container.project_id - elif self.object: - project_id = getattr(self.object, self._container_field).project_id - else: - project_id = None - - values_ids = list(data_values.keys()) - qs = self._custom_attribute_model.objects.filter(project=project_id, - id__in=values_ids) - if qs.count() != len(values_ids): - raise ValidationError(_("It contain invalid custom fields.")) - - return attrs +class BaseCustomAttributesValuesSerializer(serializers.LightSerializer): + attributes_values = Field() + version = Field() class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): - _custom_attribute_model = models.UserStoryCustomAttribute - _container_model = "userstories.UserStory" - _container_field = "user_story" - - class Meta(BaseCustomAttributesValuesSerializer.Meta): - model = models.UserStoryCustomAttributesValues + user_story = Field(attr="user_story.id") -class TaskCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): - _custom_attribute_model = models.TaskCustomAttribute - _container_field = "task" - - class Meta(BaseCustomAttributesValuesSerializer.Meta): - model = models.TaskCustomAttributesValues +class TaskCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + task = Field(attr="task.id") -class IssueCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): - _custom_attribute_model = models.IssueCustomAttribute - _container_field = "issue" - - class Meta(BaseCustomAttributesValuesSerializer.Meta): - model = models.IssueCustomAttributesValues +class IssueCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + issue = Field(attr="issue.id") diff --git a/taiga/projects/custom_attributes/validators.py b/taiga/projects/custom_attributes/validators.py new file mode 100644 index 00000000..506c040c --- /dev/null +++ b/taiga/projects/custom_attributes/validators.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.fields import JsonField +from taiga.base.api.serializers import ValidationError +from taiga.base.api.validators import ModelValidator + +from . import models + + +###################################################### +# Custom Attribute Validator +####################################################### + +class BaseCustomAttributeValidator(ModelValidator): + class Meta: + read_only_fields = ('id',) + exclude = ('created_date', 'modified_date') + + def _validate_integrity_between_project_and_name(self, attrs, source): + """ + Check the name is not duplicated in the project. Check when: + - create a new one + - update the name + - update the project (move to another project) + """ + data_id = attrs.get("id", None) + data_name = attrs.get("name", None) + data_project = attrs.get("project", None) + + if self.object: + data_id = data_id or self.object.id + data_name = data_name or self.object.name + data_project = data_project or self.object.project + + model = self.Meta.model + qs = (model.objects.filter(project=data_project, name=data_name) + .exclude(id=data_id)) + if qs.exists(): + raise ValidationError(_("Already exists one with the same name.")) + + return attrs + + def validate_name(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + def validate_project(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + +class UserStoryCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.UserStoryCustomAttribute + + +class TaskCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.TaskCustomAttribute + + +class IssueCustomAttributeValidator(BaseCustomAttributeValidator): + class Meta(BaseCustomAttributeValidator.Meta): + model = models.IssueCustomAttribute + + +###################################################### +# Custom Attribute Validator +####################################################### + + +class BaseCustomAttributesValuesValidator(ModelValidator): + attributes_values = JsonField(source="attributes_values", label="attributes values") + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contain invalid custom fields.")) + + return attrs + + +class UserStoryCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator): + _custom_attribute_model = models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator, ModelValidator): + _custom_attribute_model = models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesValidator(BaseCustomAttributesValuesValidator, ModelValidator): + _custom_attribute_model = models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesValidator.Meta): + model = models.IssueCustomAttributesValues From 9c8a630fc6dca8653dc062e2f56e99ffa3597224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 6 Jul 2016 12:46:22 +0200 Subject: [PATCH 13/14] Moving ValidationError to taiga.base.exceptions --- taiga/auth/validators.py | 6 ++--- taiga/base/api/fields.py | 3 ++- taiga/base/api/mixins.py | 2 +- taiga/base/api/relations.py | 3 ++- taiga/base/api/serializers.py | 2 ++ taiga/base/exceptions.py | 5 +++++ taiga/export_import/serializers/fields.py | 2 +- .../export_import/serializers/serializers.py | 22 +++++++++---------- taiga/projects/api.py | 3 +-- .../projects/custom_attributes/validators.py | 2 +- taiga/projects/milestones/validators.py | 4 ++-- taiga/projects/notifications/validators.py | 4 ++-- taiga/projects/references/validators.py | 7 +++--- taiga/projects/tagging/fields.py | 2 +- taiga/projects/tasks/validators.py | 3 ++- taiga/projects/userstories/validators.py | 7 +++--- taiga/projects/validators.py | 17 +++++++------- taiga/users/serializers.py | 6 ----- taiga/users/validators.py | 12 +++++----- taiga/userstorage/api.py | 1 - 20 files changed, 58 insertions(+), 55 deletions(-) diff --git a/taiga/auth/validators.py b/taiga/auth/validators.py index 6c3661ed..a18dc4bc 100644 --- a/taiga/auth/validators.py +++ b/taiga/auth/validators.py @@ -17,11 +17,11 @@ # along with this program. If not, see . from django.core import validators as core_validators -from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.api import validators +from taiga.base.exceptions import ValidationError import re @@ -39,8 +39,8 @@ class BaseRegisterValidator(validators.Validator): try: validator(value) except ValidationError: - raise serializers.ValidationError(_("Required. 255 characters or fewer. Letters, numbers " - "and /./-/_ characters'")) + raise ValidationError(_("Required. 255 characters or fewer. Letters, numbers " + "and /./-/_ characters'")) return attrs diff --git a/taiga/base/api/fields.py b/taiga/base/api/fields.py index 7dfa2c0a..fc4035c2 100644 --- a/taiga/base/api/fields.py +++ b/taiga/base/api/fields.py @@ -50,7 +50,6 @@ They are very similar to Django's form fields. from django import forms from django.conf import settings from django.core import validators -from django.core.exceptions import ValidationError from django.db.models.fields import BLANK_CHOICE_DASH from django.forms import widgets from django.http import QueryDict @@ -66,6 +65,8 @@ from django.utils.functional import Promise from django.utils.translation import ugettext from django.utils.translation import ugettext_lazy as _ +from taiga.base.exceptions import ValidationError + from . import ISO_8601 from .settings import api_settings diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py index 861d77ec..c38b5cb7 100644 --- a/taiga/base/api/mixins.py +++ b/taiga/base/api/mixins.py @@ -44,12 +44,12 @@ import warnings -from django.core.exceptions import ValidationError from django.http import Http404 from django.db import transaction as tx from django.utils.translation import ugettext as _ from taiga.base import response +from taiga.base.exceptions import ValidationError from .settings import api_settings from .utils import get_object_or_404 diff --git a/taiga/base/api/relations.py b/taiga/base/api/relations.py index 60ba9a6e..6fbb98f5 100644 --- a/taiga/base/api/relations.py +++ b/taiga/base/api/relations.py @@ -48,7 +48,7 @@ Serializer fields that deal with relationships. These fields allow you to specify the style that should be used to represent model relationships, including hyperlinks, primary keys, or slugs. """ -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ObjectDoesNotExist from django.core.urlresolvers import resolve, get_script_prefix, NoReverseMatch from django import forms from django.db.models.fields import BLANK_CHOICE_DASH @@ -59,6 +59,7 @@ from django.utils.translation import ugettext_lazy as _ from .fields import Field, WritableField, get_component, is_simple_callable from .reverse import reverse +from taiga.base.exceptions import ValidationError import warnings from urllib import parse as urlparse diff --git a/taiga/base/api/serializers.py b/taiga/base/api/serializers.py index 82565b26..f2dfd849 100644 --- a/taiga/base/api/serializers.py +++ b/taiga/base/api/serializers.py @@ -78,6 +78,8 @@ import serpy # This helps keep the separation between model fields, form fields, and # serializer fields more explicit. +from taiga.base.exceptions import ValidationError + from .relations import * from .fields import * diff --git a/taiga/base/exceptions.py b/taiga/base/exceptions.py index cc58ee6d..73d277ff 100644 --- a/taiga/base/exceptions.py +++ b/taiga/base/exceptions.py @@ -51,6 +51,7 @@ In addition Django's built in 403 and 404 exceptions are handled. """ from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from django.http import Http404 @@ -224,6 +225,7 @@ class NotEnoughSlotsForProject(BaseException): "total_memberships": total_memberships } + def format_exception(exc): if isinstance(exc.detail, (dict, list, tuple,)): detail = exc.detail @@ -270,3 +272,6 @@ def exception_handler(exc): # Note: Unhandled exceptions will raise a 500 error. return None + + +ValidationError = DjangoValidationError diff --git a/taiga/export_import/serializers/fields.py b/taiga/export_import/serializers/fields.py index 64c01436..9ed21a19 100644 --- a/taiga/export_import/serializers/fields.py +++ b/taiga/export_import/serializers/fields.py @@ -23,11 +23,11 @@ from collections import OrderedDict from django.core.files.base import ContentFile from django.core.exceptions import ObjectDoesNotExist -from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ from django.contrib.contenttypes.models import ContentType from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError from taiga.base.fields import JsonField from taiga.mdrender.service import render as mdrender from taiga.users import models as users_models diff --git a/taiga/export_import/serializers/serializers.py b/taiga/export_import/serializers/serializers.py index 7cf46cba..6a316b68 100644 --- a/taiga/export_import/serializers/serializers.py +++ b/taiga/export_import/serializers/serializers.py @@ -16,13 +16,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import copy - -from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.fields import JsonField, PgArrayField +from taiga.base.exceptions import ValidationError from taiga.projects import models as projects_models from taiga.projects.custom_attributes import models as custom_attributes_models @@ -31,15 +29,12 @@ from taiga.projects.tasks import models as tasks_models from taiga.projects.issues import models as issues_models from taiga.projects.milestones import models as milestones_models from taiga.projects.wiki import models as wiki_models -from taiga.projects.history import models as history_models -from taiga.projects.attachments import models as attachments_models from taiga.timeline import models as timeline_models from taiga.users import models as users_models from taiga.projects.votes import services as votes_service -from .fields import (FileField, RelatedNoneSafeField, UserRelatedField, - UserPkField, CommentField, ProjectRelatedField, - HistoryUserField, HistoryValuesField, HistoryDiffField, +from .fields import (FileField, UserRelatedField, + ProjectRelatedField, TimelineDataField, ContentTypeField) from .mixins import (HistoryExportSerializerMixin, AttachmentExportSerializerMixin, @@ -125,7 +120,7 @@ class IssueCustomAttributeExportSerializer(serializers.ModelSerializer): class BaseCustomAttributesValuesExportSerializer(serializers.ModelSerializer): - attributes_values = JsonField(source="attributes_values",required=True) + attributes_values = JsonField(source="attributes_values", required=True) _custom_attribute_model = None _container_field = None @@ -158,6 +153,7 @@ class BaseCustomAttributesValuesExportSerializer(serializers.ModelSerializer): return attrs + class UserStoryCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute _container_model = "userstories.UserStory" @@ -224,7 +220,7 @@ class MilestoneExportSerializer(WatcheableObjectModelSerializerMixin): name = attrs[source] qs = self.project.milestones.filter(name=name) if qs.exists(): - raise serializers.ValidationError(_("Name duplicated for the project")) + raise ValidationError(_("Name duplicated for the project")) return attrs @@ -268,7 +264,9 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, His def custom_attributes_queryset(self, project): if project.id not in _custom_userstories_attributes_cache: - _custom_userstories_attributes_cache[project.id] = list(project.userstorycustomattributes.all().values('id', 'name')) + _custom_userstories_attributes_cache[project.id] = list( + project.userstorycustomattributes.all().values('id', 'name') + ) return _custom_userstories_attributes_cache[project.id] @@ -314,10 +312,10 @@ class WikiLinkExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') - class TimelineExportSerializer(serializers.ModelSerializer): data = TimelineDataField() data_content_type = ContentTypeField() + class Meta: model = timeline_models.Timeline exclude = ('id', 'project', 'namespace', 'object_id', 'content_type') diff --git a/taiga/projects/api.py b/taiga/projects/api.py index c441e419..6445c17f 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -22,7 +22,6 @@ from dateutil.relativedelta import relativedelta from django.apps import apps from django.conf import settings -from django.core.exceptions import ValidationError from django.http import Http404 from django.utils.translation import ugettext as _ from django.utils import timezone @@ -651,7 +650,7 @@ class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet): invitation_extra_text=invitation_extra_text, callback=self.post_save, precall=self.pre_save) - except ValidationError as err: + except exc.ValidationError as err: return response.BadRequest(err.message_dict) members_serialized = self.admin_serializer_class(members, many=True) diff --git a/taiga/projects/custom_attributes/validators.py b/taiga/projects/custom_attributes/validators.py index 506c040c..6663de5d 100644 --- a/taiga/projects/custom_attributes/validators.py +++ b/taiga/projects/custom_attributes/validators.py @@ -20,7 +20,7 @@ from django.utils.translation import ugettext_lazy as _ from taiga.base.fields import JsonField -from taiga.base.api.serializers import ValidationError +from taiga.base.exceptions import ValidationError from taiga.base.api.validators import ModelValidator from . import models diff --git a/taiga/projects/milestones/validators.py b/taiga/projects/milestones/validators.py index 8de3174c..b7d4d484 100644 --- a/taiga/projects/milestones/validators.py +++ b/taiga/projects/milestones/validators.py @@ -18,7 +18,7 @@ from django.utils.translation import ugettext as _ -from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError from taiga.base.api import validators from taiga.projects.validators import DuplicatedNameInProjectValidator from taiga.projects.notifications.validators import WatchersValidator @@ -31,7 +31,7 @@ class MilestoneExistsValidator: value = attrs[source] if not models.Milestone.objects.filter(pk=value).exists(): msg = _("There's no milestone with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs diff --git a/taiga/projects/notifications/validators.py b/taiga/projects/notifications/validators.py index 851cc309..40e02083 100644 --- a/taiga/projects/notifications/validators.py +++ b/taiga/projects/notifications/validators.py @@ -18,7 +18,7 @@ from django.utils.translation import ugettext as _ -from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError class WatchersValidator: @@ -45,6 +45,6 @@ class WatchersValidator: existing_watcher_ids = project.get_watchers().values_list("id", flat=True) result = set(users).difference(member_ids).difference(existing_watcher_ids) if result: - raise serializers.ValidationError(_("Watchers contains invalid users")) + raise ValidationError(_("Watchers contains invalid users")) return attrs diff --git a/taiga/projects/references/validators.py b/taiga/projects/references/validators.py index 5fcefee8..85456c4c 100644 --- a/taiga/projects/references/validators.py +++ b/taiga/projects/references/validators.py @@ -18,6 +18,7 @@ from taiga.base.api import serializers from taiga.base.api import validators +from taiga.base.exceptions import ValidationError class ResolverValidator(validators.Validator): @@ -32,10 +33,10 @@ class ResolverValidator(validators.Validator): def validate(self, attrs): if "ref" in attrs: if "us" in attrs: - raise serializers.ValidationError("'us' param is incompatible with 'ref' in the same request") + raise ValidationError("'us' param is incompatible with 'ref' in the same request") if "task" in attrs: - raise serializers.ValidationError("'task' param is incompatible with 'ref' in the same request") + raise ValidationError("'task' param is incompatible with 'ref' in the same request") if "issue" in attrs: - raise serializers.ValidationError("'issue' param is incompatible with 'ref' in the same request") + raise ValidationError("'issue' param is incompatible with 'ref' in the same request") return attrs diff --git a/taiga/projects/tagging/fields.py b/taiga/projects/tagging/fields.py index 24f92f23..47553d8c 100644 --- a/taiga/projects/tagging/fields.py +++ b/taiga/projects/tagging/fields.py @@ -16,11 +16,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.core.exceptions import ValidationError from django.forms import widgets from django.utils.translation import ugettext_lazy as _ from taiga.base.api import serializers +from taiga.base.exceptions import ValidationError import re diff --git a/taiga/projects/tasks/validators.py b/taiga/projects/tasks/validators.py index 7f71636c..ddb3f33b 100644 --- a/taiga/projects/tasks/validators.py +++ b/taiga/projects/tasks/validators.py @@ -20,6 +20,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.api import validators +from taiga.base.exceptions import ValidationError from taiga.base.fields import PgArrayField from taiga.projects.milestones.validators import MilestoneExistsValidator from taiga.projects.notifications.mixins import EditableWatchedResourceSerializer @@ -34,7 +35,7 @@ class TaskExistsValidator: value = attrs[source] if not models.Task.objects.filter(pk=value).exists(): msg = _("There's no task with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs diff --git a/taiga/projects/userstories/validators.py b/taiga/projects/userstories/validators.py index 4ea0b24a..2d61934f 100644 --- a/taiga/projects/userstories/validators.py +++ b/taiga/projects/userstories/validators.py @@ -21,6 +21,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.api import validators from taiga.base.api.utils import get_object_or_404 +from taiga.base.exceptions import ValidationError from taiga.base.fields import PgArrayField from taiga.base.fields import PickledObjectField from taiga.projects.milestones.validators import MilestoneExistsValidator @@ -40,7 +41,7 @@ class UserStoryExistsValidator: value = attrs[source] if not models.UserStory.objects.filter(pk=value).exists(): msg = _("There's no user story with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs @@ -105,9 +106,9 @@ class UpdateMilestoneBulkValidator(ProjectExistsValidator, MilestoneExistsValida project = get_object_or_404(Project, pk=data["project_id"]) if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids): - raise serializers.ValidationError("all the user stories must be from the same project") + raise ValidationError("all the user stories must be from the same project") if project.milestones.filter(id=data["milestone_id"]).count() != 1: - raise serializers.ValidationError("the milestone isn't valid for the project") + raise ValidationError("the milestone isn't valid for the project") return data diff --git a/taiga/projects/validators.py b/taiga/projects/validators.py index c8ab21bb..de06c05c 100644 --- a/taiga/projects/validators.py +++ b/taiga/projects/validators.py @@ -21,6 +21,7 @@ from django.utils.translation import ugettext as _ from taiga.base.api import serializers from taiga.base.api import validators +from taiga.base.exceptions import ValidationError from taiga.base.fields import JsonField from taiga.base.fields import PgArrayField from taiga.users.validators import RoleExistsValidator @@ -49,7 +50,7 @@ class DuplicatedNameInProjectValidator: qs = model.objects.filter(project=attrs["project"], name=attrs[source]) if qs and qs.exists(): - raise serializers.ValidationError(_("Name duplicated for the project")) + raise ValidationError(_("Name duplicated for the project")) return attrs @@ -59,7 +60,7 @@ class ProjectExistsValidator: value = attrs[source] if not models.Project.objects.filter(pk=value).exists(): msg = _("There's no project with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs @@ -68,7 +69,7 @@ class UserStoryStatusExistsValidator: value = attrs[source] if not models.UserStoryStatus.objects.filter(pk=value).exists(): msg = _("There's no user story status with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs @@ -77,7 +78,7 @@ class TaskStatusExistsValidator: value = attrs[source] if not models.TaskStatus.objects.filter(pk=value).exists(): msg = _("There's no task status with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs @@ -152,7 +153,7 @@ class MembershipValidator(validators.ModelValidator): Q(project_id=project.id, email=email)) if qs.count() > 0: - raise serializers.ValidationError(_("Email address is already taken")) + raise ValidationError(_("Email address is already taken")) return attrs @@ -164,7 +165,7 @@ class MembershipValidator(validators.ModelValidator): role = attrs[source] if project.roles.filter(id=role.id).count() == 0: - raise serializers.ValidationError(_("Invalid role for the project")) + raise ValidationError(_("Invalid role for the project")) return attrs @@ -175,10 +176,10 @@ class MembershipValidator(validators.ModelValidator): if (self.object and self.object.user): if self.object.user.id == project.owner_id and not attrs[source]: - raise serializers.ValidationError(_("The project owner must be admin.")) + raise ValidationError(_("The project owner must be admin.")) if not services.project_has_valid_admins(project, exclude_user=self.object.user): - raise serializers.ValidationError( + raise ValidationError( _("At least one user must be an active admin for this project.") ) diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 75daa74e..a28f5ce3 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -17,9 +17,6 @@ # along with this program. If not, see . from django.conf import settings -from django.core import validators -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, I18NField @@ -27,14 +24,11 @@ from taiga.base.fields import PgArrayField, Field, MethodField, I18NField from taiga.base.utils.thumbnails import get_thumbnail_url from taiga.projects.models import Project -from .models import User, Role from .services import get_photo_or_gravatar_url, get_big_photo_or_gravatar_url from .gravatar import get_gravatar_url from collections import namedtuple -import re - ###################################################### # User diff --git a/taiga/users/validators.py b/taiga/users/validators.py index 11e78efb..f23da47a 100644 --- a/taiga/users/validators.py +++ b/taiga/users/validators.py @@ -17,12 +17,12 @@ # along with this program. If not, see . 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 taiga.base.exceptions import ValidationError +from taiga.base.fields import PgArrayField from .models import User, Role @@ -34,7 +34,7 @@ class RoleExistsValidator: value = attrs[source] if not Role.objects.filter(pk=value).exists(): msg = _("There's no role with that id") - raise serializers.ValidationError(msg) + raise ValidationError(msg) return attrs @@ -55,13 +55,13 @@ class UserValidator(validators.ModelValidator): try: validator(value) except ValidationError: - raise validators.ValidationError(_("Required. 255 characters or fewer. Letters, " - "numbers and /./-/_ characters'")) + raise 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.")) + raise ValidationError(_("Invalid username. Try with a different one.")) return attrs diff --git a/taiga/userstorage/api.py b/taiga/userstorage/api.py index 5b097e71..94c5ea00 100644 --- a/taiga/userstorage/api.py +++ b/taiga/userstorage/api.py @@ -19,7 +19,6 @@ from django.utils.translation import ugettext as _ from taiga.base.api import ModelCrudViewSet -from taiga.base.api.serializers import ValidationError from taiga.base import exceptions as exc from . import models From dd4a1cd9e776278741444d56eb5d2fce3d79a959 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 6 Jul 2016 14:13:09 +0200 Subject: [PATCH 14/14] Improving by_ref endpoints and allowing to use the project slug --- taiga/projects/issues/api.py | 13 +++++++++++-- taiga/projects/tasks/api.py | 13 +++++++++++-- taiga/projects/userstories/api.py | 13 +++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index f204fc13..093b3ad1 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -183,9 +183,18 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W @list_route(methods=["GET"]) def by_ref(self, request): - ref = request.QUERY_PARAMS.get("ref", None) + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } project_id = request.QUERY_PARAMS.get("project", None) - return self.retrieve(request, project_id=project_id, ref=ref) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) @list_route(methods=["GET"]) def filters_data(self, request, *args, **kwargs): diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index bec134c5..3dc2bd32 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -163,9 +163,18 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, @list_route(methods=["GET"]) def by_ref(self, request): - ref = request.QUERY_PARAMS.get("ref", None) + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } project_id = request.QUERY_PARAMS.get("project", None) - return self.retrieve(request, project_id=project_id, ref=ref) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) @list_route(methods=["GET"]) def csv(self, request): diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index 47487e88..0e718d10 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -224,9 +224,18 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi @list_route(methods=["GET"]) def by_ref(self, request): - ref = request.QUERY_PARAMS.get("ref", None) + retrieve_kwargs = { + "ref": request.QUERY_PARAMS.get("ref", None) + } project_id = request.QUERY_PARAMS.get("project", None) - return self.retrieve(request, project_id=project_id, ref=ref) + if project_id is not None: + retrieve_kwargs["project_id"] = project_id + + project_slug = request.QUERY_PARAMS.get("project__slug", None) + if project_slug is not None: + retrieve_kwargs["project__slug"] = project_slug + + return self.retrieve(request, **retrieve_kwargs) @list_route(methods=["GET"]) def csv(self, request):