diff --git a/.travis.yml b/.travis.yml index e9f47601..8e5be2e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,6 @@ notifications: - jespinog@gmail.com - andrei.antoukh@gmail.com - bameda@dbarragan.com - - anler86@gmail.com on_success: change on_failure: change after_success: diff --git a/CHANGELOG.md b/CHANGELOG.md index 074e016a..9f63d14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog # + +## 1.4.0 Abies veitchii (2014-12-10) + +### Features +- Bitbucket integration: + + Change status of user stories, tasks and issues with the commit messages. +- Gitlab integration: + + Change status of user stories, tasks and issues with the commit messages. + + Sync issues creation in Taiga from Gitlab. +- Support throttling. + + for anonymous users + + for authenticated users + + in import mode +- Add project members stats endpoint. +- Support of leave project. +- Control of leave a project without admin user. +- Improving OCC (Optimistic concurrency control) +- Improving some SQL queries using djrom directly + +### Misc +- Lots of small and not so small bugfixes. + + ## 1.3.0 Dryas hookeriana (2014-11-18) ### Features @@ -7,7 +30,7 @@ + Login/singin connector. + Change status of user stories, tasks and issues with the commit messages. + Sync issues creation in Taiga from GitHub. - + Sync comments in Taiga from GitHub issues. + + Sync comments in Taiga from GitHub issues. ### Misc - Lots of small and not so small bugfixes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f2ac953..5f537b6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Examples of contributions include: - Code patches. - Documentation improvements. - Bug reports. -- Patch reviews. +- UI enhancements Before start developing one big feature (with intentions of including it on taiga code base), it is strongly recommended chat about it using our [mailing list](http://groups.google.com/d/forum/taigaio). diff --git a/README.md b/README.md index f99c51b7..5e23fc97 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,9 @@ scripts https://github.com/taigaio/taiga-scripts (warning: alpha state) [Taiga has a mailing list](http://groups.google.com/d/forum/taigaio). Feel free to join it and ask any questions you may have. To subscribe for announcements of releases, important changes and so on, please follow [@taigaio](https://twitter.com/taigaio) on Twitter. + +## Donations ## + +We are grateful for your emails volunteering donations to Taiga. We feel comfortable accepting them under these conditions: The first that we will only do so while we are in the current beta / pre-revenue stage and that whatever money is donated will go towards a bounty fund. Starting Q2 2015 we will be engaging much more actively with our community to help further the development of Taiga, and we will use these donations to reward people working alongside us. + +If you wish to make a donation to this Taiga fund, you can do so via http://www.paypal.com using the email: eposner@taiga.io diff --git a/requirements.txt b/requirements.txt index 9769ddb3..b741b4f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ redis==2.10.3 Unidecode==0.04.16 raven==5.1.1 bleach==1.4 +django-ipware==0.1.0 # Comment it if you are using python >= 3.4 enum34==1.0 diff --git a/settings/common.py b/settings/common.py index 8a52d4e8..81a91568 100644 --- a/settings/common.py +++ b/settings/common.py @@ -194,7 +194,9 @@ INSTALLED_APPS = [ "taiga.mdrender", "taiga.export_import", "taiga.feedback", - "taiga.github_hook", + "taiga.hooks.github", + "taiga.hooks.gitlab", + "taiga.hooks.bitbucket", "rest_framework", "djmail", @@ -291,6 +293,15 @@ REST_FRAMEWORK = { # Mainly used for api debug. "taiga.auth.backends.Session", ), + "DEFAULT_THROTTLE_CLASSES": ( + "taiga.base.throttling.AnonRateThrottle", + "taiga.base.throttling.UserRateThrottle" + ), + "DEFAULT_THROTTLE_RATES": { + "anon": None, + "user": None, + "import-mode": None + }, "FILTER_BACKEND": "taiga.base.filters.FilterBackend", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", "PAGINATE_BY": 30, @@ -299,6 +310,7 @@ REST_FRAMEWORK = { "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z" } + DEFAULT_PROJECT_TEMPLATE = "scrum" PUBLIC_REGISTER_ENABLED = False @@ -342,9 +354,13 @@ CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds # List of functions called for filling correctly the ProjectModulesConfig associated to a project # This functions should receive a Project parameter and return a dict with the desired configuration PROJECT_MODULES_CONFIGURATORS = { - "github": "taiga.github_hook.services.get_or_generate_config", + "github": "taiga.hooks.github.services.get_or_generate_config", + "gitlab": "taiga.hooks.gitlab.services.get_or_generate_config", + "bitbucket": "taiga.hooks.bitbucket.services.get_or_generate_config", } +BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166"] +GITLAB_VALID_ORIGIN_IPS = [] # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE TEST_RUNNER="django.test.runner.DiscoverRunner" diff --git a/settings/local.py.example b/settings/local.py.example index 2588f9fd..947e0ee7 100644 --- a/settings/local.py.example +++ b/settings/local.py.example @@ -32,20 +32,29 @@ from .development import * #MEDIA_ROOT = '/home/taiga/media' #STATIC_ROOT = '/home/taiga/static' +# EMAIL SETTINGS EXAMPLE +#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' #EMAIL_USE_TLS = False #EMAIL_HOST = 'localhost' +#EMAIL_PORT = 25 #EMAIL_HOST_USER = 'user' #EMAIL_HOST_PASSWORD = 'password' -#EMAIL_PORT = 25 #DEFAULT_FROM_EMAIL = "john@doe.com" # GMAIL SETTINGS EXAMPLE #EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' #EMAIL_USE_TLS = True #EMAIL_HOST = 'smtp.gmail.com' +#EMAIL_PORT = 587 #EMAIL_HOST_USER = 'youremail@gmail.com' #EMAIL_HOST_PASSWORD = 'yourpassword' -#EMAIL_PORT = 587 + +# THROTTLING +#REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { +# "anon": "20/min", +# "user": "200/min", +# "import-mode": "20/sec" +#} # GITHUB SETTINGS #GITHUB_URL = "https://github.com/" diff --git a/settings/testing.py b/settings/testing.py index 3e6235fc..2df79576 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -24,3 +24,9 @@ MEDIA_ROOT = "/tmp" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" INSTALLED_APPS = INSTALLED_APPS + ["tests"] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { + "anon": None, + "user": None, + "import-mode": None +} diff --git a/taiga/base/api/generics.py b/taiga/base/api/generics.py index 6cee8f18..752ae201 100644 --- a/taiga/base/api/generics.py +++ b/taiga/base/api/generics.py @@ -22,7 +22,6 @@ import warnings from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.paginator import Paginator, InvalidPage from django.http import Http404 -from django.shortcuts import get_object_or_404 as _get_object_or_404 from django.utils.translation import ugettext as _ from rest_framework import exceptions @@ -31,6 +30,7 @@ from rest_framework.settings import api_settings from . import views from . import mixins +from .utils import get_object_or_404 def strict_positive_int(integer_string, cutoff=None): @@ -45,17 +45,6 @@ def strict_positive_int(integer_string, cutoff=None): return ret -def get_object_or_404(queryset, *filter_args, **filter_kwargs): - """ - Same as Django's standard shortcut, but make sure to raise 404 - if the filter_kwargs don't match the required types. - """ - try: - return _get_object_or_404(queryset, *filter_args, **filter_kwargs) - except (TypeError, ValueError): - raise Http404 - - class GenericAPIView(views.APIView): """ Base class for all other generic views. diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py index 5e901648..bca89e63 100644 --- a/taiga/base/api/mixins.py +++ b/taiga/base/api/mixins.py @@ -20,7 +20,6 @@ import warnings from django.core.exceptions import ValidationError -from django.shortcuts import get_object_or_404 from django.http import Http404 from django.db import transaction as tx @@ -29,6 +28,8 @@ from rest_framework.response import Response from rest_framework.request import clone_request from rest_framework.settings import api_settings +from .utils import get_object_or_404 + def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): """ diff --git a/taiga/base/api/utils.py b/taiga/base/api/utils.py new file mode 100644 index 00000000..433e668a --- /dev/null +++ b/taiga/base/api/utils.py @@ -0,0 +1,32 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +# This code is partially taken from django-rest-framework: +# Copyright (c) 2011-2014, Tom Christie + +from django.http import Http404 +from django.shortcuts import get_object_or_404 as _get_object_or_404 + + +def get_object_or_404(queryset, *filter_args, **filter_kwargs): + """ + Same as Django's standard shortcut, but make sure to raise 404 + if the filter_kwargs don't match the required types. + """ + try: + return _get_object_or_404(queryset, *filter_args, **filter_kwargs) + except (TypeError, ValueError): + raise Http404 diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 986a0192..f596c409 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -103,28 +103,13 @@ class PermissionBasedFilterBackend(FilterBackend): memberships_qs = Membership.objects.filter(user=request.user) if project_id: memberships_qs = memberships_qs.filter(project_id=project_id) - - # Force users_role table inclusion - memberships_qs = memberships_qs.exclude(role__slug="not valid slug") - where_sql = ["users_role.permissions @> ARRAY['{}']".format(self.permission)] - memberships_qs = memberships_qs.extra(where=where_sql) + memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | Q(is_owner=True)) projects_list = [membership.project_id for membership in memberships_qs] - if len(projects_list) == 0: - qs = qs.filter(Q(project__owner=request.user)) - elif len(projects_list) == 1: - qs = qs.filter(Q(project__owner=request.user) | Q(project=projects_list[0])) - else: - qs = qs.filter(Q(project__owner=request.user) | Q(project__in=projects_list)) - extra_where = ExtraWhere(["projects_project.public_permissions @> ARRAY['{}']".format( - self.permission)], []) - qs.query.where.add(extra_where, OR) + qs = qs.filter(Q(project_id__in=projects_list) | Q(project__public_permissions__contains=[self.permission])) else: - qs = qs.exclude(project__owner=-1) - extra_where = ExtraWhere(["projects_project.anon_permissions @> ARRAY['{}']".format( - self.permission)], []) - qs.query.where.add(extra_where, AND) + qs = qs.filter(project__anon_permissions__contains=[self.permission]) return super().filter_queryset(request, qs.distinct(), view) @@ -197,19 +182,13 @@ class CanViewProjectObjFilterBackend(FilterBackend): memberships_qs = Membership.objects.filter(user=request.user) if project_id: memberships_qs = memberships_qs.filter(project_id=project_id) - memberships_qs = memberships_qs.exclude(role__slug="not valid slug") # Force users_role table inclusion - memberships_qs = memberships_qs.extra(where=["users_role.permissions @> ARRAY['view_project']"]) + memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) | Q(is_owner=True)) + projects_list = [membership.project_id for membership in memberships_qs] - if len(projects_list) == 0: - qs = qs.filter(Q(owner=request.user)) - elif len(projects_list) == 1: - qs = qs.filter(Q(owner=request.user) | Q(id=projects_list[0])) - else: - qs = qs.filter(Q(owner=request.user) | Q(id__in=projects_list)) - qs.query.where.add(ExtraWhere(["projects_project.public_permissions @> ARRAY['view_project']"], []), OR) + qs = qs.filter(Q(id__in=projects_list) | Q(public_permissions__contains=["view_project"])) else: - qs.query.where.add(ExtraWhere(["projects_project.anon_permissions @> ARRAY['view_project']"], []), AND) + qs = qs.filter(public_permissions__contains=["view_project"]) return super().filter_queryset(request, qs.distinct(), view) @@ -219,8 +198,7 @@ class IsProjectMemberFilterBackend(FilterBackend): if request.user.is_authenticated() and request.user.is_superuser: queryset = queryset elif request.user.is_authenticated(): - queryset = queryset.filter(Q(project__members=request.user) | - Q(project__owner=request.user)) + queryset = queryset.filter(project__members=request.user) else: queryset = queryset.none() @@ -232,12 +210,16 @@ class TagsFilter(FilterBackend): self.filter_name = filter_name def _get_tags_queryparams(self, params): - return params.get(self.filter_name, "") + tags = params.get(self.filter_name, None) + if tags: + return tags.split(",") + + return None def filter_queryset(self, request, queryset, view): query_tags = self._get_tags_queryparams(request.QUERY_PARAMS) if query_tags: - queryset = tags.filter(queryset, contains=query_tags) + queryset = queryset.filter(tags__contains=query_tags) return super().filter_queryset(request, queryset, view) diff --git a/taiga/base/tags.py b/taiga/base/tags.py index 8a02ab9e..9575f090 100644 --- a/taiga/base/tags.py +++ b/taiga/base/tags.py @@ -29,94 +29,3 @@ class TaggedMixin(models.Model): class Meta: abstract = True - - -def get_queryset_table(queryset): - """Return queryset model's table name""" - return queryset.model._meta.db_table - - -def _filter_bin(queryset, value, operator): - """tags """ - if not isinstance(value, str): - value = ",".join(value) - - sql = "{table_name}.tags {operator} string_to_array(%s, ',')" - where_clause = sql.format(table_name=get_queryset_table(queryset), operator=operator) - queryset = queryset.extra(where=[where_clause], params=[value]) - return queryset -_filter_contains = partial(_filter_bin, operator="@>") -_filter_contained_by = partial(_filter_bin, operator="<@") -_filter_overlap = partial(_filter_bin, operator="&&") - - -def _filter_index(queryset, index, value): - """tags[] == """ - sql = "{table_name}.tags[{index}] = %s" - where_clause = sql.format(table_name=get_queryset_table(queryset), index=index) - queryset = queryset.extra(where=[where_clause], params=[value]) - return queryset - - -def _filter_len(queryset, value): - """len(tags) == """ - sql = "array_length({table_name}.tags, 1) = %s" - where_clause = sql.format(table_name=get_queryset_table(queryset)) - queryset = queryset.extra(where=[where_clause], params=[value]) - return queryset - - -def _filter_len_operator(queryset, value, operator): - """len(tags) """ - operator = {"gt": ">", "lt": "<", "gte": ">=", "lte": "<="}[operator] - sql = "array_length({table_name}.tags, 1) {operator} %s" - where_clause = sql.format(table_name=get_queryset_table(queryset), operator=operator) - queryset = queryset.extra(where=[where_clause], params=[value]) - return queryset - - -def _filter_index_operator(queryset, value, operator): - """tags[] == value""" - index = int(operator) + 1 - sql = "{table_name}.tags[{index}] = %s" - where_clause = sql.format(table_name=get_queryset_table(queryset), index=index) - queryset = queryset.extra(where=[where_clause], params=[value]) - return queryset - - -def _tags_filter(**filters_map): - filter_re = re.compile(r"""(?:(len__)(gte|lte|lt|gt) - | - (index__)(\d+))""", re.VERBOSE) - - def get_filter(filter_name, strict=False): - return filters_map[filter_name] if strict else filters_map.get(filter_name) - - def get_filter_matching(filter_name): - match = filter_re.search(filter_name) - filter_name, operator = (group for group in match.groups() if group) - return partial(get_filter(filter_name, strict=True), operator=operator) - - def tags_filter(model_or_qs, **filters): - "Filter a queryset but adding support to filters that work with postgresql array fields" - if hasattr(model_or_qs, "_meta"): - qs = model_or_qs._default_manager.get_queryset() - else: - qs = model_or_qs - - for filter_name, filter_value in filters.items(): - try: - filter = get_filter(filter_name) or get_filter_matching(filter_name) - except (LookupError, AttributeError): - qs = qs.filter(**{filter_name: filter_value}) - else: - qs = filter(queryset=qs, value=filter_value) - return qs - - return tags_filter -filter = _tags_filter(contains=_filter_contains, - contained_by=_filter_contained_by, - overlap=_filter_overlap, - len=_filter_len, - len__=_filter_len_operator, - index__=_filter_index_operator) diff --git a/taiga/base/throttling.py b/taiga/base/throttling.py new file mode 100644 index 00000000..f931bd73 --- /dev/null +++ b/taiga/base/throttling.py @@ -0,0 +1,25 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 rest_framework import throttling + + +class AnonRateThrottle(throttling.AnonRateThrottle): + scope = "anon" + + +class UserRateThrottle(throttling.UserRateThrottle): + scope = "user" diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index 45bd13f0..8277be63 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -28,6 +28,7 @@ from taiga.base.decorators import detail_route from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue +from . import mixins from . import serializers from . import service from . import permissions @@ -37,7 +38,7 @@ class Http400(APIException): status_code = 400 -class ProjectImporterViewSet(CreateModelMixin, GenericViewSet): +class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet): model = Project permission_classes = (permissions.ImportPermission, ) diff --git a/taiga/export_import/mixins.py b/taiga/export_import/mixins.py new file mode 100644 index 00000000..bc5504fa --- /dev/null +++ b/taiga/export_import/mixins.py @@ -0,0 +1,21 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . import throttling + + +class ImportThrottlingPolicyMixin: + throttle_classes = (throttling.ImportModeRateThrottle,) diff --git a/taiga/export_import/throttling.py b/taiga/export_import/throttling.py new file mode 100644 index 00000000..3457ee44 --- /dev/null +++ b/taiga/export_import/throttling.py @@ -0,0 +1,21 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 import throttling + + +class ImportModeRateThrottle(throttling.UserRateThrottle): + scope = "import-mode" diff --git a/taiga/github_hook/__init__.py b/taiga/hooks/__init__.py similarity index 100% rename from taiga/github_hook/__init__.py rename to taiga/hooks/__init__.py diff --git a/taiga/github_hook/api.py b/taiga/hooks/api.py similarity index 72% rename from taiga/github_hook/api.py rename to taiga/hooks/api.py index 23c4a65a..105d0189 100644 --- a/taiga/github_hook/api.py +++ b/taiga/hooks/api.py @@ -22,44 +22,20 @@ from taiga.base import exceptions as exc from taiga.base.utils import json from taiga.projects.models import Project -from . import event_hooks from .exceptions import ActionSyntaxException -import hmac -import hashlib - -class GitHubViewSet(GenericViewSet): +class BaseWebhookApiViewSet(GenericViewSet): # We don't want rest framework to parse the request body and transform it in # a dict in request.DATA, we need it raw parser_classes = () # This dict associates the event names we are listening for # with their reponsible classes (extending event_hooks.BaseEventHook) - event_hook_classes = { - "push": event_hooks.PushEventHook, - "issues": event_hooks.IssuesEventHook, - "issue_comment": event_hooks.IssueCommentEventHook, - } + event_hook_classes = {} def _validate_signature(self, project, request): - x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None) - if not x_hub_signature: - return False - - sha_name, signature = x_hub_signature.split('=') - if sha_name != 'sha1': - return False - - if not hasattr(project, "modules_config"): - return False - - if project.modules_config.config is None: - return False - - secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8")) - mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1) - return hmac.compare_digest(mac.hexdigest(), signature) + raise NotImplemented def _get_project(self, request): project_id = request.GET.get("project", None) @@ -69,6 +45,16 @@ class GitHubViewSet(GenericViewSet): except Project.DoesNotExist: return None + def _get_payload(self, request): + try: + payload = json.loads(request.body.decode("utf-8")) + except ValueError: + raise exc.BadRequest(_("The payload is not a valid json")) + return payload + + def _get_event_name(self, request): + raise NotImplemented + def create(self, request, *args, **kwargs): project = self._get_project(request) if not project: @@ -77,12 +63,9 @@ class GitHubViewSet(GenericViewSet): if not self._validate_signature(project, request): raise exc.BadRequest(_("Bad signature")) - event_name = request.META.get("HTTP_X_GITHUB_EVENT", None) + event_name = self._get_event_name(request) - try: - payload = json.loads(request.body.decode("utf-8")) - except ValueError: - raise exc.BadRequest(_("The payload is not a valid json")) + payload = self._get_payload(request) event_hook_class = self.event_hook_classes.get(event_name, None) if event_hook_class is not None: diff --git a/taiga/github_hook/migrations/__init__.py b/taiga/hooks/bitbucket/__init__.py similarity index 100% rename from taiga/github_hook/migrations/__init__.py rename to taiga/hooks/bitbucket/__init__.py diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py new file mode 100644 index 00000000..cb1c75e8 --- /dev/null +++ b/taiga/hooks/bitbucket/api.py @@ -0,0 +1,80 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 rest_framework.response import Response +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from taiga.base.api.viewsets import GenericViewSet +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks +from ..exceptions import ActionSyntaxException + +from urllib.parse import parse_qs +from ipware.ip import get_real_ip + + +class BitBucketViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + } + + def _get_payload(self, request): + try: + body = parse_qs(request.body.decode("utf-8"), strict_parsing=True) + payload = body["payload"] + except (ValueError, KeyError): + raise exc.BadRequest(_("The payload is not a valid application/x-www-form-urlencoded")) + + return payload + + def _validate_signature(self, project, request): + secret_key = request.GET.get("key", None) + + if secret_key is None: + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + project_secret = project.modules_config.config.get("bitbucket", {}).get("secret", "") + if not project_secret: + return False + + valid_origin_ips = project.modules_config.config.get("bitbucket", {}).get("valid_origin_ips", settings.BITBUCKET_VALID_ORIGIN_IPS) + origin_ip = get_real_ip(request) + if valid_origin_ips and (not origin_ip or not origin_ip in valid_origin_ips): + return False + + return project_secret == secret_key + + def _get_project(self, request): + project_id = request.GET.get("project", None) + try: + project = Project.objects.get(id=project_id) + return project + except Project.DoesNotExist: + return None + + def _get_event_name(self, request): + return "push" diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py new file mode 100644 index 00000000..a149923d --- /dev/null +++ b/taiga/hooks/bitbucket/event_hooks.py @@ -0,0 +1,102 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import re +import os + +from django.utils.translation import ugettext_lazy as _ + +from taiga.base import exceptions as exc +from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.history.services import take_snapshot +from taiga.projects.notifications.services import send_notifications +from taiga.hooks.event_hooks import BaseEventHook +from taiga.hooks.exceptions import ActionSyntaxException + +from .services import get_bitbucket_user + +import json + +class PushEventHook(BaseEventHook): + def process_event(self): + if self.payload is None: + return + + # In bitbucket the payload is a list! :( + for payload_element_text in self.payload: + try: + payload_element = json.loads(payload_element_text) + except ValueError: + raise exc.BadRequest(_("The payload is not valid")) + + commits = payload_element.get("commits", []) + for commit in commits: + message = commit.get("message", None) + self._process_message(message, None) + + def _process_message(self, message, bitbucket_user): + """ + The message we will be looking for seems like + TG-XX #yyyyyy + Where: + XX: is the ref for us, issue or task + yyyyyy: is the status slug we are setting + """ + if message is None: + return + + p = re.compile("tg-(\d+) +#([-\w]+)") + m = p.search(message.lower()) + if m: + ref = m.group(1) + status_slug = m.group(2) + self._change_status(ref, status_slug, bitbucket_user) + + def _change_status(self, ref, status_slug, bitbucket_user): + if Issue.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Issue + statusClass = IssueStatus + elif Task.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Task + statusClass = TaskStatus + elif UserStory.objects.filter(project=self.project, ref=ref).exists(): + modelClass = UserStory + statusClass = UserStoryStatus + else: + raise ActionSyntaxException(_("The referenced element doesn't exist")) + + element = modelClass.objects.get(project=self.project, ref=ref) + + try: + status = statusClass.objects.get(project=self.project, slug=status_slug) + except statusClass.DoesNotExist: + raise ActionSyntaxException(_("The status doesn't exist")) + + element.status = status + element.save() + + snapshot = take_snapshot(element, + comment="Status changed from BitBucket commit", + user=get_bitbucket_user(bitbucket_user)) + send_notifications(element, history=snapshot) + + +def replace_bitbucket_references(project_url, wiki_text): + template = "\g<1>[BitBucket#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url) + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) diff --git a/taiga/hooks/bitbucket/migrations/0001_initial.py b/taiga/hooks/bitbucket/migrations/0001_initial.py new file mode 100644 index 00000000..372d93bb --- /dev/null +++ b/taiga/hooks/bitbucket/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.files import File + +import uuid + +def create_github_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="bitbucket-{}".format(random_hash), + email="bitbucket-{}@taiga.io".format(random_hash), + full_name="BitBucket", + is_active=False, + is_system=True, + bio="", + ) + f = open("taiga/hooks/bitbucket/migrations/logo.png", "rb") + user.photo.save("logo.png", File(f)) + user.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132') + ] + + operations = [ + migrations.RunPython(create_github_system_user), + ] diff --git a/taiga/hooks/bitbucket/migrations/__init__.py b/taiga/hooks/bitbucket/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/bitbucket/migrations/logo.png b/taiga/hooks/bitbucket/migrations/logo.png new file mode 100644 index 00000000..fbc456a7 Binary files /dev/null and b/taiga/hooks/bitbucket/migrations/logo.png differ diff --git a/taiga/github_hook/models.py b/taiga/hooks/bitbucket/models.py similarity index 100% rename from taiga/github_hook/models.py rename to taiga/hooks/bitbucket/models.py diff --git a/taiga/hooks/bitbucket/services.py b/taiga/hooks/bitbucket/services.py new file mode 100644 index 00000000..bcb74f56 --- /dev/null +++ b/taiga/hooks/bitbucket/services.py @@ -0,0 +1,55 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import uuid + +from django.core.urlresolvers import reverse +from django.conf import settings + +from taiga.users.models import User +from taiga.base.utils.urls import get_absolute_url + + +def get_or_generate_config(project): + config = project.modules_config.config + if config and "bitbucket" in config: + g_config = project.modules_config.config["bitbucket"] + else: + g_config = { + "secret": uuid.uuid4().hex, + "valid_origin_ips": settings.BITBUCKET_VALID_ORIGIN_IPS, + } + + url = reverse("bitbucket-hook-list") + url = get_absolute_url(url) + url = "%s?project=%s&key=%s"%(url, project.id, g_config["secret"]) + g_config["webhooks_url"] = url + return g_config + + +def get_bitbucket_user(user_email): + user = None + + if user_email: + try: + user = User.objects.get(email=user_email) + except User.DoesNotExist: + pass + + if user is None: + user = User.objects.get(is_system=True, username__startswith="bitbucket") + + return user diff --git a/taiga/hooks/event_hooks.py b/taiga/hooks/event_hooks.py new file mode 100644 index 00000000..0d26be38 --- /dev/null +++ b/taiga/hooks/event_hooks.py @@ -0,0 +1,24 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + + +class BaseEventHook: + def __init__(self, project, payload): + self.project = project + self.payload = payload + + def process_event(self): + raise NotImplementedError("process_event must be overwritten") diff --git a/taiga/github_hook/exceptions.py b/taiga/hooks/exceptions.py similarity index 100% rename from taiga/github_hook/exceptions.py rename to taiga/hooks/exceptions.py diff --git a/taiga/hooks/github/__init__.py b/taiga/hooks/github/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/github/api.py b/taiga/hooks/github/api.py new file mode 100644 index 00000000..c0f32c16 --- /dev/null +++ b/taiga/hooks/github/api.py @@ -0,0 +1,59 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 rest_framework.response import Response +from django.utils.translation import ugettext_lazy as _ + +from taiga.base.api.viewsets import GenericViewSet +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +import hmac +import hashlib + + +class GitHubViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issues": event_hooks.IssuesEventHook, + "issue_comment": event_hooks.IssueCommentEventHook, + } + + def _validate_signature(self, project, request): + x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None) + if not x_hub_signature: + return False + + sha_name, signature = x_hub_signature.split('=') + if sha_name != 'sha1': + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8")) + mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1) + return hmac.compare_digest(mac.hexdigest(), signature) + + def _get_event_name(self, request): + return request.META.get("HTTP_X_GITHUB_EVENT", None) diff --git a/taiga/github_hook/event_hooks.py b/taiga/hooks/github/event_hooks.py similarity index 95% rename from taiga/github_hook/event_hooks.py rename to taiga/hooks/github/event_hooks.py index bf7745f0..d7231d72 100644 --- a/taiga/github_hook/event_hooks.py +++ b/taiga/hooks/github/event_hooks.py @@ -23,22 +23,14 @@ from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory from taiga.projects.history.services import take_snapshot from taiga.projects.notifications.services import send_notifications +from taiga.hooks.event_hooks import BaseEventHook +from taiga.hooks.exceptions import ActionSyntaxException -from .exceptions import ActionSyntaxException from .services import get_github_user import re -class BaseEventHook: - def __init__(self, project, payload): - self.project = project - self.payload = payload - - def process_event(self): - raise NotImplementedError("process_event must be overwritten") - - class PushEventHook(BaseEventHook): def process_event(self): if self.payload is None: diff --git a/taiga/github_hook/migrations/0001_initial.py b/taiga/hooks/github/migrations/0001_initial.py similarity index 93% rename from taiga/github_hook/migrations/0001_initial.py rename to taiga/hooks/github/migrations/0001_initial.py index fc98953d..75e43abf 100644 --- a/taiga/github_hook/migrations/0001_initial.py +++ b/taiga/hooks/github/migrations/0001_initial.py @@ -20,7 +20,7 @@ def create_github_system_user(apps, schema_editor): is_system=True, bio="", ) - f = open("taiga/github_hook/migrations/logo.png", "rb") + f = open("taiga/hooks/github/migrations/logo.png", "rb") user.photo.save("logo.png", File(f)) user.save() diff --git a/taiga/hooks/github/migrations/__init__.py b/taiga/hooks/github/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/github_hook/migrations/logo.png b/taiga/hooks/github/migrations/logo.png similarity index 100% rename from taiga/github_hook/migrations/logo.png rename to taiga/hooks/github/migrations/logo.png diff --git a/taiga/hooks/github/models.py b/taiga/hooks/github/models.py new file mode 100644 index 00000000..fca83d73 --- /dev/null +++ b/taiga/hooks/github/models.py @@ -0,0 +1 @@ +# This file is needed to load migrations diff --git a/taiga/github_hook/services.py b/taiga/hooks/github/services.py similarity index 100% rename from taiga/github_hook/services.py rename to taiga/hooks/github/services.py diff --git a/taiga/hooks/gitlab/__init__.py b/taiga/hooks/gitlab/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py new file mode 100644 index 00000000..a7596910 --- /dev/null +++ b/taiga/hooks/gitlab/api.py @@ -0,0 +1,71 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 rest_framework.response import Response +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from taiga.base.api.viewsets import GenericViewSet +from taiga.base import exceptions as exc +from taiga.base.utils import json +from taiga.projects.models import Project +from taiga.hooks.api import BaseWebhookApiViewSet + +from . import event_hooks + +from ipware.ip import get_real_ip + + +class GitLabViewSet(BaseWebhookApiViewSet): + event_hook_classes = { + "push": event_hooks.PushEventHook, + "issue": event_hooks.IssuesEventHook, + } + + def _validate_signature(self, project, request): + secret_key = request.GET.get("key", None) + + if secret_key is None: + return False + + if not hasattr(project, "modules_config"): + return False + + if project.modules_config.config is None: + return False + + project_secret = project.modules_config.config.get("gitlab", {}).get("secret", "") + if not project_secret: + return False + + valid_origin_ips = project.modules_config.config.get("gitlab", {}).get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS) + origin_ip = get_real_ip(request) + if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips): + return False + + return project_secret == secret_key + + def _get_project(self, request): + project_id = request.GET.get("project", None) + try: + project = Project.objects.get(id=project_id) + return project + except Project.DoesNotExist: + return None + + def _get_event_name(self, request): + payload = json.loads(request.body.decode("utf-8")) + return payload.get('object_kind', 'push') diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py new file mode 100644 index 00000000..3a84a20d --- /dev/null +++ b/taiga/hooks/gitlab/event_hooks.py @@ -0,0 +1,127 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import re +import os + +from django.utils.translation import ugettext_lazy as _ + +from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus + +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.history.services import take_snapshot +from taiga.projects.notifications.services import send_notifications +from taiga.hooks.event_hooks import BaseEventHook +from taiga.hooks.exceptions import ActionSyntaxException + +from .services import get_gitlab_user + + +class PushEventHook(BaseEventHook): + def process_event(self): + if self.payload is None: + return + + commits = self.payload.get("commits", []) + for commit in commits: + message = commit.get("message", None) + self._process_message(message, None) + + def _process_message(self, message, gitlab_user): + """ + The message we will be looking for seems like + TG-XX #yyyyyy + Where: + XX: is the ref for us, issue or task + yyyyyy: is the status slug we are setting + """ + if message is None: + return + + p = re.compile("tg-(\d+) +#([-\w]+)") + m = p.search(message.lower()) + if m: + ref = m.group(1) + status_slug = m.group(2) + self._change_status(ref, status_slug, gitlab_user) + + def _change_status(self, ref, status_slug, gitlab_user): + if Issue.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Issue + statusClass = IssueStatus + elif Task.objects.filter(project=self.project, ref=ref).exists(): + modelClass = Task + statusClass = TaskStatus + elif UserStory.objects.filter(project=self.project, ref=ref).exists(): + modelClass = UserStory + statusClass = UserStoryStatus + else: + raise ActionSyntaxException(_("The referenced element doesn't exist")) + + element = modelClass.objects.get(project=self.project, ref=ref) + + try: + status = statusClass.objects.get(project=self.project, slug=status_slug) + except statusClass.DoesNotExist: + raise ActionSyntaxException(_("The status doesn't exist")) + + element.status = status + element.save() + + snapshot = take_snapshot(element, + comment="Status changed from GitLab commit", + user=get_gitlab_user(gitlab_user)) + send_notifications(element, history=snapshot) + + +def replace_gitlab_references(project_url, wiki_text): + template = "\g<1>[GitLab#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url) + return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M) + + +class IssuesEventHook(BaseEventHook): + def process_event(self): + if self.payload.get('object_attributes', {}).get("action", "") != "open": + return + + subject = self.payload.get('object_attributes', {}).get('title', None) + description = self.payload.get('object_attributes', {}).get('description', None) + gitlab_reference = self.payload.get('object_attributes', {}).get('url', None) + + project_url = None + if gitlab_reference: + project_url = os.path.basename(os.path.basename(gitlab_reference)) + + if not all([subject, gitlab_reference, project_url]): + raise ActionSyntaxException(_("Invalid issue information")) + + issue = Issue.objects.create( + project=self.project, + subject=subject, + description=replace_gitlab_references(project_url, description), + status=self.project.default_issue_status, + type=self.project.default_issue_type, + severity=self.project.default_severity, + priority=self.project.default_priority, + external_reference=['gitlab', gitlab_reference], + owner=get_gitlab_user(None) + ) + take_snapshot(issue, user=get_gitlab_user(None)) + + snapshot = take_snapshot(issue, comment="Created from GitLab", user=get_gitlab_user(None)) + send_notifications(issue, history=snapshot) diff --git a/taiga/hooks/gitlab/migrations/0001_initial.py b/taiga/hooks/gitlab/migrations/0001_initial.py new file mode 100644 index 00000000..683d3956 --- /dev/null +++ b/taiga/hooks/gitlab/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.core.files import File + +import uuid + +def create_github_system_user(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version + User = apps.get_model("users", "User") + db_alias = schema_editor.connection.alias + random_hash = uuid.uuid4().hex + user = User.objects.using(db_alias).create( + username="gitlab-{}".format(random_hash), + email="gitlab-{}@taiga.io".format(random_hash), + full_name="GitLab", + is_active=False, + is_system=True, + bio="", + ) + f = open("taiga/hooks/gitlab/migrations/logo.png", "rb") + user.photo.save("logo.png", File(f)) + user.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132') + ] + + operations = [ + migrations.RunPython(create_github_system_user), + ] diff --git a/taiga/hooks/gitlab/migrations/__init__.py b/taiga/hooks/gitlab/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/hooks/gitlab/migrations/logo.png b/taiga/hooks/gitlab/migrations/logo.png new file mode 100644 index 00000000..bd90452a Binary files /dev/null and b/taiga/hooks/gitlab/migrations/logo.png differ diff --git a/taiga/hooks/gitlab/models.py b/taiga/hooks/gitlab/models.py new file mode 100644 index 00000000..fca83d73 --- /dev/null +++ b/taiga/hooks/gitlab/models.py @@ -0,0 +1 @@ +# This file is needed to load migrations diff --git a/taiga/hooks/gitlab/services.py b/taiga/hooks/gitlab/services.py new file mode 100644 index 00000000..2d99969a --- /dev/null +++ b/taiga/hooks/gitlab/services.py @@ -0,0 +1,55 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import uuid + +from django.core.urlresolvers import reverse +from django.conf import settings + +from taiga.users.models import User +from taiga.base.utils.urls import get_absolute_url + + +def get_or_generate_config(project): + config = project.modules_config.config + if config and "gitlab" in config: + g_config = project.modules_config.config["gitlab"] + else: + g_config = { + "secret": uuid.uuid4().hex, + "valid_origin_ips": settings.GITLAB_VALID_ORIGIN_IPS, + } + + url = reverse("gitlab-hook-list") + url = get_absolute_url(url) + url = "{}?project={}&key={}".format(url, project.id, g_config["secret"]) + g_config["webhooks_url"] = url + return g_config + + +def get_gitlab_user(user_email): + user = None + + if user_email: + try: + user = User.objects.get(email=user_email) + except User.DoesNotExist: + pass + + if user is None: + user = User.objects.get(is_system=True, username__startswith="gitlab") + + return user diff --git a/taiga/mdrender/extensions/mentions.py b/taiga/mdrender/extensions/mentions.py index 592374a8..4c243910 100644 --- a/taiga/mdrender/extensions/mentions.py +++ b/taiga/mdrender/extensions/mentions.py @@ -54,7 +54,6 @@ class MentionsPattern(Pattern): a = etree.Element('a') a.text = link_text a.set('href', url) - a.set('alt', user.get_full_name()) a.set('title', user.get_full_name()) a.set('class', "mention") diff --git a/taiga/mdrender/extensions/references.py b/taiga/mdrender/extensions/references.py index 8eef881f..1ff6ac04 100644 --- a/taiga/mdrender/extensions/references.py +++ b/taiga/mdrender/extensions/references.py @@ -73,7 +73,6 @@ class TaigaReferencesPattern(Pattern): a = etree.Element('a') a.text = link_text a.set('href', url) - a.set('alt', subject) a.set('title', subject) a.set('class', html_classes) diff --git a/taiga/mdrender/extensions/wikilinks.py b/taiga/mdrender/extensions/wikilinks.py index a7f2c657..9eedbbcf 100644 --- a/taiga/mdrender/extensions/wikilinks.py +++ b/taiga/mdrender/extensions/wikilinks.py @@ -15,69 +15,73 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from __future__ import absolute_import -from __future__ import unicode_literals from markdown import Extension from markdown.inlinepatterns import Pattern +from markdown.treeprocessors import Treeprocessor + from markdown.util import etree + +from taiga.front import resolve + import re - -def build_url(label, base, end): - """ Build a url from the label, a base, and an end. """ - clean_label = re.sub(r'([ ]+_)|(_[ ]+)|([ ]+)', '_', label) - return '%s%s%s' % (base, clean_label, end) - - class WikiLinkExtension(Extension): - def __init__(self, configs): - # set extension defaults - self.config = { - 'base_url': ['/', 'String to append to beginning or URL.'], - 'end_url': ['/', 'String to append to end of URL.'], - 'html_class': ['wikilink', 'CSS hook. Leave blank for none.'], - 'build_url': [build_url, 'Callable formats URL from label.'], - } - configs = dict(configs) or {} - # Override defaults with user settings - for key, value in configs.items(): - self.setConfig(key, value) + def __init__(self, project, *args, **kwargs): + self.project = project + return super().__init__(*args, **kwargs) def extendMarkdown(self, md, md_globals): + WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[\w0-9_ -]+)?\]\]" + md.inlinePatterns.add("wikilinks", + WikiLinksPattern(md, WIKILINK_RE, self.project), + " +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +default_app_config = "taiga.projects.apps.ProjectsAppConfig" diff --git a/taiga/projects/api.py b/taiga/projects/api.py index f291a994..5f5532f8 100644 --- a/taiga/projects/api.py +++ b/taiga/projects/api.py @@ -80,6 +80,12 @@ class ProjectViewSet(ModelCrudViewSet): self.check_permissions(request, 'stats', project) return Response(services.get_stats_for_project(project)) + @detail_route(methods=['get']) + def member_stats(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, 'member_stats', project) + return Response(services.get_member_stats_for_project(project)) + @detail_route(methods=['get']) def issues_stats(self, request, pk=None): project = self.get_object() @@ -148,6 +154,13 @@ class ProjectViewSet(ModelCrudViewSet): template.save() return Response(serializers.ProjectTemplateSerializer(template).data, status=201) + @detail_route(methods=['post']) + def leave(self, request, pk=None): + project = self.get_object() + self.check_permissions(request, 'leave', project) + services.remove_user_from_project(request.user, project) + return Response(status=status.HTTP_200_OK) + def pre_save(self, obj): if not obj.id: obj.owner = self.request.user @@ -228,6 +241,10 @@ class MembershipViewSet(ModelCrudViewSet): services.send_invitation(invitation=invitation) return Response(status=status.HTTP_204_NO_CONTENT) + def pre_delete(self, obj): + if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project): + raise exc.BadRequest(_("At least one of the user must be an active admin")) + def pre_save(self, obj): if not obj.token: obj.token = str(uuid.uuid1()) diff --git a/taiga/projects/apps.py b/taiga/projects/apps.py new file mode 100644 index 00000000..232efc60 --- /dev/null +++ b/taiga/projects/apps.py @@ -0,0 +1,47 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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.apps import AppConfig +from django.apps import apps +from django.db.models import signals + +from . import signals as handlers + + +class ProjectsAppConfig(AppConfig): + name = "taiga.projects" + verbose_name = "Projects" + + def ready(self): + # On membership object is deleted, update role-points relation. + signals.pre_delete.connect(handlers.membership_post_delete, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='membership_pre_delete') + + # On membership object is deleted, update watchers of all objects relation. + signals.post_delete.connect(handlers.update_watchers_on_membership_post_delete, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='update_watchers_on_membership_post_delete') + + # On membership object is deleted, update watchers of all objects relation. + signals.post_save.connect(handlers.create_notify_policy, + sender=apps.get_model("projects", "Membership"), + dispatch_uid='create-notify-policy') + + # On project object is created apply template. + signals.post_save.connect(handlers.project_post_save, + sender=apps.get_model("projects", "Project"), + dispatch_uid='project_post_save') diff --git a/taiga/projects/fixtures/initial_project_templates.json b/taiga/projects/fixtures/initial_project_templates.json index 1be81279..42d8118f 100644 --- a/taiga/projects/fixtures/initial_project_templates.json +++ b/taiga/projects/fixtures/initial_project_templates.json @@ -3,10 +3,10 @@ "model": "projects.projecttemplate", "fields": { "is_issues_activated": true, - "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#ff9900\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#ffcc00\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#669900\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#999999\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}]", + "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff9900\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#ffcc00\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#999999\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]", "is_backlog_activated": true, "modified_date": "2014-07-25T10:02:46.479Z", - "us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"wip_limit\": null, \"name\": \"New\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"wip_limit\": null, \"name\": \"In progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready for test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"wip_limit\": null, \"name\": \"Done\"}]", + "us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\"}]", "is_wiki_activated": true, "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "points": "[{\"value\": null, \"order\": 1, \"name\": \"?\"}, {\"value\": 0.0, \"order\": 2, \"name\": \"0\"}, {\"value\": 0.5, \"order\": 3, \"name\": \"1/2\"}, {\"value\": 1.0, \"order\": 4, \"name\": \"1\"}, {\"value\": 2.0, \"order\": 5, \"name\": \"2\"}, {\"value\": 3.0, \"order\": 6, \"name\": \"3\"}, {\"value\": 5.0, \"order\": 7, \"name\": \"5\"}, {\"value\": 8.0, \"order\": 8, \"name\": \"8\"}, {\"value\": 10.0, \"order\": 9, \"name\": \"10\"}, {\"value\": 15.0, \"order\": 10, \"name\": \"15\"}, {\"value\": 20.0, \"order\": 11, \"name\": \"20\"}, {\"value\": 40.0, \"order\": 12, \"name\": \"40\"}]", @@ -17,7 +17,7 @@ "default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}", "slug": "scrum", "videoconferences_salt": "", - "issue_statuses": "[{\"color\": \"#8C2318\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#5E8C6A\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#88A65E\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#BFB35A\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#89BAB4\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}, {\"color\": \"#CC0000\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\"}, {\"color\": \"#666666\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\"}]", + "issue_statuses": "[{\"color\": \"#8C2318\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#5E8C6A\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#88A65E\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#BFB35A\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#89BAB4\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#CC0000\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#666666\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]", "default_owner_role": "product-owner", "issue_types": "[{\"color\": \"#89BAB4\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#ba89a8\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#89a8ba\", \"order\": 3, \"name\": \"Enhancement\"}]", "videoconferences": null, @@ -30,10 +30,10 @@ "model": "projects.projecttemplate", "fields": { "is_issues_activated": false, - "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}]", + "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]", "is_backlog_activated": false, "modified_date": "2014-07-25T13:11:42.754Z", - "us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"color\": \"#999999\", \"name\": \"New\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"color\": \"#f57900\", \"name\": \"Ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"color\": \"#729fcf\", \"name\": \"In progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"color\": \"#cc0000\", \"name\": \"Done\"}]", + "us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"color\": \"#999999\", \"name\": \"New\", \"slug\": \"new\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"color\": \"#f57900\", \"name\": \"Ready\", \"slug\": \"ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"color\": \"#729fcf\", \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"color\": \"#cc0000\", \"name\": \"Done\", \"slug\": \"done\"}]", "is_wiki_activated": false, "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 15.0, \"name\": \"15\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]", @@ -44,7 +44,7 @@ "default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}", "slug": "kanban", "videoconferences_salt": "", - "issue_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}, {\"color\": \"#d3d7cf\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\"}, {\"color\": \"#75507b\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\"}]", + "issue_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#d3d7cf\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#75507b\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]", "default_owner_role": "product-owner", "issue_types": "[{\"color\": \"#cc0000\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#729fcf\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#4e9a06\", \"order\": 3, \"name\": \"Enhancement\"}]", "videoconferences": null, diff --git a/taiga/projects/history/migrations/0005_auto_20141120_1119.py b/taiga/projects/history/migrations/0005_auto_20141120_1119.py new file mode 100644 index 00000000..72489aad --- /dev/null +++ b/taiga/projects/history/migrations/0005_auto_20141120_1119.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0004_historyentry_is_hidden'), + ] + + operations = [ + migrations.AlterField( + model_name='historyentry', + name='key', + field=models.CharField(default=None, blank=True, max_length=255, db_index=True, null=True), + ), + ] diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index ec79f91f..0cf9d0e5 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -48,7 +48,7 @@ class HistoryEntry(models.Model): user = JsonField(blank=True, default=None, null=True) created_at = models.DateTimeField(default=timezone.now) type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES) - key = models.CharField(max_length=255, null=True, default=None, blank=True) + key = models.CharField(max_length=255, null=True, default=None, blank=True, db_index=True) # Stores the last diff diff = JsonField(null=True, default=None) diff --git a/taiga/projects/history/permissions.py b/taiga/projects/history/permissions.py index 6b97d078..c0adf8d6 100644 --- a/taiga/projects/history/permissions.py +++ b/taiga/projects/history/permissions.py @@ -18,35 +18,46 @@ from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, IsProjectOwner, AllowAny, IsObjectOwner, PermissionComponent) +from taiga.permissions.service import is_project_owner +from taiga.projects.history.services import get_model_from_key, get_pk_from_key + class IsCommentDeleter(PermissionComponent): def check_permissions(self, request, view, obj=None): return obj.delete_comment_user and obj.delete_comment_user.get("pk", "not-pk") == request.user.pk + class IsCommentOwner(PermissionComponent): def check_permissions(self, request, view, obj=None): return obj.user and obj.user.get("pk", "not-pk") == request.user.pk +class IsCommentProjectOwner(PermissionComponent): + def check_permissions(self, request, view, obj=None): + model = get_model_from_key(obj.key) + pk = get_pk_from_key(obj.key) + project = model.objects.get(pk=pk) + return is_project_owner(request.user, project) + class UserStoryHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') - delete_comment_perms = IsProjectOwner() | IsCommentOwner() - undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() + delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter() class TaskHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') - delete_comment_perms = IsProjectOwner() | IsCommentOwner() - undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() + delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter() class IssueHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') - delete_comment_perms = IsProjectOwner() | IsCommentOwner() - undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() + delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter() class WikiHistoryPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') - delete_comment_perms = IsProjectOwner() | IsCommentOwner() - undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() + delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner() + undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter() diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index 31e56f99..f22574a2 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -81,6 +81,14 @@ def get_model_from_key(key:str) -> object: return apps.get_model(class_name) +def get_pk_from_key(key:str) -> object: + """ + Get pk from key + """ + class_name, pk = key.split(":", 1) + return pk + + def register_values_implementation(typename:str, fn=None): """ Register values implementation for specified typename. @@ -243,6 +251,24 @@ def get_last_snapshot_for_key(key:str) -> FrozenObj: # Public api +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]) + + modified_fields = [] + for history_entry in history_entries: + modified_fields += history_entry.keys() + + return modified_fields + @tx.atomic def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): """ diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja index 150269b7..12090cd6 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -2,7 +2,11 @@ "description", "description_html", "content", - "content_html" + "content_html", + "backlog_order", + "kanban_order", + "taskboard_order", + "us_order" ] %}
diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja index 71e6dbbe..88d80fbc 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -2,7 +2,11 @@ "description_diff", "description_html", "content_diff", - "content_html" + "content_html", + "backlog_order", + "kanban_order", + "taskboard_order", + "us_order" ] %} {% for field_name, values in changed_fields.items() %} {% if field_name not in excluded_fields %} diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index ab0878f0..94dff493 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -79,7 +79,7 @@ class IssuesFilter(filters.FilterBackend): filterdata = self._prepare_filters_data(request) if "tags" in filterdata: - queryset = tags.filter(queryset, contains=filterdata["tags"]) + queryset = queryset.filter(tags__contains=filterdata["tags"]) for name, value in filter(lambda x: x[0] != "tags", filterdata.items()): if None in value: diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index cd64d577..f88c2ff0 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -223,6 +223,13 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=wiki_page.owner) + # Add history entry + wiki_page.content=self.sd.paragraphs(3,15) + wiki_page.save() + take_snapshot(wiki_page, + comment=self.sd.paragraph(), + user=wiki_page.owner) + return wiki_page def create_bug(self, project): @@ -253,6 +260,13 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=bug.owner) + # Add history entry + bug.status=self.sd.db_object_from_queryset(IssueStatus.objects.filter(project=project)) + bug.save() + take_snapshot(bug, + comment=self.sd.paragraph(), + user=bug.owner) + return bug def create_task(self, project, milestone, us, min_date, max_date, closed=False): @@ -284,6 +298,13 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=task.owner) + # Add history entry + task.status=self.sd.db_object_from_queryset(project.task_statuses.all()) + task.save() + take_snapshot(task, + comment=self.sd.paragraph(), + user=task.owner) + return task def create_us(self, project, milestone=None, computable_project_roles=[]): @@ -318,6 +339,13 @@ class Command(BaseCommand): comment=self.sd.paragraph(), user=us.owner) + # Add history entry + us.status=self.sd.db_object_from_queryset(project.us_statuses.filter(is_closed=False)) + us.save() + take_snapshot(us, + comment=self.sd.paragraph(), + user=us.owner) + return us def create_milestone(self, project, start_date, end_date): diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 7e4e36d5..4c615d05 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -31,12 +31,10 @@ from djorm_pgarray.fields import TextArrayField from taiga.permissions.permissions import ANON_PERMISSIONS, USER_PERMISSIONS from taiga.base.tags import TaggedMixin -from taiga.users.models import Role from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.dicts import dict_sum from taiga.base.utils.sequence import arithmetic_progression from taiga.base.utils.slug import slugify_uniquely_for_queryset -from taiga.projects.notifications.services import create_notify_policy_if_not_exists from . import choices @@ -598,6 +596,7 @@ class ProjectTemplate(models.Model): for us_status in project.us_statuses.all(): self.us_statuses.append({ "name": us_status.name, + "slug": us_status.slug, "is_closed": us_status.is_closed, "color": us_status.color, "wip_limit": us_status.wip_limit, @@ -616,6 +615,7 @@ class ProjectTemplate(models.Model): for task_status in project.task_statuses.all(): self.task_statuses.append({ "name": task_status.name, + "slug": task_status.slug, "is_closed": task_status.is_closed, "color": task_status.color, "order": task_status.order, @@ -625,6 +625,7 @@ class ProjectTemplate(models.Model): for issue_status in project.issue_statuses.all(): self.issue_statuses.append({ "name": issue_status.name, + "slug": issue_status.slug, "is_closed": issue_status.is_closed, "color": issue_status.color, "order": issue_status.order, @@ -671,6 +672,8 @@ class ProjectTemplate(models.Model): self.default_owner_role = self.roles[0].get("slug", None) def apply_to_project(self, project): + Role = apps.get_model("users", "Role") + if project.id is None: raise Exception("Project need an id (must be a saved project)") @@ -685,6 +688,7 @@ class ProjectTemplate(models.Model): for us_status in self.us_statuses: UserStoryStatus.objects.create( name=us_status["name"], + slug=us_status["slug"], is_closed=us_status["is_closed"], color=us_status["color"], wip_limit=us_status["wip_limit"], @@ -703,6 +707,7 @@ class ProjectTemplate(models.Model): for task_status in self.task_statuses: TaskStatus.objects.create( name=task_status["name"], + slug=task_status["slug"], is_closed=task_status["is_closed"], color=task_status["color"], order=task_status["order"], @@ -712,6 +717,7 @@ class ProjectTemplate(models.Model): for issue_status in self.issue_statuses: IssueStatus.objects.create( name=issue_status["name"], + slug=issue_status["slug"], is_closed=issue_status["is_closed"], color=issue_status["color"], order=issue_status["order"], @@ -777,58 +783,3 @@ class ProjectTemplate(models.Model): project.default_severity = Severity.objects.get(name=self.default_options["severity"], project=project) return project - - -# On membership object is deleted, update role-points relation. -@receiver(signals.pre_delete, sender=Membership, dispatch_uid='membership_pre_delete') -def membership_post_delete(sender, instance, using, **kwargs): - instance.project.update_role_points() - - -# On membership object is deleted, update watchers of all objects relation. -@receiver(signals.post_delete, sender=Membership, dispatch_uid='update_watchers_on_membership_post_delete') -def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs): - models = [apps.get_model("userstories", "UserStory"), - apps.get_model("tasks", "Task"), - apps.get_model("issues", "Issue")] - - # `user_id` is used beacuse in some momments - # instance.user can contain pointer to now - # removed object from a database. - for model in models: - model.watchers.through.objects.filter(user_id=instance.user_id).delete() - - -# On membership object is deleted, update watchers of all objects relation. -@receiver(signals.post_save, sender=Membership, dispatch_uid='create-notify-policy') -def create_notify_policy(sender, instance, using, **kwargs): - if instance.user: - create_notify_policy_if_not_exists(instance.project, instance.user) - - -@receiver(signals.post_save, sender=Project, dispatch_uid='project_post_save') -def project_post_save(sender, instance, created, **kwargs): - """ - Populate new project dependen default data - """ - if not created: - return - - if instance._importing: - return - - template = getattr(instance, "creation_template", None) - if template is None: - template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE) - template.apply_to_project(instance) - - instance.save() - - try: - owner_role = instance.roles.get(slug=template.default_owner_role) - except Role.DoesNotExist: - owner_role = instance.roles.first() - - if owner_role: - Membership.objects.create(user=instance.owner, project=instance, role=owner_role, - is_owner=True, email=instance.owner.email) diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index ba08918e..a278cee2 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -32,6 +32,7 @@ from taiga.projects.history.choices import HistoryType from taiga.projects.history.services import (make_key_from_model_object, get_last_snapshot_for_key, get_model_from_key) +from taiga.permissions.service import user_has_perm from taiga.users.models import User from .models import HistoryChangeNotification @@ -121,6 +122,23 @@ def analize_object_for_watchers(obj:object, history:object): obj.watchers.add(user) +def _filter_by_permissions(obj, user): + UserStory = apps.get_model("userstories", "UserStory") + Issue = apps.get_model("issues", "Issue") + Task = apps.get_model("tasks", "Task") + WikiPage = apps.get_model("wiki", "WikiPage") + + if isinstance(obj, UserStory): + return user_has_perm(user, "view_us", obj) + elif isinstance(obj, Issue): + return user_has_perm(user, "view_issues", obj) + elif isinstance(obj, Task): + return user_has_perm(user, "view_tasks", obj) + elif isinstance(obj, WikiPage): + return user_has_perm(user, "view_wiki_pages", obj) + return False + + def get_users_to_notify(obj, *, discard_users=None) -> list: """ Get filtered set of users to notify for specified @@ -149,6 +167,8 @@ def get_users_to_notify(obj, *, discard_users=None) -> list: if discard_users: candidates = candidates - set(discard_users) + candidates = filter(partial(_filter_by_permissions, obj), candidates) + return frozenset(candidates) @@ -187,6 +207,9 @@ def _make_template_mail(name:str): @transaction.atomic def send_notifications(obj, *, history): + if history.is_hidden: + return None + key = make_key_from_model_object(obj) owner = User.objects.get(pk=history.user["pk"]) notification, created = (HistoryChangeNotification.objects.select_for_update() diff --git a/taiga/projects/occ/mixins.py b/taiga/projects/occ/mixins.py index e7d3fe00..c9bdeeac 100644 --- a/taiga/projects/occ/mixins.py +++ b/taiga/projects/occ/mixins.py @@ -19,23 +19,62 @@ from django.utils.translation import ugettext_lazy as _ from taiga.base import exceptions as exc from taiga.base.utils import db - +from taiga.projects.history.services import get_modified_fields class OCCResourceMixin(object): """ Rest Framework resource mixin for resources that need to have concurrent accesses and editions controlled. """ - def pre_save(self, obj): - current_version = obj.version + def _extract_param_version(self): param_version = self.request.DATA.get('version', None) + try: + param_version = param_version and int(param_version) + except (ValueError, TypeError): + raise exc.WrongArguments({"version": "The version must be an integer"}) - if obj.id is not None and current_version != param_version: - raise exc.WrongArguments({"version": "The version doesn't match with the current one"}) + return param_version + + def _validate_param_version(self, param_version, current_version): + if param_version is not None: + if param_version < 0: + return False + if current_version is not None and param_version > current_version: + return False + + return True + + def _validate_and_update_version(self, obj): + current_version = None + if obj.id: + current_version = type(obj).objects.model.objects.get(id=obj.id).version + + # Extract param version + param_version = self._extract_param_version() + if not self._validate_param_version(param_version, current_version): + raise exc.WrongArguments({"version": "The version is not valid"}) + + if current_version != param_version: + diff_versions = current_version - param_version + + modifying_fields = set(self.request.DATA.keys()) + if "version" in modifying_fields: + modifying_fields.remove("version") + + modified_fields = set(get_modified_fields(obj, diff_versions)) + if "version" in modifying_fields: + modified_fields.remove("version") + + both_modified = modifying_fields & modified_fields + + if both_modified: + raise exc.WrongArguments({"version": "The version doesn't match with the current one"}) if obj.id: obj.version = models.F('version') + 1 + def pre_save(self, obj): + self._validate_and_update_version(obj) super().pre_save(obj) def post_save(self, obj, created=False): diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index 89c9476a..126ac9c0 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -13,20 +13,41 @@ # 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.api.permissions import (TaigaResourcePermission, HasProjectPerm, IsAuthenticated, IsProjectOwner, - AllowAny, IsSuperUser) + AllowAny, IsSuperUser, PermissionComponent) + +from taiga.base import exceptions as exc +from taiga.projects.models import Membership + +from . import services + +class CanLeaveProject(PermissionComponent): + def check_permissions(self, request, view, obj=None): + if not obj or not request.user.is_authenticated(): + return False + + try: + if not services.can_user_leave_project(request.user, obj): + raise exc.PermissionDenied(_("You can't leave the project if there are no more owners")) + + return True + except Membership.DoesNotExist: + return False class ProjectPermission(TaigaResourcePermission): retrieve_perms = HasProjectPerm('view_project') create_perms = IsAuthenticated() update_perms = IsProjectOwner() + partial_update_perms = IsProjectOwner() destroy_perms = IsProjectOwner() modules_perms = IsProjectOwner() list_perms = AllowAny() stats_perms = AllowAny() + member_stats_perms = HasProjectPerm('view_project') star_perms = IsAuthenticated() unstar_perms = IsAuthenticated() issues_stats_perms = AllowAny() @@ -35,6 +56,7 @@ class ProjectPermission(TaigaResourcePermission): tags_colors_perms = HasProjectPerm('view_project') fans_perms = HasProjectPerm('view_project') create_template_perms = IsSuperUser() + leave_perms = CanLeaveProject() class MembershipPermission(TaigaResourcePermission): diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 5d5465ef..5dbbba4c 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -30,6 +30,7 @@ from taiga.users.validators import RoleExistsValidator from taiga.permissions.service import get_user_project_permissions, is_project_owner from . import models +from . import services from . validators import ProjectExistsValidator @@ -202,6 +203,17 @@ class MembershipSerializer(ModelSerializer): return attrs + def validate_is_owner(self, attrs, source): + is_owner = attrs[source] + project = attrs.get("project", None) + if project is None: + project = self.object.project + + if self.object and not services.project_has_valid_owners(project, exclude_user=self.object.user): + raise serializers.ValidationError(_("At least one of the user must be an active admin")) + + return attrs + class ProjectMembershipSerializer(ModelSerializer): role_name = serializers.CharField(source='role.name', required=False) @@ -310,6 +322,7 @@ class ProjectTemplateSerializer(ModelSerializer): class Meta: model = models.ProjectTemplate + read_only_fields = ("created_date", "modified_date") class StarredSerializer(ModelSerializer): diff --git a/taiga/projects/services/__init__.py b/taiga/projects/services/__init__.py index 35341f8d..bf1aac00 100644 --- a/taiga/projects/services/__init__.py +++ b/taiga/projects/services/__init__.py @@ -30,9 +30,11 @@ from .filters import get_issues_filters_data from .stats import get_stats_for_project_issues from .stats import get_stats_for_project +from .stats import get_member_stats_for_project from .members import create_members_in_bulk from .members import get_members_from_bulk +from .members import remove_user_from_project, project_has_valid_owners, can_user_leave_project from .invitations import send_invitation from .invitations import find_invited_user diff --git a/taiga/projects/services/members.py b/taiga/projects/services/members.py index 0758cd66..f4efc5e9 100644 --- a/taiga/projects/services/members.py +++ b/taiga/projects/services/members.py @@ -2,7 +2,6 @@ from taiga.base.utils import db, text from .. import models - def get_members_from_bulk(bulk_data, **additional_fields): """Convert `bulk_data` into a list of members. @@ -31,3 +30,29 @@ def create_members_in_bulk(bulk_data, callback=None, precall=None, **additional_ members = get_members_from_bulk(bulk_data, **additional_fields) db.save_in_bulk(members, callback, precall) return members + + +def remove_user_from_project(user, project): + models.Membership.objects.get(project=project, user=user).delete() + + +def project_has_valid_owners(project, exclude_user=None): + """ + Checks if the project has any owner membership with a user different than the specified + """ + owner_memberships = project.memberships.filter(is_owner=True, user__is_active=True) + if exclude_user: + owner_memberships = owner_memberships.exclude(user=exclude_user) + + return owner_memberships.count() > 0 + + +def can_user_leave_project(user, project): + membership = project.memberships.get(user=user) + if not membership.is_owner: + return True + + if not project_has_valid_owners(project, exclude_user=user): + return False + + return True diff --git a/taiga/projects/services/stats.py b/taiga/projects/services/stats.py index 9782879f..a770f95b 100644 --- a/taiga/projects/services/stats.py +++ b/taiga/projects/services/stats.py @@ -18,6 +18,8 @@ from django.db.models import Q, Count import datetime import copy +from taiga.projects.history.models import HistoryEntry + def _get_milestones_stats_for_backlog(project): """ @@ -212,3 +214,77 @@ def get_stats_for_project(project): 'speed': speed, } return project_stats + + +def _get_closed_bugs_per_member_stats(project): + # Closed bugs per user + closed_bugs = project.issues.filter(status__is_closed=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + closed_bugs = { p["assigned_to"]: p["count"] for p in closed_bugs} + return closed_bugs + + +def _get_iocaine_tasks_per_member_stats(project): + # Iocaine tasks assigned per user + iocaine_tasks = project.tasks.filter(is_iocaine=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + iocaine_tasks = { t["assigned_to"]: t["count"] for t in iocaine_tasks} + return iocaine_tasks + + +def _get_wiki_changes_per_member_stats(project): + # Wiki changes + wiki_changes = {} + wiki_page_keys = ["wiki.wikipage:%s"%id for id in project.wiki_pages.values_list("id", flat=True)] + history_entries = HistoryEntry.objects.filter(key__in=wiki_page_keys).values('user') + for entry in history_entries: + editions = wiki_changes.get(entry["user"]["pk"], 0) + wiki_changes[entry["user"]["pk"]] = editions + 1 + + return wiki_changes + + +def _get_created_bugs_per_member_stats(project): + # Created_bugs + created_bugs = project.issues\ + .values('owner')\ + .annotate(count=Count('owner'))\ + .order_by() + created_bugs = { p["owner"]: p["count"] for p in created_bugs } + return created_bugs + + +def _get_closed_tasks_per_member_stats(project): + # Closed tasks + closed_tasks = project.tasks.filter(status__is_closed=True)\ + .values('assigned_to')\ + .annotate(count=Count('assigned_to'))\ + .order_by() + closed_tasks = {p["assigned_to"]: p["count"] for p in closed_tasks} + return closed_tasks + +def get_member_stats_for_project(project): + base_counters = {id: 0 for id in project.members.values_list("id", flat=True)} + closed_bugs = base_counters.copy() + closed_bugs.update(_get_closed_bugs_per_member_stats(project)) + iocaine_tasks = base_counters.copy() + iocaine_tasks.update(_get_iocaine_tasks_per_member_stats(project)) + wiki_changes = base_counters.copy() + wiki_changes.update(_get_wiki_changes_per_member_stats(project)) + created_bugs = base_counters.copy() + created_bugs.update(_get_created_bugs_per_member_stats(project)) + closed_tasks = base_counters.copy() + closed_tasks.update(_get_closed_tasks_per_member_stats(project)) + + member_stats = { + "closed_bugs": closed_bugs, + "iocaine_tasks": iocaine_tasks, + "wiki_changes": wiki_changes, + "created_bugs": created_bugs, + "closed_tasks": closed_tasks, + } + return member_stats diff --git a/taiga/projects/signals.py b/taiga/projects/signals.py index e61722cb..e4366d8d 100644 --- a/taiga/projects/signals.py +++ b/taiga/projects/signals.py @@ -14,7 +14,11 @@ # 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.conf import settings + from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags +from taiga.projects.notifications.services import create_notify_policy_if_not_exists #################################### @@ -35,3 +39,54 @@ def update_project_tags_when_create_or_edit_taggable_item(sender, instance, **kw def update_project_tags_when_delete_taggable_item(sender, instance, **kwargs): remove_unused_tags(instance.project) instance.project.save() + +def membership_post_delete(sender, instance, using, **kwargs): + instance.project.update_role_points() + + +def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs): + models = [apps.get_model("userstories", "UserStory"), + apps.get_model("tasks", "Task"), + apps.get_model("issues", "Issue")] + + # `user_id` is used beacuse in some momments + # instance.user can contain pointer to now + # removed object from a database. + for model in models: + model.watchers.through.objects.filter(user_id=instance.user_id).delete() + + +def create_notify_policy(sender, instance, using, **kwargs): + if instance.user: + create_notify_policy_if_not_exists(instance.project, instance.user) + + +def project_post_save(sender, instance, created, **kwargs): + """ + Populate new project dependen default data + """ + if not created: + return + + if instance._importing: + return + + + template = getattr(instance, "creation_template", None) + if template is None: + ProjectTemplate = apps.get_model("projects", "ProjectTemplate") + template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE) + template.apply_to_project(instance) + + instance.save() + + Role = apps.get_model("users", "Role") + try: + owner_role = instance.roles.get(slug=template.default_owner_role) + except Role.DoesNotExist: + owner_role = instance.roles.first() + + if owner_role: + Membership = apps.get_model("projects", "Membership") + Membership.objects.create(user=instance.owner, project=instance, role=owner_role, + is_owner=True, email=instance.owner.email) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index d3727b78..ed7cd208 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from django.utils.translation import ugettext_lazy as _ +from django.shortcuts import get_object_or_404 from taiga.base import filters, response from taiga.base import exceptions as exc @@ -79,3 +80,27 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, return response.Ok(tasks_serialized.data) return response.BadRequest(serializer.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) + + data = serializer.data + project = get_object_or_404(Project, pk=data["project_id"]) + + self.check_permissions(request, "bulk_update_order", project) + services.update_tasks_order_in_bulk(data["bulk_tasks"], + project=project, + field=order_field) + services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user) + + return response.NoContent() + + @list_route(methods=["POST"]) + def bulk_update_taskboard_order(self, request, **kwargs): + return self._bulk_update_order("taskboard_order", request, **kwargs) + + @list_route(methods=["POST"]) + def bulk_update_us_order(self, request, **kwargs): + return self._bulk_update_order("us_order", request, **kwargs) diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py index 46cde396..1983d7a6 100644 --- a/taiga/projects/tasks/permissions.py +++ b/taiga/projects/tasks/permissions.py @@ -27,3 +27,4 @@ class TaskPermission(TaigaResourcePermission): destroy_perms = HasProjectPerm('delete_task') list_perms = AllowAny() bulk_create_perms = HasProjectPerm('add_task') + bulk_update_order_perms = HasProjectPerm('modify_task') diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index 2dcf6097..fe71186b 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -18,9 +18,9 @@ from rest_framework import serializers from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField from taiga.mdrender.service import render as mdrender -from taiga.projects.validators import ProjectExistsValidator, TaskStatusExistsValidator +from taiga.projects.validators import ProjectExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator -from taiga.projects.userstories.validators import UserStoryExistsValidator +from taiga.projects.tasks.validators import TaskExistsValidator from taiga.projects.notifications.validators import WatchersValidator from . import models @@ -70,10 +70,21 @@ class NeighborTaskSerializer(serializers.ModelSerializer): depth = 0 -class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator, TaskStatusExistsValidator, - UserStoryExistsValidator, Serializer): +class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator, + TaskExistsValidator, 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, Serializer): + task_id = serializers.IntegerField() + order = serializers.IntegerField() + + +class UpdateTasksOrderBulkSerializer(ProjectExistsValidator, Serializer): + project_id = serializers.IntegerField() + bulk_tasks = _TaskOrderBulkSerializer(many=True) diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py index 6e9e571c..379d1321 100644 --- a/taiga/projects/tasks/services.py +++ b/taiga/projects/tasks/services.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from taiga.base.utils import db, text +from taiga.projects.history.services import take_snapshot +from taiga.events import events from . import models @@ -43,3 +45,33 @@ def create_tasks_in_bulk(bulk_data, callback=None, precall=None, **additional_fi tasks = get_tasks_from_bulk(bulk_data, **additional_fields) db.save_in_bulk(tasks, callback, precall) return tasks + + +def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object): + """ + Update the order of some tasks. + `bulk_data` should be a list of tuples with the following format: + + [(, {: , ...}), ...] + """ + task_ids = [] + new_order_values = [] + for task_data in bulk_data: + task_ids.append(task_data["task_id"]) + new_order_values.append({field: task_data["order"]}) + + events.emit_event_for_ids(ids=task_ids, + content_type="tasks.task", + projectid=project.pk) + + db.update_in_bulk_with_ids(task_ids, new_order_values, model=models.Task) + + +def snapshot_tasks_in_bulk(bulk_data, user): + task_ids = [] + for task_data in bulk_data: + try: + task = models.Task.objects.get(pk=task_data['task_id']) + take_snapshot(task, user=user) + except models.UserStory.DoesNotExist: + pass diff --git a/taiga/projects/tasks/validators.py b/taiga/projects/tasks/validators.py new file mode 100644 index 00000000..c7f1293b --- /dev/null +++ b/taiga/projects/tasks/validators.py @@ -0,0 +1,14 @@ +from django.utils.translation import ugettext_lazy as _ + +from rest_framework import serializers + +from . import models + + +class TaskExistsValidator: + def validate_task_id(self, attrs, source): + value = attrs[source] + if not models.Task.objects.filter(pk=value).exists(): + msg = _("There's no task with that id") + raise serializers.ValidationError(msg) + return attrs diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index f95b0007..166ddd26 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -110,8 +110,7 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi return response.Ok(user_stories_serialized.data) return response.BadRequest(serializer.errors) - @list_route(methods=["POST"]) - def bulk_update_backlog_order(self, request, **kwargs): + 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) @@ -122,42 +121,22 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi self.check_permissions(request, "bulk_update_order", project) services.update_userstories_order_in_bulk(data["bulk_stories"], project=project, - field="backlog_order") + field=order_field) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) return response.NoContent() + @list_route(methods=["POST"]) + def bulk_update_backlog_order(self, request, **kwargs): + return self._bulk_update_order("backlog_order", request, **kwargs) + @list_route(methods=["POST"]) def bulk_update_sprint_order(self, request, **kwargs): - serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - project = get_object_or_404(Project, pk=data["project_id"]) - - self.check_permissions(request, "bulk_update_order", project) - services.update_userstories_order_in_bulk(data["bulk_stories"], - project=project, - field="sprint_order") - services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) - return response.NoContent() + return self._bulk_update_order("sprint_order", request, **kwargs) @list_route(methods=["POST"]) def bulk_update_kanban_order(self, request, **kwargs): - serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) - if not serializer.is_valid(): - return response.BadRequest(serializer.errors) - - data = serializer.data - project = get_object_or_404(Project, pk=data["project_id"]) - - self.check_permissions(request, "bulk_update_order", project) - services.update_userstories_order_in_bulk(data["bulk_stories"], - project=project, - field="kanban_order") - services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) - return response.NoContent() + return self._bulk_update_order("kanban_order", request, **kwargs) @transaction.atomic def create(self, *args, **kwargs): diff --git a/taiga/routers.py b/taiga/routers.py index ac8c6da8..0b6ffba2 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -132,8 +132,16 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") # GitHub webhooks -from taiga.github_hook.api import GitHubViewSet +from taiga.hooks.github.api import GitHubViewSet router.register(r"github-hook", GitHubViewSet, base_name="github-hook") +# Gitlab webhooks +from taiga.hooks.gitlab.api import GitLabViewSet +router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook") + +# Bitbucket webhooks +from taiga.hooks.bitbucket.api import BitBucketViewSet +router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook") + # feedback # - see taiga.feedback.routers and taiga.feedback.apps diff --git a/taiga/users/api.py b/taiga/users/api.py index 56bee9e7..139c1f11 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -40,6 +40,7 @@ from taiga.base.api import ModelCrudViewSet from taiga.base.utils.slug import slugify_uniquely from taiga.projects.votes import services as votes_service from taiga.projects.serializers import StarredSerializer +from taiga.permissions.service import is_project_owner from . import models from . import serializers @@ -53,8 +54,8 @@ class MembersFilterBackend(BaseFilterBackend): if project_id: Project = apps.get_model('projects', 'Project') project = get_object_or_404(Project, pk=project_id) - if request.user.is_authenticated() and (project.memberships.filter(user=request.user).exists() or project.owner == request.user): - return queryset.filter(Q(memberships__project=project) | Q(id=project.owner.id)).distinct() + if request.user.is_authenticated() and project.memberships.filter(user=request.user).exists(): + return queryset.filter(memberships__project=project).distinct() else: raise exc.PermissionDenied(_("You don't have permisions to see this project users.")) diff --git a/tests/factories.py b/tests/factories.py index bc2dddc8..f49e4d49 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -400,6 +400,13 @@ class AttachmentFactory(Factory): attached_file = factory.django.FileField(data=b"File contents") +class HistoryEntryFactory(Factory): + class Meta: + model = "history.HistoryEntry" + strategy = factory.CREATE_STRATEGY + + type = 1 + def create_issue(**kwargs): "Create an issue and along with its dependencies." owner = kwargs.pop("owner", None) diff --git a/tests/fixtures.py b/tests/fixtures.py index 5c9bfa21..97cbea87 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -16,7 +16,7 @@ # along with this program. If not, see . import pytest -import mock +from unittest import mock import functools diff --git a/tests/integration/resources_permissions/test_attachment_resources.py b/tests/integration/resources_permissions/test_attachment_resources.py index 2bdf2e2f..d198bd98 100644 --- a/tests/integration/resources_permissions/test_attachment_resources.py +++ b/tests/integration/resources_permissions/test_attachment_resources.py @@ -69,6 +69,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + return m @pytest.fixture diff --git a/tests/integration/resources_permissions/test_history_resources.py b/tests/integration/resources_permissions/test_history_resources.py index 7cf1950c..fe85eda2 100644 --- a/tests/integration/resources_permissions/test_history_resources.py +++ b/tests/integration/resources_permissions/test_history_resources.py @@ -56,10 +56,23 @@ def data(): 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=[]) + + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) return m @pytest.fixture diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index 2bf40ebf..d4895443 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -7,6 +7,9 @@ from taiga.base.utils import json from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from taiga.projects.votes.services import add_vote +from taiga.projects.occ import OCCResourceMixin + +from unittest import mock import pytest pytestmark = pytest.mark.django_db @@ -64,6 +67,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_issue = f.IssueFactory(project=m.public_project, status__project=m.public_project, severity__project=m.public_project, @@ -120,23 +135,24 @@ def test_issue_update(client, data): data.project_owner ] - issue_data = IssueSerializer(data.public_issue).data - issue_data["subject"] = "test" - issue_data = json.dumps(issue_data) - results = helper_test_http_method(client, 'put', public_url, issue_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + issue_data = IssueSerializer(data.public_issue).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', public_url, issue_data, users) + assert results == [401, 403, 403, 200, 200] - issue_data = IssueSerializer(data.private_issue1).data - issue_data["subject"] = "test" - issue_data = json.dumps(issue_data) - results = helper_test_http_method(client, 'put', private_url1, issue_data, users) - assert results == [401, 403, 403, 200, 200] + issue_data = IssueSerializer(data.private_issue1).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url1, issue_data, users) + assert results == [401, 403, 403, 200, 200] - issue_data = IssueSerializer(data.private_issue2).data - issue_data["subject"] = "test" - issue_data = json.dumps(issue_data) - results = helper_test_http_method(client, 'put', private_url2, issue_data, users) - assert results == [401, 403, 403, 200, 200] + issue_data = IssueSerializer(data.private_issue2).data + issue_data["subject"] = "test" + issue_data = json.dumps(issue_data) + results = helper_test_http_method(client, 'put', private_url2, issue_data, users) + assert results == [401, 403, 403, 200, 200] def test_issue_delete(client, data): @@ -150,6 +166,7 @@ def test_issue_delete(client, data): data.project_member_without_perms, data.project_member_with_perms, ] + results = helper_test_http_method(client, 'delete', public_url, None, users) assert results == [401, 403, 403, 204] results = helper_test_http_method(client, 'delete', private_url1, None, users) @@ -249,17 +266,18 @@ def test_issue_patch(client, data): data.project_owner ] - patch_data = json.dumps({"subject": "test", "version": data.public_issue.version}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + patch_data = json.dumps({"subject": "test", "version": data.public_issue.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_issue1.version}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_issue1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_issue2.version}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_issue2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] def test_issue_bulk_create(client, data): @@ -291,7 +309,6 @@ def test_issue_bulk_create(client, data): data.project_owner ] - bulk_data = json.dumps({"bulk_issues": "test1\ntest2", "project_id": data.public_issue.project.pk}) results = helper_test_http_method(client, 'post', url, bulk_data, users) diff --git a/tests/integration/resources_permissions/test_milestones_resources.py b/tests/integration/resources_permissions/test_milestones_resources.py index 76d8ff4e..ce6fb6de 100644 --- a/tests/integration/resources_permissions/test_milestones_resources.py +++ b/tests/integration/resources_permissions/test_milestones_resources.py @@ -64,6 +64,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_milestone = f.MilestoneFactory(project=m.public_project) m.private_milestone1 = f.MilestoneFactory(project=m.private_project1) m.private_milestone2 = f.MilestoneFactory(project=m.private_project2) diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index 8f52124b..ed0a6a77 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -44,6 +44,7 @@ def data(): email=m.project_member_with_perms.email, 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, email=m.project_member_without_perms.email, @@ -60,6 +61,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_points = f.PointsFactory(project=m.public_project) m.private_points1 = f.PointsFactory(project=m.private_project1) m.private_points2 = f.PointsFactory(project=m.private_project2) @@ -1427,31 +1440,31 @@ def test_membership_list(client, data): response = client.get(url) projects_data = json.loads(response.content.decode('utf-8')) - assert len(projects_data) == 3 + assert len(projects_data) == 5 assert response.status_code == 200 client.login(data.registered_user) response = client.get(url) projects_data = json.loads(response.content.decode('utf-8')) - assert len(projects_data) == 3 + assert len(projects_data) == 5 assert response.status_code == 200 client.login(data.project_member_without_perms) response = client.get(url) projects_data = json.loads(response.content.decode('utf-8')) - assert len(projects_data) == 3 + assert len(projects_data) == 5 assert response.status_code == 200 client.login(data.project_member_with_perms) response = client.get(url) projects_data = json.loads(response.content.decode('utf-8')) - assert len(projects_data) == 5 + assert len(projects_data) == 8 assert response.status_code == 200 client.login(data.project_owner) response = client.get(url) projects_data = json.loads(response.content.decode('utf-8')) - assert len(projects_data) == 5 + assert len(projects_data) == 8 assert response.status_code == 200 diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 58ef3eae..6518734b 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -53,6 +53,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + ContentType = apps.get_model("contenttypes", "ContentType") Project = apps.get_model("projects", "Project") diff --git a/tests/integration/resources_permissions/test_resolver_resources.py b/tests/integration/resources_permissions/test_resolver_resources.py index 096c61bb..009500ff 100644 --- a/tests/integration/resources_permissions/test_resolver_resources.py +++ b/tests/integration/resources_permissions/test_resolver_resources.py @@ -65,6 +65,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.view_only_membership = f.MembershipFactory(project=m.private_project2, user=m.other_user, role__project=m.private_project2, diff --git a/tests/integration/resources_permissions/test_search_resources.py b/tests/integration/resources_permissions/test_search_resources.py index 0867675e..ef35d050 100644 --- a/tests/integration/resources_permissions/test_search_resources.py +++ b/tests/integration/resources_permissions/test_search_resources.py @@ -62,6 +62,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_issue = f.IssueFactory(project=m.public_project, status__project=m.public_project, severity__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 bc3f8768..7d63db6a 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects.tasks.serializers import TaskSerializer from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_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 unittest import mock + import pytest pytestmark = pytest.mark.django_db @@ -63,6 +66,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_task = f.TaskFactory(project=m.public_project, status__project=m.public_project, milestone__project=m.public_project, @@ -120,23 +135,24 @@ def test_task_update(client, data): data.project_owner ] - task_data = TaskSerializer(data.public_task).data - task_data["subject"] = "test" - task_data = json.dumps(task_data) - results = helper_test_http_method(client, 'put', public_url, task_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + task_data = TaskSerializer(data.public_task).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', public_url, task_data, users) + assert results == [401, 403, 403, 200, 200] - task_data = TaskSerializer(data.private_task1).data - task_data["subject"] = "test" - task_data = json.dumps(task_data) - results = helper_test_http_method(client, 'put', private_url1, task_data, users) - assert results == [401, 403, 403, 200, 200] + task_data = TaskSerializer(data.private_task1).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url1, task_data, users) + assert results == [401, 403, 403, 200, 200] - task_data = TaskSerializer(data.private_task2).data - task_data["subject"] = "test" - task_data = json.dumps(task_data) - results = helper_test_http_method(client, 'put', private_url2, task_data, users) - assert results == [401, 403, 403, 200, 200] + task_data = TaskSerializer(data.private_task2).data + task_data["subject"] = "test" + task_data = json.dumps(task_data) + results = helper_test_http_method(client, 'put', private_url2, task_data, users) + assert results == [401, 403, 403, 200, 200] def test_task_delete(client, data): @@ -240,17 +256,18 @@ def test_task_patch(client, data): data.project_owner ] - patch_data = json.dumps({"subject": "test", "version": data.public_task.version}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + patch_data = json.dumps({"subject": "test", "version": data.public_task.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_task1.version}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_task1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_task2.version}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_task2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] def test_task_action_bulk_create(client, data): diff --git a/tests/integration/resources_permissions/test_timelines_resources.py b/tests/integration/resources_permissions/test_timelines_resources.py index c364b0a7..63948ff8 100644 --- a/tests/integration/resources_permissions/test_timelines_resources.py +++ b/tests/integration/resources_permissions/test_timelines_resources.py @@ -61,6 +61,17 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) return m diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index 7e6db573..df172a1a 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -103,7 +103,7 @@ def test_user_list(client, data): response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 4 + assert len(users_data) == 6 assert response.status_code == 200 diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index bf41066d..1b006935 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects.userstories.serializers import UserStorySerializer from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_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 unittest import mock + import pytest pytestmark = pytest.mark.django_db @@ -63,6 +66,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_points = f.PointsFactory(project=m.public_project) m.private_points1 = f.PointsFactory(project=m.private_project1) m.private_points2 = f.PointsFactory(project=m.private_project2) @@ -118,23 +133,24 @@ def test_user_story_update(client, data): data.project_owner ] - user_story_data = UserStorySerializer(data.public_user_story).data - user_story_data["subject"] = "test" - user_story_data = json.dumps(user_story_data) - results = helper_test_http_method(client, 'put', public_url, user_story_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + user_story_data = UserStorySerializer(data.public_user_story).data + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', public_url, user_story_data, users) + assert results == [401, 403, 403, 200, 200] - user_story_data = UserStorySerializer(data.private_user_story1).data - user_story_data["subject"] = "test" - user_story_data = json.dumps(user_story_data) - results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) - assert results == [401, 403, 403, 200, 200] + user_story_data = UserStorySerializer(data.private_user_story1).data + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url1, user_story_data, users) + assert results == [401, 403, 403, 200, 200] - user_story_data = UserStorySerializer(data.private_user_story2).data - user_story_data["subject"] = "test" - user_story_data = json.dumps(user_story_data) - results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) - assert results == [401, 403, 403, 200, 200] + user_story_data = UserStorySerializer(data.private_user_story2).data + user_story_data["subject"] = "test" + user_story_data = json.dumps(user_story_data) + results = helper_test_http_method(client, 'put', private_url2, user_story_data, users) + assert results == [401, 403, 403, 200, 200] def test_user_story_delete(client, data): @@ -223,17 +239,18 @@ def test_user_story_patch(client, data): data.project_owner ] - patch_data = json.dumps({"subject": "test", "version": data.public_user_story.version}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 403, 403, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + patch_data = json.dumps({"subject": "test", "version": data.public_user_story.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_user_story1.version}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_user_story1.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 403, 403, 200, 200] - patch_data = json.dumps({"subject": "test", "version": data.private_user_story2.version}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"subject": "test", "version": data.private_user_story2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] def test_user_story_action_bulk_create(client, data): diff --git a/tests/integration/resources_permissions/test_wiki_resources.py b/tests/integration/resources_permissions/test_wiki_resources.py index 0691f775..8d0ff278 100644 --- a/tests/integration/resources_permissions/test_wiki_resources.py +++ b/tests/integration/resources_permissions/test_wiki_resources.py @@ -4,10 +4,13 @@ from taiga.base.utils import json from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer from taiga.projects.wiki.models import WikiPage, WikiLink from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_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 unittest import mock + import pytest pytestmark = pytest.mark.django_db @@ -64,6 +67,18 @@ def data(): role__project=m.private_project2, role__permissions=[]) + f.MembershipFactory(project=m.public_project, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project1, + user=m.project_owner, + is_owner=True) + + f.MembershipFactory(project=m.private_project2, + user=m.project_owner, + is_owner=True) + m.public_wiki_page = f.WikiPageFactory(project=m.public_project) m.private_wiki_page1 = f.WikiPageFactory(project=m.private_project1) m.private_wiki_page2 = f.WikiPageFactory(project=m.private_project2) @@ -109,23 +124,24 @@ def test_wiki_page_update(client, data): data.project_owner ] - wiki_page_data = WikiPageSerializer(data.public_wiki_page).data - wiki_page_data["content"] = "test" - wiki_page_data = json.dumps(wiki_page_data) - results = helper_test_http_method(client, 'put', public_url, wiki_page_data, users) - assert results == [401, 200, 200, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + wiki_page_data = WikiPageSerializer(data.public_wiki_page).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', public_url, wiki_page_data, users) + assert results == [401, 200, 200, 200, 200] - wiki_page_data = WikiPageSerializer(data.private_wiki_page1).data - wiki_page_data["content"] = "test" - wiki_page_data = json.dumps(wiki_page_data) - results = helper_test_http_method(client, 'put', private_url1, wiki_page_data, users) - assert results == [401, 200, 200, 200, 200] + wiki_page_data = WikiPageSerializer(data.private_wiki_page1).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_page_data, users) + assert results == [401, 200, 200, 200, 200] - wiki_page_data = WikiPageSerializer(data.private_wiki_page2).data - wiki_page_data["content"] = "test" - wiki_page_data = json.dumps(wiki_page_data) - results = helper_test_http_method(client, 'put', private_url2, wiki_page_data, users) - assert results == [401, 403, 403, 200, 200] + wiki_page_data = WikiPageSerializer(data.private_wiki_page2).data + wiki_page_data["content"] = "test" + wiki_page_data = json.dumps(wiki_page_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_page_data, users) + assert results == [401, 403, 403, 200, 200] def test_wiki_page_delete(client, data): @@ -226,17 +242,18 @@ def test_wiki_page_patch(client, data): data.project_owner ] - patch_data = json.dumps({"content": "test", "version": data.public_wiki_page.version}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 200, 200, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + patch_data = json.dumps({"content": "test", "version": data.public_wiki_page.version}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] - patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 200, 200, 200, 200] + patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 200, 200, 200, 200] - patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"content": "test", "version": data.private_wiki_page2.version}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] def test_wiki_page_action_render(client, data): url = reverse('wiki-render') @@ -288,23 +305,24 @@ def test_wiki_link_update(client, data): data.project_owner ] - wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data - wiki_link_data["title"] = "test" - wiki_link_data = json.dumps(wiki_link_data) - results = helper_test_http_method(client, 'put', public_url, wiki_link_data, users) - assert results == [401, 200, 200, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', public_url, wiki_link_data, users) + assert results == [401, 200, 200, 200, 200] - wiki_link_data = WikiLinkSerializer(data.private_wiki_link1).data - wiki_link_data["title"] = "test" - wiki_link_data = json.dumps(wiki_link_data) - results = helper_test_http_method(client, 'put', private_url1, wiki_link_data, users) - assert results == [401, 200, 200, 200, 200] + wiki_link_data = WikiLinkSerializer(data.private_wiki_link1).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', private_url1, wiki_link_data, users) + assert results == [401, 200, 200, 200, 200] - wiki_link_data = WikiLinkSerializer(data.private_wiki_link2).data - wiki_link_data["title"] = "test" - wiki_link_data = json.dumps(wiki_link_data) - results = helper_test_http_method(client, 'put', private_url2, wiki_link_data, users) - assert results == [401, 403, 403, 200, 200] + wiki_link_data = WikiLinkSerializer(data.private_wiki_link2).data + wiki_link_data["title"] = "test" + wiki_link_data = json.dumps(wiki_link_data) + results = helper_test_http_method(client, 'put', private_url2, wiki_link_data, users) + assert results == [401, 403, 403, 200, 200] def test_wiki_link_delete(client, data): @@ -405,14 +423,15 @@ def test_wiki_link_patch(client, data): data.project_owner ] - patch_data = json.dumps({"title": "test"}) - results = helper_test_http_method(client, 'patch', public_url, patch_data, users) - assert results == [401, 200, 200, 200, 200] + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', public_url, patch_data, users) + assert results == [401, 200, 200, 200, 200] - patch_data = json.dumps({"title": "test"}) - results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) - assert results == [401, 200, 200, 200, 200] + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', private_url1, patch_data, users) + assert results == [401, 200, 200, 200, 200] - patch_data = json.dumps({"title": "test"}) - results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) - assert results == [401, 403, 403, 200, 200] + patch_data = json.dumps({"title": "test"}) + results = helper_test_http_method(client, 'patch', private_url2, patch_data, users) + assert results == [401, 403, 403, 200, 200] diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index 2d8cc791..1aa1ef70 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -15,6 +15,7 @@ def test_create_user_story_attachment_without_file(client): Bug test "Don't create attachments without attached_file" """ us = f.UserStoryFactory.create() + membership = f.MembershipFactory(project=us.project, user=us.owner, is_owner=True) attachment_data = { "description": "test", "attached_file": None, @@ -31,6 +32,7 @@ def test_create_user_story_attachment_without_file(client): def test_create_attachment_on_wrong_project(client): issue1 = f.create_issue() issue2 = f.create_issue(owner=issue1.owner) + membership = f.MembershipFactory(project=issue1.project, user=issue1.owner, is_owner=True) assert issue1.owner == issue2.owner assert issue1.project.owner == issue2.project.owner diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py index 22b8cd7a..690b724e 100644 --- a/tests/integration/test_history.py +++ b/tests/integration/test_history.py @@ -26,7 +26,7 @@ from .. import factories as f from taiga.projects.history import services from taiga.projects.history.models import HistoryEntry from taiga.projects.history.choices import HistoryType - +from taiga.projects.history.services import make_key_from_model_object pytestmark = pytest.mark.django_db @@ -143,7 +143,7 @@ def test_issue_resource_history_test(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) role = f.RoleFactory.create(project=project) - member = f.MembershipFactory.create(project=project, user=user, role=role) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) issue = f.IssueFactory.create(owner=user, project=project) mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" @@ -200,6 +200,7 @@ def test_take_hidden_snapshot(): def test_history_with_only_comment_shouldnot_be_hidden(client): project = f.create_project() us = f.create_userstory(project=project) + membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) qs_all = HistoryEntry.objects.all() qs_hidden = qs_all.filter(is_hidden=True) @@ -209,7 +210,6 @@ def test_history_with_only_comment_shouldnot_be_hidden(client): url = reverse("userstories-detail", args=[us.pk]) data = json.dumps({"comment": "test comment", "version": us.version}) - print(url, data) client.login(project.owner) response = client.patch(url, data, content_type="application/json") @@ -217,3 +217,18 @@ def test_history_with_only_comment_shouldnot_be_hidden(client): assert qs_all.count() == 1 assert qs_hidden.count() == 0 + +def test_delete_comment_by_project_owner(client): + project = f.create_project() + us = f.create_userstory(project=project) + membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + key = make_key_from_model_object(us) + history_entry = f.HistoryEntryFactory.create(type=HistoryType.change, + comment="testing", + key=key) + + client.login(project.owner) + url = reverse("userstory-history-delete-comment", args=(us.id,)) + url = "%s?id=%s"%(url, history_entry.id) + response = client.post(url, content_type="application/json") + assert 200 == response.status_code, response.status_code diff --git a/tests/integration/test_hooks_bitbucket.py b/tests/integration/test_hooks_bitbucket.py new file mode 100644 index 00000000..2fd53059 --- /dev/null +++ b/tests/integration/test_hooks_bitbucket.py @@ -0,0 +1,269 @@ +import pytest +import json +import urllib + +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core import mail +from django.conf import settings + +from taiga.hooks.bitbucket import event_hooks +from taiga.hooks.bitbucket.api import BitBucketViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + +def test_bad_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = {} + response = client.post(url, urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded") + response_content = json.loads(response.content.decode("utf-8")) + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR=settings.BITBUCKET_VALID_ORIGIN_IPS[0]) + assert response.status_code == 200 + +def test_invalid_ip(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 400 + + +def test_not_ip_filter(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "bitbucket": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [] + } + }) + + url = reverse("bitbucket-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {'payload': ['{"commits": []}']} + response = client.post(url, + urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded", + REMOTE_ADDR="111.111.111.112") + assert response.status_code == 200 + + +def test_push_event_detected(client): + project=f.ProjectFactory() + url = reverse("bitbucket-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {'payload': ['{"commits": [{"message": "test message"}]}']} + + BitBucketViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, urllib.parse.urlencode(data, True), + content_type="application/x-www-form-urlencoded") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 200 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = [ + '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(issue.ref, new_status.slug) + ] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = [ + '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(task.ref, new_status.slug) + ] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = [ + '{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(user_story.ref, new_status.slug) + ] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = [ + '{"commits": [{"message": "test message test tg-%s #%s ok bye!"}]}'%(task.ref, new_status.slug.upper()) + ] + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = [ + '{"commits": [{"message": "test message test TG-6666666 #%s ok bye!"}]}'%(issue_status.slug) + ] + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = [ + '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}'%(user_story.ref) + ] + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = [ + '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}'%(issue.ref) + ] + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_api_get_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = json.loads(response.content.decode("utf-8")) + assert "bitbucket" in content + assert content["bitbucket"]["secret"] != "" + assert content["bitbucket"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "bitbucket": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "bitbucket" in config + assert config["bitbucket"]["secret"] == "test_secret" + assert config["bitbucket"]["webhooks_url"] != "test_url" + +def test_replace_bitbucket_references(): + assert event_hooks.replace_bitbucket_references("project-url", "#2") == "[BitBucket#2](project-url/issues/2)" + assert event_hooks.replace_bitbucket_references("project-url", "#2 ") == "[BitBucket#2](project-url/issues/2) " + assert event_hooks.replace_bitbucket_references("project-url", " #2 ") == " [BitBucket#2](project-url/issues/2) " + assert event_hooks.replace_bitbucket_references("project-url", " #2") == " [BitBucket#2](project-url/issues/2)" + assert event_hooks.replace_bitbucket_references("project-url", "#test") == "#test" diff --git a/tests/integration/test_github_hook.py b/tests/integration/test_hooks_github.py similarity index 86% rename from tests/integration/test_github_hook.py rename to tests/integration/test_hooks_github.py index 168e44ef..bd5103ae 100644 --- a/tests/integration/test_github_hook.py +++ b/tests/integration/test_hooks_github.py @@ -6,9 +6,9 @@ from unittest import mock from django.core.urlresolvers import reverse from django.core import mail -from taiga.github_hook.api import GitHubViewSet -from taiga.github_hook import event_hooks -from taiga.github_hook.exceptions import ActionSyntaxException +from taiga.hooks.github import event_hooks +from taiga.hooks.github.api import GitHubViewSet +from taiga.hooks.exceptions import ActionSyntaxException from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -75,8 +75,10 @@ def test_push_event_detected(client): def test_push_event_issue_processing(client): creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) new_status = f.IssueStatusFactory(project=creation_status.project) - issue = f.IssueFactory.create(status=creation_status, project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) payload = {"commits": [ {"message": """test message test TG-%s #%s ok @@ -93,8 +95,10 @@ def test_push_event_issue_processing(client): def test_push_event_task_processing(client): creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) new_status = f.TaskStatusFactory(project=creation_status.project) - task = f.TaskFactory.create(status=creation_status, project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) payload = {"commits": [ {"message": """test message test TG-%s #%s ok @@ -111,8 +115,10 @@ def test_push_event_task_processing(client): def test_push_event_user_story_processing(client): creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) new_status = f.UserStoryStatusFactory(project=creation_status.project) - user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) payload = {"commits": [ {"message": """test message test TG-%s #%s ok @@ -130,8 +136,10 @@ def test_push_event_user_story_processing(client): def test_push_event_processing_case_insensitive(client): creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) new_status = f.TaskStatusFactory(project=creation_status.project) - task = f.TaskFactory.create(status=creation_status, project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) payload = {"commits": [ {"message": """test message test tg-%s #%s ok @@ -291,12 +299,17 @@ def test_issues_event_bad_issue(client): def test_issue_comment_event_on_existing_issue_task_and_us(client): - issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) - take_snapshot(issue, user=issue.owner) - task = f.TaskFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"]) - take_snapshot(task, user=task.owner) - us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"]) - take_snapshot(us, user=us.owner) + project = f.ProjectFactory() + role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"]) + membership = f.MembershipFactory(project=project, role=role, user=project.owner) + user = f.UserFactory() + + issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(issue, user=user) + task = f.TaskFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(task, user=user) + us = f.UserStoryFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project) + take_snapshot(us, user=user) payload = { "action": "created", @@ -399,6 +412,7 @@ def test_issues_event_bad_comment(client): def test_api_get_project_modules(client): project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -413,6 +427,7 @@ def test_api_get_project_modules(client): def test_api_patch_project_modules(client): project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py new file mode 100644 index 00000000..adf3970c --- /dev/null +++ b/tests/integration/test_hooks_gitlab.py @@ -0,0 +1,385 @@ +import pytest +import json + +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core import mail + +from taiga.hooks.gitlab import event_hooks +from taiga.hooks.gitlab.api import GitLabViewSet +from taiga.hooks.exceptions import ActionSyntaxException +from taiga.projects.issues.models import Issue +from taiga.projects.tasks.models import Task +from taiga.projects.userstories.models import UserStory +from taiga.projects.models import Membership +from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot +from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.notifications.models import NotifyPolicy +from taiga.projects import services +from .. import factories as f + +pytestmark = pytest.mark.django_db + +def test_bad_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e" + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "badbadbad") + data = {} + response = client.post(url, json.dumps(data), content_type="application/json") + response_content = json.loads(response.content.decode("utf-8")) + assert response.status_code == 400 + assert "Bad signature" in response_content["_error_message"] + + +def test_ok_signature(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 200 + + +def test_invalid_ip(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": ["111.111.111.111"], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.112") + + assert response.status_code == 400 + + +def test_not_ip_filter(client): + project=f.ProjectFactory() + f.ProjectModulesConfigFactory(project=project, config={ + "gitlab": { + "secret": "tpnIwJDz4e", + "valid_origin_ips": [], + } + }) + + url = reverse("gitlab-hook-list") + url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e") + data = {"test:": "data"} + response = client.post(url, + json.dumps(data), + content_type="application/json", + REMOTE_ADDR="111.111.111.111") + + assert response.status_code == 200 + + +def test_push_event_detected(client): + project=f.ProjectFactory() + url = reverse("gitlab-hook-list") + url = "%s?project=%s"%(url, project.id) + data = {"commits": [ + {"message": "test message"}, + ]} + + GitLabViewSet._validate_signature = mock.Mock(return_value=True) + + with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock: + response = client.post(url, json.dumps(data), + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") + + assert process_event_mock.call_count == 1 + + assert response.status_code == 200 + + +def test_push_event_issue_processing(client): + creation_status = f.IssueStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.IssueStatusFactory(project=creation_status.project) + issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(issue.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(issue.project, payload) + ev_hook.process_event() + issue = Issue.objects.get(id=issue.id) + assert issue.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_processing(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(task.ref, new_status.slug)}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_user_story_processing(client): + creation_status = f.UserStoryStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_us"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.UserStoryStatusFactory(project=creation_status.project) + user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test TG-%s #%s ok + bye! + """%(user_story.ref, new_status.slug)}, + ]} + + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + ev_hook.process_event() + user_story = UserStory.objects.get(id=user_story.id) + assert user_story.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_processing_case_insensitive(client): + creation_status = f.TaskStatusFactory() + role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"]) + membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner) + new_status = f.TaskStatusFactory(project=creation_status.project) + task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner) + payload = {"commits": [ + {"message": """test message + test tg-%s #%s ok + bye! + """%(task.ref, new_status.slug.upper())}, + ]} + mail.outbox = [] + ev_hook = event_hooks.PushEventHook(task.project, payload) + ev_hook.process_event() + task = Task.objects.get(id=task.id) + assert task.status.id == new_status.id + assert len(mail.outbox) == 1 + + +def test_push_event_task_bad_processing_non_existing_ref(client): + issue_status = f.IssueStatusFactory() + payload = {"commits": [ + {"message": """test message + test TG-6666666 #%s ok + bye! + """%(issue_status.slug)}, + ]} + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue_status.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The referenced element doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_us_bad_processing_non_existing_status(client): + user_story = f.UserStoryFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """%(user_story.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(user_story.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_push_event_bad_processing_non_existing_status(client): + issue = f.IssueFactory.create() + payload = {"commits": [ + {"message": """test message + test TG-%s #non-existing-slug ok + bye! + """%(issue.ref)}, + ]} + + mail.outbox = [] + + ev_hook = event_hooks.PushEventHook(issue.project, payload) + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "The status doesn't exist" + assert len(mail.outbox) == 0 + + +def test_issues_event_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_owner=True) + notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project) + notify_policy.notify_level = NotifyLevel.watch + notify_policy.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "title": "test-title", + "description": "test-body", + "url": "http://gitlab.com/test/project/issues/11", + "action": "open", + }, + } + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 2 + assert len(mail.outbox) == 1 + + +def test_issues_event_other_than_opened_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "title": "test-title", + "description": "test-body", + "url": "http://gitlab.com/test/project/issues/11", + "action": "update", + }, + } + + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + ev_hook.process_event() + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + +def test_issues_event_bad_issue(client): + issue = f.IssueFactory.create() + issue.project.default_issue_status = issue.status + issue.project.default_issue_type = issue.type + issue.project.default_severity = issue.severity + issue.project.default_priority = issue.priority + issue.project.save() + + payload = { + "object_kind": "issue", + "object_attributes": { + "action": "open", + }, + } + mail.outbox = [] + + ev_hook = event_hooks.IssuesEventHook(issue.project, payload) + + with pytest.raises(ActionSyntaxException) as excinfo: + ev_hook.process_event() + + assert str(excinfo.value) == "Invalid issue information" + + assert Issue.objects.count() == 1 + assert len(mail.outbox) == 0 + + + +def test_api_get_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + response = client.get(url) + assert response.status_code == 200 + content = json.loads(response.content.decode("utf-8")) + assert "gitlab" in content + assert content["gitlab"]["secret"] != "" + assert content["gitlab"]["webhooks_url"] != "" + + +def test_api_patch_project_modules(client): + project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + + url = reverse("projects-modules", args=(project.id,)) + + client.login(project.owner) + data = { + "gitlab": { + "secret": "test_secret", + "url": "test_url", + } + } + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 204 + + config = services.get_modules_config(project).config + assert "gitlab" in config + assert config["gitlab"]["secret"] == "test_secret" + assert config["gitlab"]["webhooks_url"] != "test_url" + +def test_replace_gitlab_references(): + assert event_hooks.replace_gitlab_references("project-url", "#2") == "[GitLab#2](project-url/issues/2)" + assert event_hooks.replace_gitlab_references("project-url", "#2 ") == "[GitLab#2](project-url/issues/2) " + assert event_hooks.replace_gitlab_references("project-url", " #2 ") == " [GitLab#2](project-url/issues/2) " + assert event_hooks.replace_gitlab_references("project-url", " #2") == " [GitLab#2](project-url/issues/2)" + assert event_hooks.replace_gitlab_references("project-url", "#test") == "#test" diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 4502080b..00480c0d 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -168,6 +168,7 @@ def test_invalid_project_import_with_extra_data(client): def test_invalid_issue_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-issue", args=[project.pk]) @@ -179,6 +180,7 @@ def test_invalid_issue_import(client): def test_valid_user_story_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -199,6 +201,7 @@ def test_valid_user_story_import(client): def test_valid_issue_import_without_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project) @@ -220,6 +223,7 @@ def test_valid_issue_import_without_extra_data(client): def test_valid_issue_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project) @@ -252,6 +256,7 @@ def test_valid_issue_import_with_extra_data(client): def test_invalid_issue_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project) @@ -275,6 +280,7 @@ def test_invalid_issue_import_with_extra_data(client): def test_invalid_issue_import_with_bad_choices(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project) @@ -333,6 +339,7 @@ def test_invalid_issue_import_with_bad_choices(client): def test_invalid_us_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-us", args=[project.pk]) @@ -344,6 +351,7 @@ def test_invalid_us_import(client): def test_valid_us_import_without_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -362,6 +370,7 @@ def test_valid_us_import_without_extra_data(client): def test_valid_us_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -389,6 +398,7 @@ def test_valid_us_import_with_extra_data(client): def test_invalid_us_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -409,6 +419,7 @@ def test_invalid_us_import_with_extra_data(client): def test_invalid_us_import_with_bad_choices(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -428,6 +439,7 @@ def test_invalid_us_import_with_bad_choices(client): def test_invalid_task_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-task", args=[project.pk]) @@ -439,6 +451,7 @@ def test_invalid_task_import(client): def test_valid_task_import_without_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -457,6 +470,7 @@ def test_valid_task_import_without_extra_data(client): def test_valid_task_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -484,6 +498,7 @@ def test_valid_task_import_with_extra_data(client): def test_invalid_task_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -504,6 +519,7 @@ def test_invalid_task_import_with_extra_data(client): def test_invalid_task_import_with_bad_choices(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -523,6 +539,7 @@ def test_invalid_task_import_with_bad_choices(client): def test_valid_task_with_user_story(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) us = f.UserStoryFactory.create(project=project) project.save() @@ -542,6 +559,7 @@ def test_valid_task_with_user_story(client): def test_invalid_wiki_page_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -553,6 +571,7 @@ def test_invalid_wiki_page_import(client): def test_valid_wiki_page_import_without_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -568,6 +587,7 @@ def test_valid_wiki_page_import_without_extra_data(client): def test_valid_wiki_page_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -592,6 +612,7 @@ def test_valid_wiki_page_import_with_extra_data(client): def test_invalid_wiki_page_import_with_extra_data(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -610,6 +631,7 @@ def test_invalid_wiki_page_import_with_extra_data(client): def test_invalid_wiki_link_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-link", args=[project.pk]) @@ -621,6 +643,7 @@ def test_invalid_wiki_link_import(client): def test_valid_wiki_link_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-link", args=[project.pk]) @@ -636,6 +659,7 @@ def test_valid_wiki_link_import(client): def test_invalid_milestone_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) @@ -647,6 +671,7 @@ def test_invalid_milestone_import(client): def test_valid_milestone_import(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) @@ -663,6 +688,7 @@ def test_valid_milestone_import(client): def test_milestone_import_duplicated_milestone(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index a40cd418..5a3b4ac7 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -46,6 +46,7 @@ def test_update_issues_order_in_bulk(): def test_api_create_issues_in_bulk(client): project = f.create_project() + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("issues-bulk-create") @@ -59,9 +60,10 @@ def test_api_create_issues_in_bulk(client): def test_api_filter_by_subject(client): - f.create_issue() - issue = f.create_issue(subject="some random subject") - url = reverse("issues-list") + "?subject=some subject" + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="some random subject", owner=user) + url = reverse("issues-list") + "?q=some subject" client.login(issue.owner) response = client.get(url) @@ -72,8 +74,9 @@ def test_api_filter_by_subject(client): def test_api_filter_by_text_1(client): - f.create_issue() - issue = f.create_issue(subject="this is the issue one") + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) f.create_issue(subject="this is the issue two", owner=issue.owner) url = reverse("issues-list") + "?q=one" @@ -85,8 +88,9 @@ def test_api_filter_by_text_1(client): assert number_of_issues == 1 def test_api_filter_by_text_2(client): - f.create_issue() - issue = f.create_issue(subject="this is the issue one") + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) f.create_issue(subject="this is the issue two", owner=issue.owner) url = reverse("issues-list") + "?q=this is the issue one" @@ -98,8 +102,9 @@ def test_api_filter_by_text_2(client): assert number_of_issues == 1 def test_api_filter_by_text_3(client): - f.create_issue() - issue = f.create_issue(subject="this is the issue one") + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) f.create_issue(subject="this is the issue two", owner=issue.owner) url = reverse("issues-list") + "?q=this is the issue" @@ -111,8 +116,9 @@ def test_api_filter_by_text_3(client): assert number_of_issues == 2 def test_api_filter_by_text_4(client): - f.create_issue() - issue = f.create_issue(subject="this is the issue one") + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="this is the issue one", owner=user) f.create_issue(subject="this is the issue two", owner=issue.owner) url = reverse("issues-list") + "?q=one two" @@ -124,8 +130,9 @@ def test_api_filter_by_text_4(client): assert number_of_issues == 0 def test_api_filter_by_text_5(client): - f.create_issue() - issue = f.create_issue(subject="python 3") + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="python 3", owner=user) url = reverse("issues-list") + "?q=python 3" client.login(issue.owner) @@ -137,9 +144,13 @@ def test_api_filter_by_text_5(client): def test_api_filter_by_text_6(client): - f.create_issue() - issue = f.create_issue(subject="test") - url = reverse("issues-list") + "?q=%s"%(issue.ref) + user = f.UserFactory(is_superuser=True) + f.create_issue(owner=user) + issue = f.create_issue(subject="test", owner=user) + issue.ref = 123 + issue.save() + print(issue.ref, issue.subject) + url = reverse("issues-list") + "?q=%s" % (issue.ref) client.login(issue.owner) response = client.get(url) diff --git a/tests/integration/test_mdrender.py b/tests/integration/test_mdrender.py index 700fb465..a5e076e8 100644 --- a/tests/integration/test_mdrender.py +++ b/tests/integration/test_mdrender.py @@ -33,7 +33,7 @@ dummy_project.slug = "test" def test_proccessor_valid_user_mention(): factories.UserFactory(username="user1", full_name="test name") result = render(dummy_project, "**@user1**") - expected_result = "

