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