@user1

" + expected_result = "

@user1

" assert result == expected_result diff --git a/tests/integration/test_memberships.py b/tests/integration/test_memberships.py index c77aff75..90fd1e97 100644 --- a/tests/integration/test_memberships.py +++ b/tests/integration/test_memberships.py @@ -34,6 +34,7 @@ def test_api_create_bulk_members(client): joseph = f.UserFactory.create() tester = f.RoleFactory(project=project, name="Tester") gamer = f.RoleFactory(project=project, name="Gamer") + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("memberships-bulk-create") @@ -54,6 +55,7 @@ def test_api_create_bulk_members(client): def test_api_create_bulk_members_with_extra_text(client, outbox): project = f.ProjectFactory() tester = f.RoleFactory(project=project, name="Tester") + membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("memberships-bulk-create") invitation_extra_text = "this is a not so random invitation text" @@ -77,6 +79,7 @@ def test_api_create_bulk_members_with_extra_text(client, outbox): def test_api_resend_invitation(client, outbox): invitation = f.create_invitation() + membership = f.MembershipFactory(project=invitation.project, user=invitation.project.owner, is_owner=True) url = reverse("memberships-resend-invitation", kwargs={"pk": invitation.pk}) client.login(invitation.project.owner) @@ -91,6 +94,7 @@ def test_api_invite_existing_user(client, outbox): "Should create the invitation linked to that user" user = f.UserFactory.create() role = f.RoleFactory.create() + membership = f.MembershipFactory(project=role.project, user=role.project.owner, is_owner=True) client.login(role.project.owner) @@ -143,9 +147,11 @@ def test_api_create_invalid_membership_role_doesnt_exist_in_the_project(client): def test_api_create_membership(client): - user = f.UserFactory() - role = f.RoleFactory.create() - client.login(role.project.owner) + membership = f.MembershipFactory(is_owner=True) + role = f.RoleFactory.create(project=membership.project) + user = f.UserFactory.create() + + client.login(membership.user) url = reverse("memberships-list") data = {"role": role.pk, "project": role.project.pk, "email": user.email} response = client.json.post(url, json.dumps(data)) @@ -155,8 +161,8 @@ def test_api_create_membership(client): def test_api_edit_membership(client): - membership = f.MembershipFactory() - client.login(membership.project.owner) + membership = f.MembershipFactory(is_owner=True) + client.login(membership.user) url = reverse("memberships-detail", args=[membership.id]) data = {"email": "new@email.com"} response = client.json.patch(url, json.dumps(data)) @@ -165,9 +171,27 @@ def test_api_edit_membership(client): def test_api_delete_membership(client): - membership = f.MembershipFactory() - client.login(membership.project.owner) + membership = f.MembershipFactory(is_owner=True) + client.login(membership.user) + url = reverse("memberships-detail", args=[membership.id]) + response = client.json.delete(url) + + assert response.status_code == 400 + + f.MembershipFactory(is_owner=True, project=membership.project) + url = reverse("memberships-detail", args=[membership.id]) response = client.json.delete(url) assert response.status_code == 204 + + +def test_api_delete_membership_without_user(client): + membership_owner = f.MembershipFactory(is_owner=True) + membership_without_user_one = f.MembershipFactory(project=membership_owner.project, user=None) + membership_without_user_two = f.MembershipFactory(project=membership_owner.project, user=None) + client.login(membership_owner.user) + url = reverse("memberships-detail", args=[membership_without_user_one.id]) + response = client.json.delete(url) + + assert response.status_code == 204 diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py index e32f23ac..ab980e35 100644 --- a/tests/integration/test_milestones.py +++ b/tests/integration/test_milestones.py @@ -33,9 +33,8 @@ def test_update_milestone_with_userstories_list(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) role = f.RoleFactory.create(project=project) - member = f.MembershipFactory.create(project=project, user=user, role=role) + member = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) sprint = f.MilestoneFactory.create(project=project, owner=user) - points = f.PointsFactory.create(project=project, value=None) us = f.UserStoryFactory.create(project=project, owner=user) @@ -49,4 +48,3 @@ def test_update_milestone_with_userstories_list(client): client.login(user) response = client.json.patch(url, json.dumps(form_data)) assert response.status_code == 200 - diff --git a/tests/integration/test_neighbors.py b/tests/integration/test_neighbors.py index 8cb37e90..6d3b7b02 100644 --- a/tests/integration/test_neighbors.py +++ b/tests/integration/test_neighbors.py @@ -21,7 +21,6 @@ import pytest from taiga.projects.userstories.models import UserStory from taiga.projects.issues.models import Issue -from taiga.base import tags from taiga.base import neighbors as n from .. import factories as f @@ -58,7 +57,7 @@ class TestUserStories: us1 = f.UserStoryFactory.create(project=project, tags=tag_names) us2 = f.UserStoryFactory.create(project=project, tags=tag_names) - test_user_stories = tags.filter(UserStory.objects.get_queryset(), contains=tag_names) + test_user_stories = UserStory.objects.get_queryset().filter(tags__contains=tag_names) neighbors = n.get_neighbors(us1, results_set=test_user_stories) @@ -116,7 +115,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, severity=severity1) issue3 = f.IssueFactory.create(project=project, severity=severity1) - issues = Issue.objects.filter(project=project).order_by("severity") + issues = Issue.objects.filter(project=project).order_by("severity", "-id") issue2_neighbors = n.get_neighbors(issue2, results_set=issues) issue3_neighbors = n.get_neighbors(issue3, results_set=issues) @@ -135,7 +134,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, severity=severity1) issue3 = f.IssueFactory.create(project=project, severity=severity1) - issues = Issue.objects.filter(project=project).order_by("-severity") + issues = Issue.objects.filter(project=project).order_by("-severity", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -154,7 +153,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, status=status1) issue3 = f.IssueFactory.create(project=project, status=status1) - issues = Issue.objects.filter(project=project).order_by("status") + issues = Issue.objects.filter(project=project).order_by("status", "-id") issue2_neighbors = n.get_neighbors(issue2, results_set=issues) issue3_neighbors = n.get_neighbors(issue3, results_set=issues) @@ -173,7 +172,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, status=status1) issue3 = f.IssueFactory.create(project=project, status=status1) - issues = Issue.objects.filter(project=project).order_by("-status") + issues = Issue.objects.filter(project=project).order_by("-status", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -192,7 +191,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, priority=priority1) issue3 = f.IssueFactory.create(project=project, priority=priority1) - issues = Issue.objects.filter(project=project).order_by("priority") + issues = Issue.objects.filter(project=project).order_by("priority", "-id") issue2_neighbors = n.get_neighbors(issue2, results_set=issues) issue3_neighbors = n.get_neighbors(issue3, results_set=issues) @@ -211,7 +210,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, priority=priority1) issue3 = f.IssueFactory.create(project=project, priority=priority1) - issues = Issue.objects.filter(project=project).order_by("-priority") + issues = Issue.objects.filter(project=project).order_by("-priority", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -230,7 +229,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, owner=owner1) issue3 = f.IssueFactory.create(project=project, owner=owner1) - issues = Issue.objects.filter(project=project).order_by("owner__full_name") + issues = Issue.objects.filter(project=project).order_by("owner__full_name", "-id") issue2_neighbors = n.get_neighbors(issue2, results_set=issues) issue3_neighbors = n.get_neighbors(issue3, results_set=issues) @@ -249,7 +248,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, owner=owner1) issue3 = f.IssueFactory.create(project=project, owner=owner1) - issues = Issue.objects.filter(project=project).order_by("-owner__full_name") + issues = Issue.objects.filter(project=project).order_by("-owner__full_name", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -268,7 +267,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) issue3 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) - issues = Issue.objects.filter(project=project).order_by("assigned_to__full_name") + issues = Issue.objects.filter(project=project).order_by("assigned_to__full_name", "-id") issue2_neighbors = n.get_neighbors(issue2, results_set=issues) issue3_neighbors = n.get_neighbors(issue3, results_set=issues) @@ -287,7 +286,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) issue3 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) - issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name") + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -304,7 +303,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, assigned_to=None) issue3 = f.IssueFactory.create(project=project, assigned_to=None) - issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name") + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) @@ -320,7 +319,7 @@ class TestIssues: issue2 = f.IssueFactory.create(project=project, assigned_to=assigned_to1) issue3 = f.IssueFactory.create(project=project, assigned_to=None) - issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name") + issues = Issue.objects.filter(project=project).order_by("-assigned_to__full_name", "-id") issue1_neighbors = n.get_neighbors(issue1, results_set=issues) issue2_neighbors = n.get_neighbors(issue2, results_set=issues) diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index 1f3f0f1e..13e641c3 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -23,7 +23,6 @@ from unittest.mock import MagicMock, patch from django.core.urlresolvers import reverse from django.apps import apps from .. import factories as f -from .. utils import set_settings from taiga.projects.notifications import services from taiga.projects.notifications import models @@ -102,11 +101,16 @@ def test_analize_object_for_watchers(): def test_users_to_notify(): project = f.ProjectFactory.create() - issue = f.IssueFactory.create(project=project) + role1 = f.RoleFactory.create(project=project, permissions=['view_issues']) + role2 = f.RoleFactory.create(project=project, permissions=[]) - member1 = f.MembershipFactory.create(project=project) - member2 = f.MembershipFactory.create(project=project) - member3 = f.MembershipFactory.create(project=project) + member1 = f.MembershipFactory.create(project=project, role=role1) + member2 = f.MembershipFactory.create(project=project, role=role1) + member3 = f.MembershipFactory.create(project=project, role=role1) + member4 = f.MembershipFactory.create(project=project, role=role1) + member5 = f.MembershipFactory.create(project=project, role=role2) + + issue = f.IssueFactory.create(project=project, owner=member4.user) policy_model_cls = apps.get_model("notifications", "NotifyPolicy") @@ -148,29 +152,41 @@ def test_users_to_notify(): assert len(users) == 2 assert users == {member1.user, issue.get_owner()} -@set_settings(CHANGE_NOTIFICATIONS_MIN_INTERVAL=1) -def test_send_notifications_using_services_method(mail): + # Test with watchers without permissions + issue.watchers.add(member5.user) + users = services.get_users_to_notify(issue) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + +def test_send_notifications_using_services_method(settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + project = f.ProjectFactory.create() - member1 = f.MembershipFactory.create(project=project) - member2 = f.MembershipFactory.create(project=project) + role = f.RoleFactory.create(project=project, permissions=['view_issues', 'view_us', 'view_tasks', 'view_wiki_pages']) + member1 = f.MembershipFactory.create(project=project, role=role) + member2 = f.MembershipFactory.create(project=project, role=role) history_change = MagicMock() history_change.user = {"pk": member1.user.pk} history_change.comment = "" history_change.type = HistoryType.change + history_change.is_hidden = False history_create = MagicMock() history_create.user = {"pk": member1.user.pk} history_create.comment = "" history_create.type = HistoryType.create + history_create.is_hidden = False history_delete = MagicMock() history_delete.user = {"pk": member1.user.pk} history_delete.comment = "" history_delete.type = HistoryType.delete + history_delete.is_hidden = False # Issues - issue = f.IssueFactory.create(project=project) + issue = f.IssueFactory.create(project=project, owner=member2.user) take_snapshot(issue) services.send_notifications(issue, history=history_create) @@ -183,7 +199,7 @@ def test_send_notifications_using_services_method(mail): # Userstories - us = f.UserStoryFactory.create() + us = f.UserStoryFactory.create(project=project, owner=member2.user) take_snapshot(us) services.send_notifications(us, history=history_create) @@ -195,7 +211,7 @@ def test_send_notifications_using_services_method(mail): history=history_delete) # Tasks - task = f.TaskFactory.create() + task = f.TaskFactory.create(project=project, owner=member2.user) take_snapshot(task) services.send_notifications(task, history=history_create) @@ -207,7 +223,7 @@ def test_send_notifications_using_services_method(mail): history=history_delete) # Wiki pages - wiki = f.WikiPageFactory.create() + wiki = f.WikiPageFactory.create(project=project, owner=member2.user) take_snapshot(wiki) services.send_notifications(wiki, history=history_create) @@ -224,13 +240,14 @@ def test_send_notifications_using_services_method(mail): services.process_sync_notifications() assert len(mail.outbox) == 12 -@set_settings(CHANGE_NOTIFICATIONS_MIN_INTERVAL=1) -def test_resource_notification_test(client, mail): +def test_resource_notification_test(client, settings, mail): + settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 + user1 = f.UserFactory.create() user2 = f.UserFactory.create() project = f.ProjectFactory.create(owner=user1) - role = f.RoleFactory.create(project=project) - member1 = f.MembershipFactory.create(project=project, user=user1, role=role) + role = f.RoleFactory.create(project=project, permissions=["view_issues"]) + member1 = f.MembershipFactory.create(project=project, user=user1, role=role, is_owner=True) member2 = f.MembershipFactory.create(project=project, user=user2, role=role) issue = f.IssueFactory.create(owner=user2, project=project) @@ -268,7 +285,7 @@ def test_watchers_assignation_for_issue(client): project2 = f.ProjectFactory.create(owner=user2) role1 = f.RoleFactory.create(project=project1) role2 = f.RoleFactory.create(project=project2) - member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1) + member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) @@ -321,7 +338,7 @@ def test_watchers_assignation_for_task(client): project2 = f.ProjectFactory.create(owner=user2) role1 = f.RoleFactory.create(project=project1) role2 = f.RoleFactory.create(project=project2) - member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1) + member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) @@ -374,7 +391,7 @@ def test_watchers_assignation_for_us(client): project2 = f.ProjectFactory.create(owner=user2) role1 = f.RoleFactory.create(project=project1) role2 = f.RoleFactory.create(project=project2) - member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1) + member1 = f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) diff --git a/tests/integration/test_occ.py b/tests/integration/test_occ.py index eb25eef8..4cd408b6 100644 --- a/tests/integration/test_occ.py +++ b/tests/integration/test_occ.py @@ -30,82 +30,10 @@ from .. import factories as f pytestmark = pytest.mark.django_db -def test_invalid_concurrent_save_for_issue(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - issue = f.IssueFactory.create(version=10, project=project) - - client.login(user) - - mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" - with patch(mock_path) as m: - url = reverse("issues-detail", args=(issue.id,)) - data = {} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert response.status_code == 400 - -def test_valid_concurrent_save_for_issue(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - issue = f.IssueFactory.create(version=10, project=project) - - client.login(user) - - mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" - with patch(mock_path) as m: - url = reverse("issues-detail", args=(issue.id,)) - data = {"version": 10} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content)['version'] == 11 - assert response.status_code == 200 - issue = Issue.objects.get(id=issue.id) - assert issue.version == 11 - -def test_invalid_concurrent_save_for_wiki_page(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - wiki_page = f.WikiPageFactory.create(version=10, project=project, owner=user) - client.login(user) - - url = reverse("wiki-detail", args=(wiki_page.id,)) - data = {} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert response.status_code == 400 - -def test_valid_concurrent_save_for_wiki_page(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - wiki_page = f.WikiPageFactory.create(version=10, project=project, owner=user) - client.login(user) - - url = reverse("wiki-detail", args=(wiki_page.id,)) - data = {"version": 10} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content)['version'] == 11 - assert response.status_code == 200 - wiki_page = WikiPage.objects.get(id=wiki_page.id) - assert wiki_page.version == 11 - -def test_invalid_concurrent_save_for_us(client): - user = f.UserFactory.create() - project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - userstory = f.UserStoryFactory.create(version=10, project=project) - client.login(user) - - url = reverse("userstories-detail", args=(userstory.id,)) - data = {} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert response.status_code == 400 - def test_valid_us_creation(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) client.login(user) @@ -118,48 +46,294 @@ def test_valid_us_creation(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 201 -def test_valid_concurrent_save_for_us(client): + +def test_invalid_concurrent_save_for_issue(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = json.loads(response.content)["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_issue_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = json.loads(response.content)["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_issue_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("issues-list") + data = {"subject": "test", + "project": project.id, + "status": f.IssueStatusFactory.create(project=project).id, + "severity": f.SeverityFactory.create(project=project).id, + "type": f.IssueTypeFactory.create(project=project).id, + "priority": f.PriorityFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + issue_id = json.loads(response.content)["id"] + url = reverse("issues-detail", args=(issue_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "description": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_invalid_concurrent_save_for_wiki_page(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.wiki.api.WikiViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("wiki-list") + data = {"project": project.id, "slug": "test"} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + wiki_id = json.loads(response.content)["id"] + url = reverse("wiki-detail", args=(wiki_id,)) + data = {"version":1, "content": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "content": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_wiki_page_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.wiki.api.WikiViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("wiki-list") + data = {"project": project.id, "slug": "test"} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201, response.content + + wiki_id = json.loads(response.content)["id"] + url = reverse("wiki-detail", args=(wiki_id,)) + data = {"version":1, "content": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":2, "content": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_invalid_concurrent_save_for_us(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) userstory = f.UserStoryFactory.create(version=10, project=project) client.login(user) - url = reverse("userstories-detail", args=(userstory.id,)) - data = {"version": 10} - response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content)['version'] == 11 - assert response.status_code == 200 - userstory = UserStory.objects.get(id=userstory.id) - assert userstory.version == 11 + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = json.loads(response.content)["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 400 + + +def test_valid_concurrent_save_for_us_different_versions(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = json.loads(response.content)["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_us_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("userstories-list") + data = {"subject": "test", + "project": project.id, + "status": f.UserStoryStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + userstory_id = json.loads(response.content)["id"] + url = reverse("userstories-detail", args=(userstory_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "description": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + def test_invalid_concurrent_save_for_task(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - task = f.TaskFactory.create(version=10, project=project) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) client.login(user) mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" with patch(mock_path) as m: - url = reverse("tasks-detail", args=(task.id,)) - data = {} + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = json.loads(response.content)["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 -def test_valid_concurrent_save_for_task(client): + +def test_valid_concurrent_save_for_task_different_versions(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user) - task = f.TaskFactory.create(version=10, project=project) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) client.login(user) mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" with patch(mock_path) as m: - url = reverse("tasks-detail", args=(task.id,)) - data = {"version": 10} + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = json.loads(response.content)["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":2, "subject": "test 2"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + +def test_valid_concurrent_save_for_task_different_fields(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + client.login(user) + + mock_path = "taiga.projects.tasks.api.TaskViewSet.pre_conditions_on_save" + with patch(mock_path) as m: + url = reverse("tasks-list") + data = {"subject": "test", + "project": project.id, + "status": f.TaskStatusFactory.create(project=project).id} + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + + task_id = json.loads(response.content)["id"] + url = reverse("tasks-detail", args=(task_id,)) + data = {"version":1, "subject": "test 1"} + response = client.patch(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + + data = {"version":1, "description": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") - assert json.loads(response.content)['version'] == 11 assert response.status_code == 200 - task = Task.objects.get(id=task.id) - assert task.version == 11 diff --git a/tests/integration/test_permissions.py b/tests/integration/test_permissions.py index 4e337e43..aabfc34a 100644 --- a/tests/integration/test_permissions.py +++ b/tests/integration/test_permissions.py @@ -53,9 +53,7 @@ def test_owner_get_user_project_permissions(): factories.MembershipFactory(user=user1, project=project, role=role) expected_perms = set( - ["test1", "test2"] + - [x[0] for x in permissions.OWNERS_PERMISSIONS] + - [x[0] for x in permissions.MEMBERS_PERMISSIONS] + ["test1", "test2", "view_us"] ) assert service.get_user_project_permissions(user1, project) == expected_perms @@ -70,7 +68,8 @@ def test_owner_member_get_user_project_permissions(): expected_perms = set( ["test1", "test2", "test3"] + - [x[0] for x in permissions.OWNERS_PERMISSIONS] + [x[0] for x in permissions.OWNERS_PERMISSIONS] + + [x[0] for x in permissions.MEMBERS_PERMISSIONS] ) assert service.get_user_project_permissions(user1, project) == expected_perms diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 7178362c..941b3a47 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -1,5 +1,7 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json +from taiga.projects.services import stats as stats_services +from taiga.projects.history.services import take_snapshot from .. import factories as f @@ -7,6 +9,18 @@ import pytest pytestmark = pytest.mark.django_db +def test_get_project_by_slug(client): + project = f.create_project() + url = reverse("projects-detail", kwargs={"pk": project.slug}) + + response = client.json.get(url) + assert response.status_code == 404 + + client.login(project.owner) + response = client.json.get(url) + assert response.status_code == 404 + + def test_create_project(client): user = f.create_user() url = reverse("projects-list") @@ -20,6 +34,7 @@ def test_create_project(client): def test_partially_update_project(client): project = f.create_project() + f.MembershipFactory(user=project.owner, project=project, is_owner=True) url = reverse("projects-detail", kwargs={"pk": project.pk}) data = {"name": ""} @@ -30,6 +45,7 @@ def test_partially_update_project(client): def test_us_status_slug_generation(client): us_status = f.UserStoryStatusFactory(name="NEW") + f.MembershipFactory(user=us_status.project.owner, project=us_status.project, is_owner=True) assert us_status.slug == "new" client.login(us_status.project.owner) @@ -49,6 +65,7 @@ def test_us_status_slug_generation(client): def test_task_status_slug_generation(client): task_status = f.TaskStatusFactory(name="NEW") + f.MembershipFactory(user=task_status.project.owner, project=task_status.project, is_owner=True) assert task_status.slug == "new" client.login(task_status.project.owner) @@ -68,6 +85,7 @@ def test_task_status_slug_generation(client): def test_issue_status_slug_generation(client): issue_status = f.IssueStatusFactory(name="NEW") + f.MembershipFactory(user=issue_status.project.owner, project=issue_status.project, is_owner=True) assert issue_status.slug == "new" client.login(issue_status.project.owner) @@ -84,10 +102,11 @@ def test_issue_status_slug_generation(client): assert response.status_code == 200 assert response.data["slug"] == "new-status" + def test_points_name_duplicated(client): point_1 = f.PointsFactory() point_2 = f.PointsFactory(project=point_1.project) - + f.MembershipFactory(user=point_1.project.owner, project=point_1.project, is_owner=True) client.login(point_1.project.owner) url = reverse("points-detail", kwargs={"pk": point_2.pk}) @@ -96,9 +115,123 @@ def test_points_name_duplicated(client): assert response.status_code == 400 assert response.data["name"][0] == "Name duplicated for the project" + def test_update_points_when_not_null_values_for_points(client): points = f.PointsFactory(name="?", value="6") role = f.RoleFactory(project=points.project, computable=True) assert points.project.points.filter(value__isnull=True).count() == 0 points.project.update_role_points() assert points.project.points.filter(value__isnull=True).count() == 1 + + +def test_get_closed_bugs_per_member_stats(): + project = f.ProjectFactory() + membership_1 = f.MembershipFactory(project=project) + membership_2 = f.MembershipFactory(project=project) + issue_closed_status = f.IssueStatusFactory(is_closed=True, project=project) + issue_open_status = f.IssueStatusFactory(is_closed=False, project=project) + issue_closed = f.IssueFactory(project=project, + status=issue_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + issue_open = f.IssueFactory(project=project, + status=issue_open_status, + owner=membership_2.user, + assigned_to=membership_2.user) + task_closed_status = f.TaskStatusFactory(is_closed=True, project=project) + task_open_status = f.TaskStatusFactory(is_closed=False, project=project) + task_closed = f.TaskFactory(project=project, + status=task_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + task_open = f.TaskFactory(project=project, + status=task_open_status, + owner=membership_2.user, + assigned_to=membership_2.user) + task_iocaine = f.TaskFactory(project=project, + status=task_open_status, + owner=membership_2.user, + assigned_to=membership_2.user, + is_iocaine=True) + + wiki_page = f.WikiPageFactory.create(project=project, owner=membership_1.user) + take_snapshot(wiki_page, user=membership_1.user) + wiki_page.content="Frontend, future" + wiki_page.save() + take_snapshot(wiki_page, user=membership_1.user) + + stats = stats_services.get_member_stats_for_project(project) + + assert stats["closed_bugs"][membership_1.user.id] == 1 + assert stats["closed_bugs"][membership_2.user.id] == 0 + + assert stats["iocaine_tasks"][membership_1.user.id] == 0 + assert stats["iocaine_tasks"][membership_2.user.id] == 1 + + assert stats["wiki_changes"][membership_1.user.id] == 2 + assert stats["wiki_changes"][membership_2.user.id] == 0 + + assert stats["created_bugs"][membership_1.user.id] == 1 + assert stats["created_bugs"][membership_2.user.id] == 1 + + assert stats["closed_tasks"][membership_1.user.id] == 1 + assert stats["closed_tasks"][membership_2.user.id] == 0 + + +def test_leave_project_valid_membership(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role) + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 200 + + +def test_leave_project_valid_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 403 + assert json.loads(response.content)["_error_message"] == "You can't leave the project if there are no more owners" + + +def test_leave_project_invalid_membership(client): + user = f.UserFactory.create() + project = f.ProjectFactory() + client.login(user) + url = reverse("projects-leave", args=(project.id,)) + response = client.post(url) + assert response.status_code == 404 + + +def test_delete_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + client.login(user) + url = reverse("memberships-detail", args=(membership.id,)) + response = client.delete(url) + assert response.status_code == 400 + assert json.loads(response.content)["_error_message"] == "At least one of the user must be an active admin" + + +def test_edit_membership_only_owner(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create() + role = f.RoleFactory.create(project=project, permissions=["view_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + data = { + "is_owner": False + } + client.login(user) + url = reverse("memberships-detail", args=(membership.id,)) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + assert response.data["is_owner"][0] == "At least one of the user must be an active admin" diff --git a/tests/integration/test_roles.py b/tests/integration/test_roles.py index e8bd89fa..923da195 100644 --- a/tests/integration/test_roles.py +++ b/tests/integration/test_roles.py @@ -39,7 +39,7 @@ def test_destroy_role_and_reassign_members(client): project = f.ProjectFactory.create(owner=user1) role1 = f.RoleFactory.create(project=project) role2 = f.RoleFactory.create(project=project) - member = f.MembershipFactory.create(project=project, user=user1, role=role1) + member = f.MembershipFactory.create(project=project, user=user1, role=role1, is_owner=True) member = f.MembershipFactory.create(project=project, user=user2, role=role2) url = reverse("roles-detail", args=[role2.pk]) + "?moveTo={}".format(role1.pk) diff --git a/tests/integration/test_stars.py b/tests/integration/test_stars.py index 27293eb1..eddb03a5 100644 --- a/tests/integration/test_stars.py +++ b/tests/integration/test_stars.py @@ -26,6 +26,7 @@ pytestmark = pytest.mark.django_db def test_project_owner_star_project(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, is_owner=True, user=user) url = reverse("projects-star", args=(project.id,)) client.login(user) @@ -37,6 +38,7 @@ def test_project_owner_star_project(client): def test_project_owner_unstar_project(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, is_owner=True, user=user) url = reverse("projects-unstar", args=(project.id,)) client.login(user) @@ -74,6 +76,7 @@ def test_project_member_unstar_project(client): def test_list_project_fans(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) fan = f.VoteFactory.create(content_object=project) url = reverse("projects-fans", args=(project.id,)) @@ -100,6 +103,7 @@ def test_list_user_starred_projects(client): def test_get_project_stars(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) url = reverse("projects-detail", args=(project.id,)) f.VotesFactory.create(content_object=project, count=5) f.VotesFactory.create(count=3) diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py deleted file mode 100644 index fa0e1af9..00000000 --- a/tests/integration/test_tags.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest -pytestmark = pytest.mark.django_db - -from taiga.base import tags -from tests.models import TaggedModel - - -def test_tags(): - tags1 = TaggedModel.objects.create(tags=["foo", "bar"]) - tags2 = TaggedModel.objects.create(tags=["foo"]) - - assert list(tags.filter(TaggedModel, contains=["foo"])) == [tags1, tags2] - assert list(tags.filter(TaggedModel, contained_by=["foo"])) == [tags2] - assert list(tags.filter(TaggedModel, overlap=["bar"])) == [tags1] - - assert list(tags.filter(TaggedModel, len=2)) == [tags1] - assert list(tags.filter(TaggedModel, len__gte=1)) == [tags1, tags2] - assert list(tags.filter(TaggedModel, len__lt=2)) == [tags2] - - assert list(tags.filter(TaggedModel, index__1="bar")) == [tags1] - assert list(tags.filter(TaggedModel, index__1="bar", id__isnull=False)) == [tags1] diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 105541ac..924e8e99 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -36,6 +36,7 @@ Task #2 def test_api_update_task_tags(client): task = f.create_task() + f.MembershipFactory.create(project=task.project, user=task.owner, is_owner=True) url = reverse("tasks-detail", kwargs={"pk": task.pk}) data = {"tags": ["back", "front"], "version": task.version} @@ -47,6 +48,7 @@ def test_api_update_task_tags(client): def test_api_create_in_bulk_with_status(client): us = f.create_userstory() + f.MembershipFactory.create(project=us.project, user=us.owner, is_owner=True) us.project.default_task_status = f.TaskStatusFactory.create(project=us.project) url = reverse("tasks-bulk-create") data = { @@ -69,6 +71,7 @@ def test_api_create_invalid_task(client): # But the User Story is not associated with the milestone us_milestone = f.MilestoneFactory.create() us = f.create_userstory(milestone=us_milestone) + f.MembershipFactory.create(project=us.project, user=us.owner, is_owner=True) us.project.default_task_status = f.TaskStatusFactory.create(project=us.project) task_milestone = f.MilestoneFactory.create(project=us.project, owner=us.owner) @@ -84,3 +87,27 @@ def test_api_create_invalid_task(client): client.login(us.owner) response = client.json.post(url, json.dumps(data)) assert response.status_code == 400 + + +def test_api_update_order_in_bulk(client): + project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + task1 = f.create_task(project=project) + task2 = f.create_task(project=project) + + url1 = reverse("tasks-bulk-update-taskboard-order") + url2 = reverse("tasks-bulk-update-us-order") + + data = { + "project_id": project.id, + "bulk_tasks": [{"task_id": task1.id, "order": 1}, + {"task_id": task2.id, "order": 2}] + } + + client.login(project.owner) + + response1 = client.json.post(url1, json.dumps(data)) + response2 = client.json.post(url2, json.dumps(data)) + + assert response1.status_code == 204, response1.data + assert response2.status_code == 204, response2.data diff --git a/tests/integration/test_throwttling.py b/tests/integration/test_throwttling.py new file mode 100644 index 00000000..4772bb28 --- /dev/null +++ b/tests/integration/test_throwttling.py @@ -0,0 +1,124 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + +import pytest +from unittest import mock + +from django.core.urlresolvers import reverse +from django.core.cache import cache + +from taiga.base.utils import json + +from .. import factories as f + +pytestmark = pytest.mark.django_db + + +anon_rate_path = "taiga.base.throttling.AnonRateThrottle.get_rate" +user_rate_path = "taiga.base.throttling.UserRateThrottle.get_rate" +import_rate_path = "taiga.export_import.throttling.ImportModeRateThrottle.get_rate" + + +def test_anonimous_throttling_policy(client, settings): + project = f.create_project() + url = reverse("projects-list") + + with mock.patch(anon_rate_path) as anon_rate, \ + mock.patch(user_rate_path) as user_rate, \ + mock.patch(import_rate_path) as import_rate: + anon_rate.return_value = "2/day" + user_rate.return_value = "4/day" + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 429 + + +def test_user_throttling_policy(client, settings): + project = f.create_project() + membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + + client.login(project.owner) + + with mock.patch(anon_rate_path) as anon_rate, \ + mock.patch(user_rate_path) as user_rate, \ + mock.patch(import_rate_path) as import_rate: + anon_rate.return_value = "2/day" + user_rate.return_value = "4/day" + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 200 + response = client.json.get(url) + assert response.status_code == 429 + + client.logout() + + +def test_import_mode_throttling_policy(client, settings): + project = f.create_project() + membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + project.default_issue_type = f.IssueTypeFactory.create(project=project) + project.default_issue_status = f.IssueStatusFactory.create(project=project) + project.default_severity = f.SeverityFactory.create(project=project) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test" + } + + client.login(project.owner) + + with mock.patch(anon_rate_path) as anon_rate, \ + mock.patch(user_rate_path) as user_rate, \ + mock.patch(import_rate_path) as import_rate: + anon_rate.return_value = "2/day" + user_rate.return_value = "4/day" + import_rate.return_value = "7/day" + + cache.clear() + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 429 + + client.logout() diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index 83c35c65..caad1435 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -43,7 +43,8 @@ def test_update_userstories_order_in_bulk(): def test_api_delete_userstory(client): - us = f.create_userstory() + us = f.UserStoryFactory.create() + f.MembershipFactory.create(project=us.project, user=us.owner, is_owner=True) url = reverse("userstories-detail", kwargs={"pk": us.pk}) client.login(us.owner) @@ -52,12 +53,16 @@ def test_api_delete_userstory(client): assert response.status_code == 204 -def test_api_filter_by_subject(client): - f.create_userstory() - us = f.create_userstory(subject="some random subject") - url = reverse("userstories-list") + "?subject=some subject" +def test_api_filter_by_subject_or_ref(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) - client.login(us.owner) + f.UserStoryFactory.create(project=project) + f.UserStoryFactory.create(project=project, subject="some random subject") + url = reverse("userstories-list") + "?q=some subject" + + client.login(project.owner) response = client.get(url) number_of_stories = len(response.data) @@ -67,6 +72,7 @@ def test_api_filter_by_subject(client): def test_api_create_in_bulk_with_status(client): project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) url = reverse("userstories-bulk-create") data = { "bulk_stories": "Story #1\nStory #2", @@ -83,6 +89,7 @@ def test_api_create_in_bulk_with_status(client): def test_api_update_backlog_order_in_bulk(client): project = f.create_project() + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) us1 = f.create_userstory(project=project) us2 = f.create_userstory(project=project) @@ -102,9 +109,9 @@ def test_api_update_backlog_order_in_bulk(client): response2 = client.json.post(url2, json.dumps(data)) response3 = client.json.post(url3, json.dumps(data)) - assert response1.status_code == 204, response.data - assert response2.status_code == 204, response.data - assert response3.status_code == 204, response.data + assert response1.status_code == 204, response1.data + assert response2.status_code == 204, response2.data + assert response3.status_code == 204, response3.data from taiga.projects.userstories.serializers import UserStorySerializer @@ -118,7 +125,7 @@ def test_update_userstory_points(client): role1 = f.RoleFactory.create(project=project) role2 = f.RoleFactory.create(project=project) - member = f.MembershipFactory.create(project=project, user=user1, role=role1) + member = f.MembershipFactory.create(project=project, user=user1, role=role1, is_owner=True) member = f.MembershipFactory.create(project=project, user=user2, role=role2) points1 = f.PointsFactory.create(project=project, value=None) @@ -142,7 +149,7 @@ def test_update_userstory_points(client): # Api should save successful data = {} - data["version"] = usdata["version"] + data["version"] = usdata["version"] + 1 data["points"] = copy.copy(usdata["points"]) data["points"].update({str(role1.pk):points3.pk}) @@ -181,7 +188,7 @@ def test_update_userstory_rolepoints_on_add_new_role(client): def test_archived_filter(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - f.MembershipFactory.create(project=project, user=user) + f.MembershipFactory.create(project=project, user=user, is_owner=True) f.UserStoryFactory.create(project=project) f.UserStoryFactory.create(is_archived=True, project=project) diff --git a/tests/integration/test_vote_issues.py b/tests/integration/test_vote_issues.py index c1659e6a..390dfcaa 100644 --- a/tests/integration/test_vote_issues.py +++ b/tests/integration/test_vote_issues.py @@ -27,6 +27,7 @@ pytestmark = pytest.mark.django_db def test_upvote_issue(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) url = reverse("issues-upvote", args=(issue.id,)) client.login(user) @@ -38,6 +39,7 @@ def test_upvote_issue(client): def test_downvote_issue(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) url = reverse("issues-downvote", args=(issue.id,)) client.login(user) @@ -49,6 +51,7 @@ def test_downvote_issue(client): def test_list_issue_voters(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) url = reverse("issue-voters-list", args=(issue.id,)) f.VoteFactory.create(content_object=issue, user=user) @@ -62,6 +65,7 @@ def test_list_issue_voters(client): def test_get_issue_voter(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) vote = f.VoteFactory.create(content_object=issue, user=user) url = reverse("issue-voters-detail", args=(issue.id, vote.user.id)) @@ -75,6 +79,7 @@ def test_get_issue_voter(client): def test_get_issue_votes(client): user = f.UserFactory.create() issue = f.create_issue(owner=user) + f.MembershipFactory.create(project=issue.project, user=user, is_owner=True) url = reverse("issues-detail", args=(issue.id,)) f.VotesFactory.create(content_object=issue, count=5) diff --git a/tests/unit/test_base_api_permissions.py b/tests/unit/test_base_api_permissions.py index 8fd74f66..fb792c4a 100644 --- a/tests/unit/test_base_api_permissions.py +++ b/tests/unit/test_base_api_permissions.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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.permissions import (PermissionComponent, AllowAny as TruePermissionComponent, DenyAll as FalsePermissionComponent) diff --git a/tests/unit/test_deferred.py b/tests/unit/test_deferred.py index 131a1b6e..7417fc2c 100644 --- a/tests/unit/test_deferred.py +++ b/tests/unit/test_deferred.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 unittest import mock from taiga import celery diff --git a/tests/unit/test_gravatar.py b/tests/unit/test_gravatar.py index b5e1aa4a..d91a90b2 100644 --- a/tests/unit/test_gravatar.py +++ b/tests/unit/test_gravatar.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 . + import hashlib from taiga.users.gravatar import get_gravatar_url diff --git a/tests/unit/test_mdrender.py b/tests/unit/test_mdrender.py index 81679f88..a31b749c 100644 --- a/tests/unit/test_mdrender.py +++ b/tests/unit/test_mdrender.py @@ -43,7 +43,7 @@ def test_proccessor_valid_us_reference(): instance.content_type.model = "userstory" instance.content_object.subject = "test" result = render(dummy_project, "**#1**") - expected_result = '

#1

' + expected_result = '

#1

' assert result == expected_result @@ -53,7 +53,7 @@ def test_proccessor_valid_issue_reference(): instance.content_type.model = "issue" instance.content_object.subject = "test" result = render(dummy_project, "**#2**") - expected_result = '

#2

' + expected_result = '

#2

' assert result == expected_result @@ -63,7 +63,7 @@ def test_proccessor_valid_task_reference(): instance.content_type.model = "task" instance.content_object.subject = "test" result = render(dummy_project, "**#3**") - expected_result = '

#3

' + expected_result = '

#3

' assert result == expected_result @@ -97,24 +97,26 @@ def test_render_wiki_strike(): assert render(dummy_project, "~~test~~") == "

test

" -def test_render_absolute_link(): - assert render(dummy_project, "[test](/test)") == "

test

" - - -def test_render_relative_link(): - assert render(dummy_project, "[test](test)") == "

test

" - - def test_render_wikilink(): - expected_result = "

test

" + expected_result = "

test

" assert render(dummy_project, "[[test]]") == expected_result def test_render_wikilink_with_custom_title(): - expected_result = "

custom

" + expected_result = "

custom

" assert render(dummy_project, "[[test|custom]]") == expected_result +def test_render_wikilink_slug_to_wikipages(): + expected_result = "

wiki

" + assert render(dummy_project, "[wiki](wiki_page \"wiki page\")") == expected_result + + +def test_render_wikilink_relative_to_absolute(): + expected_result = "

test project

" + assert render(dummy_project, "[test project](/project/test/)") == expected_result + + def test_render_reference_links(): expected_result = "

An example of reference link

" source = "An [example][id] of reference link\n [id]: http://example.com/ \"Title\"" diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py index 025a4bc6..203320f5 100644 --- a/tests/unit/test_permissions.py +++ b/tests/unit/test_permissions.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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.permissions import service from taiga.users.models import Role diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 48e279b8..0bb5922a 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,3 +1,19 @@ +# Copyright (C) 2014 Andrey Antukh +# Copyright (C) 2014 Jesús Espino +# Copyright (C) 2014 David Barragán +# 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 unittest import mock import django_sites as sites diff --git a/tests/utils.py b/tests/utils.py index f326505e..6a437129 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -17,7 +17,6 @@ import functools import json -from django.conf import settings from django.db.models import signals @@ -39,55 +38,6 @@ def signals_switch(): disconnect_signals, reconnect_signals = signals_switch() -def set_settings(**new_settings): - """Decorator for set django settings that will be only available during the - wrapped-function execution. - - For example: - @set_settings(FOO='bar') - def myfunc(): - ... - - @set_settings(FOO='bar') - class TestCase: - ... - """ - def decorator(testcase): - if type(testcase) is type: - namespace = {"OVERRIDE_SETTINGS": new_settings, "ORIGINAL_SETTINGS": {}} - wrapper = type(testcase.__name__, (SettingsTestCase, testcase), namespace) - else: - @functools.wraps(testcase) - def wrapper(*args, **kwargs): - old_settings = override_settings(new_settings) - try: - testcase(*args, **kwargs) - finally: - override_settings(old_settings) - - return wrapper - - return decorator - - -def override_settings(new_settings): - old_settings = {} - for name, new_value in new_settings.items(): - old_settings[name] = getattr(settings, name, None) - setattr(settings, name, new_value) - return old_settings - - -class SettingsTestCase(object): - @classmethod - def setup_class(cls): - cls.ORIGINAL_SETTINGS = override_settings(cls.OVERRIDE_SETTINGS) - - @classmethod - def teardown_class(cls): - override_settings(cls.ORIGINAL_SETTINGS) - cls.OVERRIDE_SETTINGS.clear() - def _helper_test_http_method_responses(client, method, url, data, users, after_each_request=None, content_type="application/json"): results = []