diff --git a/.gitignore b/.gitignore index c6e43b78..ed310d39 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ media .cache .\#* .project +.env diff --git a/AUTHORS.rst b/AUTHORS.rst index 85e47ad0..832d28ff 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -4,6 +4,7 @@ The PRIMARY AUTHORS are: - Jesus Espino Garcia - David Barragán Merino - Alejandro Alonso +- Xavi Julian - Anler Hernández Special thanks to Kaleidos Open Source S.L. for provice time for taiga @@ -13,4 +14,13 @@ And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- people who have submitted patches, reported bugs, added translations, helped answer newbie questions, and generally made taiga that much better: -- ... +- Andrés Moya +- Yamila Moreno +- Ricky Posner +- Alonso Torres +- Alejandro Gómez +- Andrea Stagi +- Hector Colina +- Julien Palard +- Joe Letts + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba4dbd7..92b27815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog # + +## 1.6.0 Abies Bifolia (2015-03-17) + + +### Features +- Added custom fields per project for user stories, tasks and issues. +- Support of export to CSV user stories, tasks and issues. +- Allow public projects. + +### Misc +- New contrib plugin for HipChat (by Δndrea Stagi). +- Lots of small and not so small bugfixes. +- Updated some requirements. + + ## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29) ### Features diff --git a/README.md b/README.md index be47ccdd..7969f798 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # Taiga Backend # ![Kaleidos Project](http://kaleidos.net/static/img/badge.png "Kaleidos Project") - -[![Travis Badge](https://img.shields.io/travis/taigaio/taiga-back.svg?style=flat)](https://travis-ci.org/taigaio/taiga-back "Travis Badge") - -[![Coveralls](http://img.shields.io/coveralls/taigaio/taiga-back.svg?style=flat)](https://coveralls.io/r/taigaio/taiga-back?branch=master "Coveralls") +[![Managed with Taiga.io](https://taiga.io/media/support/attachments/article-22/banner-gh.png)](https://taiga.io "Managed with Taiga.io") +[![Build Status](https://travis-ci.org/taigaio/taiga-back.svg?branch=master)](https://travis-ci.org/taigaio/taiga-back "Build Status") +[![Coverage Status](https://coveralls.io/repos/taigaio/taiga-back/badge.svg?branch=master)](https://coveralls.io/r/taigaio/taiga-back?branch=master "Coverage Status") ## Setup development environment ## @@ -19,7 +18,7 @@ python manage.py loaddata initial_role python manage.py sample_data ``` -Taiga only runs with python 3.4+ +**IMPORTANT: Taiga only runs with python 3.4+** Initial auth data: admin/123123 diff --git a/requirements-devel.txt b/requirements-devel.txt index 908c4456..da456ec3 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -1,11 +1,13 @@ -r requirements.txt factory_boy==2.4.1 -py==1.4.23 -pytest==2.6.1 -pytest-django==2.6.2 -pytest-pythonpath==0.3 +py==1.4.26 +pytest==2.6.4 +pytest-django==2.8.0 +pytest-pythonpath==0.6 coverage==3.7.1 coveralls==0.4.2 django-slowdown==0.0.1 + +taiga-contrib-github-auth==0.0.3 diff --git a/requirements.txt b/requirements.txt index c13ab2c5..e4afc973 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ djangorestframework==2.3.13 -Django==1.7 +Django==1.7.6 django-picklefield==0.3.1 django-sampledatahelper==0.2.2 gunicorn==19.1.1 @@ -9,7 +9,7 @@ pytz==2014.4 six==1.8.0 amqp==1.4.6 djmail==0.9 -django-pgjson==0.2.0 +django-pgjson==0.2.2 djorm-pgarray==1.0.4 django-jinja==1.0.4 jinja2==2.7.2 @@ -30,6 +30,7 @@ django-ipware==0.1.0 premailer==2.8.1 django-transactional-cleanup==0.1.14 lxml==3.4.1 +git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea # Comment it if you are using python >= 3.4 enum34==1.0 diff --git a/settings/common.py b/settings/common.py index f6ec3a8a..dc8d2b9d 100644 --- a/settings/common.py +++ b/settings/common.py @@ -180,6 +180,7 @@ INSTALLED_APPS = [ "taiga.userstorage", "taiga.projects", "taiga.projects.references", + "taiga.projects.custom_attributes", "taiga.projects.history", "taiga.projects.notifications", "taiga.projects.attachments", @@ -282,13 +283,6 @@ AUTHENTICATION_BACKENDS = ( MAX_AGE_AUTH_TOKEN = None MAX_AGE_CANCEL_ACCOUNT = 30 * 24 * 60 * 60 # 30 days in seconds -ANONYMOUS_USER_ID = -1 - -MAX_SEARCH_RESULTS = 100 - -# FIXME: this seems not be used by any module -API_LIMIT_PER_PAGE = 0 - REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( # Mainly used by taiga-front diff --git a/settings/local.py.example b/settings/local.py.example index 947e0ee7..58f221bb 100644 --- a/settings/local.py.example +++ b/settings/local.py.example @@ -18,7 +18,7 @@ from .development import * #DATABASES = { # 'default': { -# 'ENGINE': 'django.db.backends.postgresql_psycopg2', +# 'ENGINE': 'transaction_hooks.backends.postgresql_psycopg2', # 'NAME': 'taiga', # 'USER': 'taiga', # 'PASSWORD': '', diff --git a/settings/testing.py b/settings/testing.py index c20da9eb..9fb6ec74 100644 --- a/settings/testing.py +++ b/settings/testing.py @@ -24,7 +24,9 @@ CELERY_ENABLED = False MEDIA_ROOT = "/tmp" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" -INSTALLED_APPS = INSTALLED_APPS + ["tests"] +INSTALLED_APPS = INSTALLED_APPS + [ + "tests", +] REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { "anon": None, diff --git a/taiga/auth/api.py b/taiga/auth/api.py index b0d0365d..48c1041d 100644 --- a/taiga/auth/api.py +++ b/taiga/auth/api.py @@ -20,15 +20,12 @@ from enum import Enum from django.utils.translation import ugettext_lazy as _ from django.conf import settings -from rest_framework.response import Response -from rest_framework import status from rest_framework import serializers from taiga.base.api import viewsets from taiga.base.decorators import list_route from taiga.base import exceptions as exc -from taiga.base.connectors import github -from taiga.users.services import get_and_validate_user +from taiga.base import response from .serializers import PublicRegisterSerializer from .serializers import PrivateRegisterForExistingUserSerializer @@ -37,8 +34,8 @@ from .serializers import PrivateRegisterForNewUserSerializer from .services import private_register_for_existing_user from .services import private_register_for_new_user from .services import public_register -from .services import github_register from .services import make_auth_response_data +from .services import get_auth_plugins from .permissions import AuthPermission @@ -109,7 +106,7 @@ class AuthViewSet(viewsets.ViewSet): raise exc.BadRequest(e.detail) data = make_auth_response_data(user) - return Response(data, status=status.HTTP_201_CREATED) + return response.Created(data) def _private_register(self, request): register_type = parse_register_type(request.DATA) @@ -122,7 +119,7 @@ class AuthViewSet(viewsets.ViewSet): user = private_register_for_new_user(**data) data = make_auth_response_data(user) - return Response(data, status=status.HTTP_201_CREATED) + return response.Created(data) @list_route(methods=["POST"]) def register(self, request, **kwargs): @@ -135,36 +132,15 @@ class AuthViewSet(viewsets.ViewSet): return self._private_register(request) raise exc.BadRequest(_("invalid register type")) - def _login(self, request): - username = request.DATA.get('username', None) - password = request.DATA.get('password', None) - - user = get_and_validate_user(username=username, password=password) - data = make_auth_response_data(user) - return Response(data, status=status.HTTP_200_OK) - - def _github_login(self, request): - code = request.DATA.get('code', None) - token = request.DATA.get('token', None) - - email, user_info = github.me(code) - - user = github_register(username=user_info.username, - email=email, - full_name=user_info.full_name, - github_id=user_info.id, - bio=user_info.bio, - token=token) - data = make_auth_response_data(user) - return Response(data, status=status.HTTP_200_OK) - # Login view: /api/v1/auth def create(self, request, **kwargs): self.check_permissions(request, 'create', None) + auth_plugins = get_auth_plugins() + + login_type = request.DATA.get("type", None) + + if login_type in auth_plugins: + data = auth_plugins[login_type]['login_func'](request) + return response.Ok(data) - type = request.DATA.get("type", None) - if type == "normal": - return self._login(request) - elif type == "github": - return self._github_login(request) raise exc.BadRequest(_("invalid login type")) diff --git a/taiga/auth/backends.py b/taiga/auth/backends.py index b514f1e7..7f156c08 100644 --- a/taiga/auth/backends.py +++ b/taiga/auth/backends.py @@ -32,7 +32,6 @@ selfcontained tokens. This trust tokes from external fraudulent modifications. """ -import base64 import re from django.conf import settings @@ -40,6 +39,7 @@ from rest_framework.authentication import BaseAuthentication from .tokens import get_user_for_token + class Session(BaseAuthentication): """ Session based authentication like the standard @@ -82,8 +82,8 @@ class Token(BaseAuthentication): token = token_rx_match.group(1) max_age_auth_token = getattr(settings, "MAX_AGE_AUTH_TOKEN", None) user = get_user_for_token(token, "authentication", - max_age=max_age_auth_token) - + max_age=max_age_auth_token) + return (user, token) def authenticate_header(self, request): diff --git a/taiga/auth/services.py b/taiga/auth/services.py index 1c0f64de..72589eac 100644 --- a/taiga/auth/services.py +++ b/taiga/auth/services.py @@ -24,7 +24,6 @@ not uses clasess and uses simple functions. """ from django.apps import apps -from django.db.models import Q from django.db import transaction as tx from django.db import IntegrityError from django.utils.translation import ugettext as _ @@ -32,13 +31,25 @@ from django.utils.translation import ugettext as _ from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail from taiga.base import exceptions as exc -from taiga.users.serializers import UserSerializer +from taiga.users.serializers import UserAdminSerializer from taiga.users.services import get_and_validate_user -from taiga.base.utils.slug import slugify_uniquely from .tokens import get_token_for_user from .signals import user_registered as user_registered_signal +auth_plugins = {} + + +def register_auth_plugin(name, login_func): + auth_plugins[name] = { + "login_func": login_func, + } + + +def get_auth_plugins(): + return auth_plugins + + def send_register_email(user) -> bool: """ Given a user, send register welcome email @@ -169,54 +180,25 @@ def private_register_for_new_user(token:str, username:str, email:str, return user -@tx.atomic -def github_register(username:str, email:str, full_name:str, github_id:int, bio:str, token:str=None): - """ - Register a new user from github. - - This can raise `exc.IntegrityError` exceptions in - case of conflics found. - - :returns: User - """ - user_model = apps.get_model("users", "User") - - try: - # Github user association exist? - user = user_model.objects.get(github_id=github_id) - except user_model.DoesNotExist: - try: - # Is a user with the same email as the github user? - user = user_model.objects.get(email=email) - user.github_id = github_id - user.save(update_fields=["github_id"]) - except user_model.DoesNotExist: - # Create a new user - username_unique = slugify_uniquely(username, user_model, slugfield="username") - user = user_model.objects.create(email=email, - username=username_unique, - github_id=github_id, - full_name=full_name, - bio=bio) - - send_register_email(user) - user_registered_signal.send(sender=user.__class__, user=user) - - if token: - membership = get_membership_by_token(token) - membership.user = user - membership.save(update_fields=["user"]) - - return user - - def make_auth_response_data(user) -> dict: """ Given a domain and user, creates data structure using python dict containing a representation of the logged user. """ - serializer = UserSerializer(user) + serializer = UserAdminSerializer(user) data = dict(serializer.data) data["auth_token"] = get_token_for_user(user, "authentication") return data + + +def normal_login_func(request): + username = request.DATA.get('username', None) + password = request.DATA.get('password', None) + + user = get_and_validate_user(username=username, password=password) + data = make_auth_response_data(user) + return data + + +register_auth_plugin("normal", normal_login_func) diff --git a/taiga/auth/tokens.py b/taiga/auth/tokens.py index 680e70fc..75d36e21 100644 --- a/taiga/auth/tokens.py +++ b/taiga/auth/tokens.py @@ -19,12 +19,13 @@ from taiga.base import exceptions as exc from django.apps import apps from django.core import signing + def get_token_for_user(user, scope): """ Generate a new signed token containing a specified user limited for a scope (identified as a string). """ - data = {"user_%s_id"%(scope): user.id} + data = {"user_%s_id" % (scope): user.id} return signing.dumps(data) @@ -47,7 +48,7 @@ def get_user_for_token(token, scope, max_age=None): model_cls = apps.get_model("users", "User") try: - user = model_cls.objects.get(pk=data["user_%s_id"%(scope)]) + user = model_cls.objects.get(pk=data["user_%s_id" % (scope)]) except (model_cls.DoesNotExist, KeyError): raise exc.NotAuthenticated("Invalid token") else: diff --git a/taiga/base/api/__init__.py b/taiga/base/api/__init__.py index 973f17ef..845821b2 100644 --- a/taiga/base/api/__init__.py +++ b/taiga/base/api/__init__.py @@ -19,10 +19,12 @@ from .viewsets import ModelListViewSet from .viewsets import ModelCrudViewSet +from .viewsets import ModelUpdateRetrieveViewSet from .viewsets import GenericViewSet from .viewsets import ReadOnlyListViewSet __all__ = ["ModelCrudViewSet", "ModelListViewSet", + "ModelUpdateRetrieveViewSet", "GenericViewSet", "ReadOnlyListViewSet"] diff --git a/taiga/base/api/generics.py b/taiga/base/api/generics.py index 752ae201..a5b40c18 100644 --- a/taiga/base/api/generics.py +++ b/taiga/base/api/generics.py @@ -19,13 +19,11 @@ import warnings -from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.core.exceptions import ImproperlyConfigured from django.core.paginator import Paginator, InvalidPage from django.http import Http404 from django.utils.translation import ugettext as _ -from rest_framework import exceptions -from rest_framework.request import clone_request from rest_framework.settings import api_settings from . import views @@ -166,8 +164,8 @@ class GenericAPIView(views.APIView): page = paginator.page(page_number) except InvalidPage as e: raise Http404(_('Invalid page (%(page_number)s): %(message)s') % { - 'page_number': page_number, - 'message': str(e) + 'page_number': page_number, + 'message': str(e) }) if deprecated_style: @@ -193,16 +191,16 @@ class GenericAPIView(views.APIView): """ filter_backends = self.filter_backends or [] if not filter_backends and hasattr(self, 'filter_backend'): - raise RuntimeException('The `filter_backend` attribute and `FILTER_BACKEND` setting ' - 'are due to be deprecated in favor of a `filter_backends` ' - 'attribute and `DEFAULT_FILTER_BACKENDS` setting, that take ' - 'a *list* of filter backend classes.') + raise RuntimeError('The `filter_backend` attribute and `FILTER_BACKEND` setting ' + 'are due to be deprecated in favor of a `filter_backends` ' + 'attribute and `DEFAULT_FILTER_BACKENDS` setting, that take ' + 'a *list* of filter backend classes.') return filter_backends - - ######################## - ### The following methods provide default implementations - ### that you may want to override for more complex cases. + ########################################################### + # The following methods provide default implementations # + # that you may want to override for more complex cases. # + ########################################################### def get_paginate_by(self, queryset=None): """ @@ -214,8 +212,8 @@ class GenericAPIView(views.APIView): Otherwise defaults to using `self.paginate_by`. """ if queryset is not None: - raise RuntimeException('The `queryset` parameter to `get_paginate_by()` ' - 'is due to be deprecated.') + raise RuntimeError('The `queryset` parameter to `get_paginate_by()` ' + 'is due to be deprecated.') if self.paginate_by_param: try: return strict_positive_int( @@ -263,8 +261,7 @@ class GenericAPIView(views.APIView): if self.model is not None: return self.model._default_manager.all() - raise ImproperlyConfigured("'%s' must define 'queryset' or 'model'" - % self.__class__.__name__) + raise ImproperlyConfigured("'%s' must define 'queryset' or 'model'" % self.__class__.__name__) def get_object(self, queryset=None): """ @@ -280,7 +277,7 @@ class GenericAPIView(views.APIView): else: # NOTE: explicit exception for avoid and fix # usage of deprecated way of get_object - raise RuntimeException("DEPRECATED") + raise RuntimeError("DEPRECATED") # Perform the lookup filtering. # Note that `pk` and `slug` are deprecated styles of lookup filtering. @@ -292,11 +289,11 @@ class GenericAPIView(views.APIView): if lookup is not None: filter_kwargs = {self.lookup_field: lookup} elif pk is not None and self.lookup_field == 'pk': - raise RuntimeException('The `pk_url_kwarg` attribute is due to be deprecated. ' - 'Use the `lookup_field` attribute instead') + raise RuntimeError('The `pk_url_kwarg` attribute is due to be deprecated. ' + 'Use the `lookup_field` attribute instead') elif slug is not None and self.lookup_field == 'pk': - raise RuntimeException('The `slug_url_kwarg` attribute is due to be deprecated. ' - 'Use the `lookup_field` attribute instead') + raise RuntimeError('The `slug_url_kwarg` attribute is due to be deprecated. ' + 'Use the `lookup_field` attribute instead') else: raise ImproperlyConfigured( 'Expected view %s to be called with a URL keyword argument ' @@ -314,12 +311,13 @@ class GenericAPIView(views.APIView): except Http404: return None - ######################## - ### The following are placeholder methods, - ### and are intended to be overridden. - ### - ### The are not called by GenericAPIView directly, - ### but are used by the mixin methods. + ################################################### + # The following are placeholder methods, # + # and are intended to be overridden. # + # # + # The are not called by GenericAPIView directly, # + # but are used by the mixin methods. # + ################################################### def pre_conditions_on_save(self, obj): """ @@ -363,11 +361,11 @@ class GenericAPIView(views.APIView): pass -########################################################## -### Concrete view classes that provide method handlers ### -### by composing the mixin classes with the base view. ### -### NOTE: not used by taiga. ### -########################################################## +###################################################### +# Concrete view classes that provide method handlers # +# by composing the mixin classes with the base view. # +# NOTE: not used by taiga. # +###################################################### class CreateAPIView(mixins.CreateModelMixin, GenericAPIView): diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py index bca89e63..ed2b2f24 100644 --- a/taiga/base/api/mixins.py +++ b/taiga/base/api/mixins.py @@ -23,9 +23,7 @@ from django.core.exceptions import ValidationError from django.http import Http404 from django.db import transaction as tx -from rest_framework import status -from rest_framework.response import Response -from rest_framework.request import clone_request +from taiga.base import response from rest_framework.settings import api_settings from .utils import get_object_or_404 @@ -73,10 +71,9 @@ class CreateModelMixin(object): self.object = serializer.save(force_insert=True) self.post_save(self.object, created=True) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) + return response.Created(serializer.data, headers=headers) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return response.BadRequest(serializer.errors) def get_success_headers(self, data): try: @@ -114,7 +111,7 @@ class ListModelMixin(object): else: serializer = self.get_serializer(self.object_list, many=True) - return Response(serializer.data) + return response.Ok(serializer.data) class RetrieveModelMixin(object): @@ -130,7 +127,7 @@ class RetrieveModelMixin(object): raise Http404 serializer = self.get_serializer(self.object) - return Response(serializer.data) + return response.Ok(serializer.data) class UpdateModelMixin(object): @@ -149,7 +146,7 @@ class UpdateModelMixin(object): files=request.FILES, partial=partial) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return response.BadRequest(serializer.errors) # Hooks try: @@ -158,16 +155,16 @@ class UpdateModelMixin(object): except ValidationError as err: # full_clean on model instance may be called in pre_save, # so we have to handle eventual errors. - return Response(err.message_dict, status=status.HTTP_400_BAD_REQUEST) + return response.BadRequest(err.message_dict) if self.object is None: self.object = serializer.save(force_insert=True) self.post_save(self.object, created=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return response.Created(serializer.data) self.object = serializer.save(force_update=True) self.post_save(self.object, created=False) - return Response(serializer.data, status=status.HTTP_200_OK) + return response.Ok(serializer.data) def partial_update(self, request, *args, **kwargs): kwargs['partial'] = True @@ -216,4 +213,4 @@ class DestroyModelMixin(object): self.pre_conditions_on_delete(obj) obj.delete() self.post_delete(obj) - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() diff --git a/taiga/base/api/permissions.py b/taiga/base/api/permissions.py index 95f8028d..ff1f8bef 100644 --- a/taiga/base/api/permissions.py +++ b/taiga/base/api/permissions.py @@ -219,8 +219,10 @@ class IsObjectOwner(PermissionComponent): class AllowAnyPermission(ResourcePermission): enought_perms = AllowAny() + class IsAuthenticatedPermission(ResourcePermission): enought_perms = IsAuthenticated() + class TaigaResourcePermission(ResourcePermission): enought_perms = IsSuperUser() diff --git a/taiga/base/api/views.py b/taiga/base/api/views.py index cde7113c..26cc5970 100644 --- a/taiga/base/api/views.py +++ b/taiga/base/api/views.py @@ -27,10 +27,13 @@ from django.views.decorators.csrf import csrf_exempt from rest_framework import status, exceptions from rest_framework.compat import smart_text, HttpResponseBase, View from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.utils import formatting +from taiga.base.response import Response +from taiga.base.response import Ok +from taiga.base.response import NotFound +from taiga.base.response import Forbidden from taiga.base.utils.iterators import as_tuple from django.conf import settings @@ -53,6 +56,7 @@ def get_view_name(view_cls, suffix=None): return name + def get_view_description(view_cls, html=False): """ Given a view class, return a textual description to represent the view. @@ -89,12 +93,10 @@ def exception_handler(exc): headers=headers) elif isinstance(exc, Http404): - return Response({'detail': 'Not found'}, - status=status.HTTP_404_NOT_FOUND) + return NotFound({'detail': 'Not found'}) elif isinstance(exc, PermissionDenied): - return Response({'detail': 'Permission denied'}, - status=status.HTTP_403_FORBIDDEN) + return Forbidden({'detail': 'Permission denied'}) # Note: Unhandled exceptions will raise a 500 error. return None @@ -140,7 +142,6 @@ class APIView(View): headers['Vary'] = 'Accept' return headers - def http_method_not_allowed(self, request, *args, **kwargs): """ If `request.method` does not correspond to a handler method, @@ -425,7 +426,7 @@ class APIView(View): We may as well implement this as Django will otherwise provide a less useful default implementation. """ - return Response(self.metadata(request), status=status.HTTP_200_OK) + return Ok(self.metadata(request)) def metadata(self, request): """ @@ -444,7 +445,7 @@ class APIView(View): def api_server_error(request, *args, **kwargs): - if settings.DEBUG == False and request.META['CONTENT_TYPE'] == "application/json": + if settings.DEBUG is False and request.META['CONTENT_TYPE'] == "application/json": return HttpResponse(json.dumps({"error": "Server application error"}), status=status.HTTP_500_INTERNAL_SERVER_ERROR) return server_error(request, *args, **kwargs) diff --git a/taiga/base/api/viewsets.py b/taiga/base/api/viewsets.py index edb8cfff..cad36dcd 100644 --- a/taiga/base/api/viewsets.py +++ b/taiga/base/api/viewsets.py @@ -124,6 +124,7 @@ class GenericViewSet(ViewSetMixin, generics.GenericAPIView): """ pass + class ReadOnlyListViewSet(pagination.HeadersPaginationMixin, pagination.ConditionalPaginationMixin, GenericViewSet): @@ -132,6 +133,7 @@ class ReadOnlyListViewSet(pagination.HeadersPaginationMixin, """ pass + class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet): @@ -166,3 +168,8 @@ class ModelListViewSet(pagination.HeadersPaginationMixin, mixins.ListModelMixin, GenericViewSet): pass + +class ModelUpdateRetrieveViewSet(mixins.UpdateModelMixin, + mixins.RetrieveModelMixin, + GenericViewSet): + pass diff --git a/taiga/base/apps.py b/taiga/base/apps.py index 1b6c9027..f2719c55 100644 --- a/taiga/base/apps.py +++ b/taiga/base/apps.py @@ -20,6 +20,7 @@ import sys from django.apps import AppConfig from . import monkey + class BaseAppConfig(AppConfig): name = "taiga.base" verbose_name = "Base App Config" @@ -28,4 +29,3 @@ class BaseAppConfig(AppConfig): print("Monkey patching...", file=sys.stderr) monkey.patch_restframework() monkey.patch_serializer() - diff --git a/taiga/base/connectors/exceptions.py b/taiga/base/connectors/exceptions.py index b7647e67..1619abba 100644 --- a/taiga/base/connectors/exceptions.py +++ b/taiga/base/connectors/exceptions.py @@ -18,10 +18,7 @@ from taiga.base.exceptions import BaseException from django.utils.translation import ugettext_lazy as _ + class ConnectorBaseException(BaseException): status_code = 400 default_detail = _("Connection error.") - - -class GitHubApiError(ConnectorBaseException): - pass diff --git a/taiga/base/connectors/github.py b/taiga/base/connectors/github.py deleted file mode 100644 index c60cb1d1..00000000 --- a/taiga/base/connectors/github.py +++ /dev/null @@ -1,166 +0,0 @@ -# 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 requests -import json - -from collections import namedtuple -from urllib.parse import urljoin - -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ - -from . import exceptions as exc - - -###################################################### -## Data -###################################################### - -CLIENT_ID = getattr(settings, "GITHUB_API_CLIENT_ID", None) -CLIENT_SECRET = getattr(settings, "GITHUB_API_CLIENT_SECRET", None) - -URL = getattr(settings, "GITHUB_URL", "https://github.com/") -API_URL = getattr(settings, "GITHUB_API_URL", "https://api.github.com/") -API_RESOURCES_URLS = { - "login": { - "authorize": "login/oauth/authorize", - "access-token": "login/oauth/access_token" - }, - "user": { - "profile": "user", - "emails": "user/emails" - } -} - - -HEADERS = {"Accept": "application/json",} - -AuthInfo = namedtuple("AuthInfo", ["access_token"]) -User = namedtuple("User", ["id", "username", "full_name", "bio"]) -Email = namedtuple("Email", ["email", "is_primary"]) - - -###################################################### -## utils -###################################################### - -def _build_url(*args, **kwargs) -> str: - """ - Return a valid url. - """ - resource_url = API_RESOURCES_URLS - for key in args: - resource_url = resource_url[key] - - if kwargs: - resource_url = resource_url.format(**kwargs) - - return urljoin(API_URL, resource_url) - - -def _get(url:str, headers:dict) -> dict: - """ - Make a GET call. - """ - response = requests.get(url, headers=headers) - - data = response.json() - if response.status_code != 200: - raise exc.GitHubApiError({"status_code": response.status_code, - "error": data.get("error", "")}) - return data - - -def _post(url:str, params:dict, headers:dict) -> dict: - """ - Make a POST call. - """ - response = requests.post(url, params=params, headers=headers) - - data = response.json() - if response.status_code != 200 or "error" in data: - raise exc.GitHubApiError({"status_code": response.status_code, - "error": data.get("error", "")}) - return data - - -###################################################### -## Simple calls -###################################################### - -def login(access_code:str, client_id:str=CLIENT_ID, client_secret:str=CLIENT_SECRET, - headers:dict=HEADERS): - """ - Get access_token fron an user authorized code, the client id and the client secret key. - (See https://developer.github.com/v3/oauth/#web-application-flow). - """ - if not CLIENT_ID or not CLIENT_SECRET: - raise exc.GitHubApiError({"error_message": _("Login with github account is disabled. Contact " - "with the sysadmins. Maybe they're snoozing in a " - "secret hideout of the data center.")}) - - url = urljoin(URL, "login/oauth/access_token") - params={"code": access_code, - "client_id": client_id, - "client_secret": client_secret, - "scope": "user:emails"} - data = _post(url, params=params, headers=headers) - return AuthInfo(access_token=data.get("access_token", None)) - - -def get_user_profile(headers:dict=HEADERS): - """ - Get authenticated user info. - (See https://developer.github.com/v3/users/#get-the-authenticated-user). - """ - url = _build_url("user", "profile") - data = _get(url, headers=headers) - return User(id=data.get("id", None), - username=data.get("login", None), - full_name=(data.get("name", None) or ""), - bio=(data.get("bio", None) or "")) - - -def get_user_emails(headers:dict=HEADERS) -> list: - """ - Get a list with all emails of the authenticated user. - (See https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user). - """ - url = _build_url("user", "emails") - data = _get(url, headers=headers) - return [Email(email=e.get("email", None), is_primary=e.get("primary", False)) - for e in data] - - -###################################################### -## Convined calls -###################################################### - -def me(access_code:str) -> tuple: - """ - Connect to a github account and get all personal info (profile and the primary email). - """ - auth_info = login(access_code) - - headers = HEADERS.copy() - headers["Authorization"] = "token {}".format(auth_info.access_token) - - user = get_user_profile(headers=headers) - emails = get_user_emails(headers=headers) - - primary_email = next(filter(lambda x: x.is_primary, emails)) - return primary_email.email, user diff --git a/taiga/base/decorators.py b/taiga/base/decorators.py index bf706df3..d11b97f9 100644 --- a/taiga/base/decorators.py +++ b/taiga/base/decorators.py @@ -17,7 +17,7 @@ import warnings -## Rest Framework 2.4 backport some decorators. +# Rest Framework 2.4 backport some decorators. def detail_route(methods=['get'], **kwargs): """ @@ -51,12 +51,14 @@ def link(**kwargs): """ msg = 'link is pending deprecation. Use detail_route instead.' warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): func.bind_to_methods = ['get'] func.detail = True func.permission_classes = kwargs.get('permission_classes', []) func.kwargs = kwargs return func + return decorator @@ -66,10 +68,12 @@ def action(methods=['post'], **kwargs): """ msg = 'action is pending deprecation. Use detail_route instead.' warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): func.bind_to_methods = methods func.detail = True func.permission_classes = kwargs.get('permission_classes', []) func.kwargs = kwargs return func + return decorator diff --git a/taiga/base/exceptions.py b/taiga/base/exceptions.py index 53a56345..2d3d5f33 100644 --- a/taiga/base/exceptions.py +++ b/taiga/base/exceptions.py @@ -14,17 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . - from rest_framework import exceptions from rest_framework import status -from rest_framework.response import Response from django.core.exceptions import PermissionDenied as DjangoPermissionDenied from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from django.http import Http404 -from .utils.json import to_json +from taiga.base import response class BaseException(exceptions.APIException): @@ -129,15 +127,13 @@ def exception_handler(exc): headers["X-Throttle-Wait-Seconds"] = "%d" % exc.wait detail = format_exception(exc) - return Response(detail, status=exc.status_code, headers=headers) + return response.Response(detail, status=exc.status_code, headers=headers) elif isinstance(exc, Http404): - return Response({'_error_message': str(exc)}, - status=status.HTTP_404_NOT_FOUND) + return response.NotFound({'_error_message': str(exc)}) elif isinstance(exc, DjangoPermissionDenied): - return Response({"_error_message": str(exc)}, - status=status.HTTP_403_FORBIDDEN) + return response.Forbidden({"_error_message": str(exc)}) # Note: Unhandled exceptions will raise a 500 error. return None diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 7be6fcbd..43c0219b 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -15,15 +15,19 @@ # along with this program. If not, see . import operator from functools import reduce +import logging +from django.apps import apps from django.db.models import Q -from django.db.models.sql.where import ExtraWhere, OR, AND +from django.utils.translation import ugettext_lazy as _ from rest_framework import filters -from taiga.base import tags +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_404 -from taiga.projects.models import Membership + +logger = logging.getLogger(__name__) class QueryParamsFilterMixin(filters.BaseFilterBackend): @@ -53,7 +57,10 @@ class QueryParamsFilterMixin(filters.BaseFilterBackend): query_params[field_name] = field_data if query_params: - queryset = queryset.filter(**query_params) + try: + queryset = queryset.filter(**query_params) + except ValueError: + raise exc.BadRequest("Error in filter params types.") return queryset @@ -92,22 +99,32 @@ class PermissionBasedFilterBackend(FilterBackend): def filter_queryset(self, request, queryset, view): project_id = None - if hasattr(view, "filter_fields") and "project" in view.filter_fields: - project_id = request.QUERY_PARAMS.get("project", None) + if (hasattr(view, "filter_fields") and "project" in view.filter_fields and + "project" in request.QUERY_PARAMS): + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format( + request.QUERY_PARAMS["project"] + )) + raise exc.BadRequest("'project' must be an integer value.") qs = queryset if request.user.is_authenticated() and request.user.is_superuser: qs = qs elif request.user.is_authenticated(): - memberships_qs = Membership.objects.filter(user=request.user) + membership_model = apps.get_model('projects', 'Membership') + memberships_qs = membership_model.objects.filter(user=request.user) if project_id: memberships_qs = memberships_qs.filter(project_id=project_id) - memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | Q(is_owner=True)) + 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] - qs = qs.filter(Q(project_id__in=projects_list) | Q(project__public_permissions__contains=[self.permission])) + qs = qs.filter(Q(project_id__in=projects_list) | + Q(project__public_permissions__contains=[self.permission])) else: qs = qs.filter(project__anon_permissions__contains=[self.permission]) @@ -171,24 +188,34 @@ class CanViewWikiAttachmentFilterBackend(PermissionBasedAttachmentFilterBackend) class CanViewProjectObjFilterBackend(FilterBackend): def filter_queryset(self, request, queryset, view): project_id = None - if hasattr(view, "filter_fields") and "project" in view.filter_fields: - project_id = request.QUERY_PARAMS.get("project", None) + if (hasattr(view, "filter_fields") and "project" in view.filter_fields and + "project" in request.QUERY_PARAMS): + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format( + request.QUERY_PARAMS["project"] + )) + raise exc.BadRequest("'project' must be an integer value.") qs = queryset if request.user.is_authenticated() and request.user.is_superuser: qs = qs elif request.user.is_authenticated(): - memberships_qs = Membership.objects.filter(user=request.user) + membership_model = apps.get_model("projects", "Membership") + memberships_qs = membership_model.objects.filter(user=request.user) if project_id: memberships_qs = memberships_qs.filter(project_id=project_id) - memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) | Q(is_owner=True)) + 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] - qs = qs.filter(Q(id__in=projects_list) | Q(public_permissions__contains=["view_project"])) + qs = qs.filter((Q(id__in=projects_list) | + Q(public_permissions__contains=["view_project"]))) else: - qs = qs.filter(public_permissions__contains=["view_project"]) + qs = qs.filter(anon_permissions__contains=["view_project"]) return super().filter_queryset(request, qs.distinct(), view) @@ -204,6 +231,56 @@ class IsProjectMemberFilterBackend(FilterBackend): return super().filter_queryset(request, queryset.distinct(), view) + +class MembersFilterBackend(PermissionBasedFilterBackend): + permission = "view_project" + + def filter_queryset(self, request, queryset, view): + project_id = None + project = None + qs = queryset.filter(is_active=True) + if "project" in request.QUERY_PARAMS: + try: + project_id = int(request.QUERY_PARAMS["project"]) + except: + logger.error("Filtering project diferent value than an integer: {}".format(request.QUERY_PARAMS["project"])) + raise exc.BadRequest("'project' must be an integer value.") + + if project_id: + Project = apps.get_model('projects', 'Project') + project = get_object_or_404(Project, pk=project_id) + + if request.user.is_authenticated() and request.user.is_superuser: + qs = qs + elif request.user.is_authenticated(): + Membership = apps.get_model('projects', 'Membership') + memberships_qs = Membership.objects.filter(user=request.user) + if project_id: + memberships_qs = memberships_qs.filter(project_id=project_id) + 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 project: + is_member = project.id in projects_list + has_project_public_view_permission = "view_project" in project.public_permissions + if not is_member and not has_project_public_view_permission: + qs = qs.none() + + qs = qs.filter(Q(memberships__project_id__in=projects_list) | + Q(memberships__project__public_permissions__contains=[self.permission])| + Q(id=request.user.id)) + + else: + if project and not "view_project" in project.anon_permissions: + qs = qs.none() + + qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission]) + + return qs.distinct() + + class BaseIsProjectAdminFilterBackend(object): def get_project_ids(self, request, view): project_id = None @@ -216,7 +293,8 @@ class BaseIsProjectAdminFilterBackend(object): if not request.user.is_authenticated(): return [] - memberships_qs = Membership.objects.filter(user=request.user, is_owner=True) + membership_model = apps.get_model('projects', 'Membership') + memberships_qs = membership_model.objects.filter(user=request.user, is_owner=True) if project_id: memberships_qs = memberships_qs.filter(project_id=project_id) diff --git a/taiga/base/management/commands/test_emails.py b/taiga/base/management/commands/test_emails.py index 6b7f8bf5..29752dd4 100644 --- a/taiga/base/management/commands/test_emails.py +++ b/taiga/base/management/commands/test_emails.py @@ -17,7 +17,7 @@ import datetime from django.db.models.loading import get_model -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.utils import timezone from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail @@ -139,10 +139,10 @@ class Command(BaseCommand): ] context = { - "project": Project.objects.all().order_by("?").first(), - "changer": User.objects.all().order_by("?").first(), - "history_entries": HistoryEntry.objects.all().order_by("?")[0:5], - "user": User.objects.all().order_by("?").first(), + "project": Project.objects.all().order_by("?").first(), + "changer": User.objects.all().order_by("?").first(), + "history_entries": HistoryEntry.objects.all().order_by("?")[0:5], + "user": User.objects.all().order_by("?").first(), } for notification_email in notification_emails: diff --git a/taiga/base/middleware/cors.py b/taiga/base/middleware/cors.py index 03145e10..83031b73 100644 --- a/taiga/base/middleware/cors.py +++ b/taiga/base/middleware/cors.py @@ -14,8 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json - from django import http @@ -33,7 +31,7 @@ COORS_EXPOSE_HEADERS = ["x-pagination-count", "x-paginated", "x-paginated-by", class CoorsMiddleware(object): def _populate_response(self, response): - response["Access-Control-Allow-Origin"] = COORS_ALLOWED_ORIGINS + response["Access-Control-Allow-Origin"] = COORS_ALLOWED_ORIGINS response["Access-Control-Allow-Methods"] = ",".join(COORS_ALLOWED_METHODS) response["Access-Control-Allow-Headers"] = ",".join(COORS_ALLOWED_HEADERS) response["Access-Control-Expose-Headers"] = ",".join(COORS_EXPOSE_HEADERS) diff --git a/taiga/base/monkey.py b/taiga/base/monkey.py index 91d1be35..c956cab4 100644 --- a/taiga/base/monkey.py +++ b/taiga/base/monkey.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from __future__ import print_function -import sys + def patch_serializer(): from rest_framework import serializers diff --git a/taiga/base/neighbors.py b/taiga/base/neighbors.py index d803b4ae..80ee6844 100644 --- a/taiga/base/neighbors.py +++ b/taiga/base/neighbors.py @@ -15,10 +15,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from functools import partial from collections import namedtuple -from django.db.models import Q from django.db import connection Neighbor = namedtuple("Neighbor", "left right") @@ -46,7 +44,7 @@ def get_neighbors(obj, results_set=None): (SELECT "id" as id, ROW_NUMBER() OVER() FROM (%s) as ID_AND_ROW) AS SELECTED_ID_AND_ROW - """%(base_sql) + """ % (base_sql) query += " WHERE id=%s;" params = list(base_params) + [obj.id] diff --git a/taiga/base/routers.py b/taiga/base/routers.py index 407b40a5..3a3a5b8a 100644 --- a/taiga/base/routers.py +++ b/taiga/base/routers.py @@ -23,7 +23,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch from rest_framework import views -from rest_framework.response import Response +from taiga.base import response from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns @@ -292,7 +292,7 @@ class DRFDefaultRouter(SimpleRouter): except NoReverseMatch: # Support resources that are prefixed by a parametrized url ret[key] = request.build_absolute_uri() + key - return Response(ret) + return response.Response(ret) return APIRoot.as_view() diff --git a/taiga/base/storage.py b/taiga/base/storage.py index 1b99f53f..acf93306 100644 --- a/taiga/base/storage.py +++ b/taiga/base/storage.py @@ -19,6 +19,7 @@ from django.core.files import storage import django_sites as sites + class FileSystemStorage(storage.FileSystemStorage): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/taiga/base/tags.py b/taiga/base/tags.py index 9575f090..4b7c6409 100644 --- a/taiga/base/tags.py +++ b/taiga/base/tags.py @@ -15,9 +15,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import re -from functools import partial - from django.db import models from django.utils.translation import ugettext_lazy as _ diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py index 02f25d11..a9663751 100644 --- a/taiga/base/utils/db.py +++ b/taiga/base/utils/db.py @@ -31,6 +31,7 @@ def get_typename_for_model_class(model:object, for_concrete_model=True) -> str: return "{0}.{1}".format(model._meta.app_label, model._meta.model_name) + def get_typename_for_model_instance(model_instance): """ Get content type tuple from model instance. diff --git a/taiga/base/utils/dicts.py b/taiga/base/utils/dicts.py index 2d7e7077..512a044d 100644 --- a/taiga/base/utils/dicts.py +++ b/taiga/base/utils/dicts.py @@ -16,6 +16,7 @@ import collections + def dict_sum(*args): result = collections.Counter() for arg in args: diff --git a/taiga/base/utils/functions.py b/taiga/base/utils/functions.py index 5c8fae97..d20f824a 100644 --- a/taiga/base/utils/functions.py +++ b/taiga/base/utils/functions.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + def noop(*args, **kwargs): """The noop function.""" return None diff --git a/taiga/base/utils/json.py b/taiga/base/utils/json.py index bb8dde78..9404febd 100644 --- a/taiga/base/utils/json.py +++ b/taiga/base/utils/json.py @@ -22,6 +22,7 @@ from django.utils.encoding import force_text def dumps(data, ensure_ascii=True, encoder_class=encoders.JSONEncoder): return json.dumps(data, cls=encoder_class, indent=None, ensure_ascii=ensure_ascii) + def loads(data): if isinstance(data, bytes): data = force_text(data) diff --git a/taiga/base/utils/sequence.py b/taiga/base/utils/sequence.py index bdec27b6..18af3e2f 100644 --- a/taiga/base/utils/sequence.py +++ b/taiga/base/utils/sequence.py @@ -20,6 +20,7 @@ def first(iterable): return None return iterable[0] + def next(data:list): return data[1:] diff --git a/taiga/base/utils/signals.py b/taiga/base/utils/signals.py index 9a457fe0..64cc580a 100644 --- a/taiga/base/utils/signals.py +++ b/taiga/base/utils/signals.py @@ -36,4 +36,3 @@ def without_signals(*disablers): for disabler in disablers: signal, *ids = disabler signal.receivers = signal.backup_receivers - diff --git a/taiga/base/utils/slug.py b/taiga/base/utils/slug.py index d017664a..03b95767 100644 --- a/taiga/base/utils/slug.py +++ b/taiga/base/utils/slug.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.utils import baseconv from django.template.defaultfilters import slugify as django_slugify import time diff --git a/taiga/events/middleware.py b/taiga/events/middleware.py index c222d9f9..9dbfb103 100644 --- a/taiga/events/middleware.py +++ b/taiga/events/middleware.py @@ -30,9 +30,9 @@ def get_current_session_id() -> str: global _local if not hasattr(_local, "session_id"): - raise RuntimeException("No session identifier is found, " - "ara you sure that session id middleware " - "is active?") + raise RuntimeError("No session identifier is found, " + "are you sure that session id middleware " + "is active?") return _local.session_id diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py index 9c8c5921..7f938f15 100644 --- a/taiga/events/signal_handlers.py +++ b/taiga/events/signal_handlers.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from django.db.models import signals +from django.db import connection + from django.dispatch import receiver from taiga.base.utils.db import get_typename_for_model_instance @@ -33,10 +35,12 @@ def on_save_any_model(sender, instance, created, **kwargs): sesionid = mw.get_current_session_id() + type = "change" if created: - events.emit_event_for_model(instance, sessionid=sesionid, type="create") - else: - events.emit_event_for_model(instance, sessionid=sesionid, type="change") + type = "create" + + emit_event = lambda: events.emit_event_for_model(instance, sessionid=sesionid, type=type) + connection.on_commit(emit_event) def on_delete_any_model(sender, instance, **kwargs): @@ -48,4 +52,5 @@ def on_delete_any_model(sender, instance, **kwargs): return sesionid = mw.get_current_session_id() - events.emit_event_for_model(instance, sessionid=sesionid, type="delete") + emit_event = lambda: events.emit_event_for_model(instance, sessionid=sesionid, type="delete") + connection.on_commit(emit_event) diff --git a/taiga/export_import/api.py b/taiga/export_import/api.py index a993b6af..8ddb4c15 100644 --- a/taiga/export_import/api.py +++ b/taiga/export_import/api.py @@ -18,10 +18,6 @@ import json import codecs import uuid -from rest_framework.response import Response -from rest_framework.decorators import throttle_classes -from rest_framework import status - from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ from django.db.transaction import atomic @@ -30,10 +26,11 @@ from django.conf import settings from django.core.files.storage import default_storage from django.core.files.base import ContentFile -from taiga.base.api.mixins import CreateModelMixin -from taiga.base.api.viewsets import GenericViewSet from taiga.base.decorators import detail_route, list_route from taiga.base import exceptions as exc +from taiga.base import response +from taiga.base.api.mixins import CreateModelMixin +from taiga.base.api.viewsets import GenericViewSet from taiga.projects.models import Project, Membership from taiga.projects.issues.models import Issue from taiga.projects.serializers import ProjectSerializer @@ -65,18 +62,19 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet) if settings.CELERY_ENABLED: task = tasks.dump_project.delay(request.user, project) - tasks.delete_project_dump.apply_async((project.pk, project.slug), countdown=settings.EXPORTS_TTL) - return Response({"export_id": task.id}, status=status.HTTP_202_ACCEPTED) + tasks.delete_project_dump.apply_async((project.pk, project.slug), + countdown=settings.EXPORTS_TTL) + return response.Accepted({"export_id": task.id}) path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex) content = ContentFile(ExportRenderer().render(service.project_to_dict(project), - renderer_context={"indent": 4}).decode('utf-8')) + renderer_context={"indent": 4}).decode('utf-8')) default_storage.save(path, content) response_data = { "url": default_storage.url(path) } - return Response(response_data, status=status.HTTP_200_OK) + return response.Ok(response_data) class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet): @@ -129,6 +127,21 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi "severities" in data): service.store_default_choices(project_serialized.object, data) + if "userstorycustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "userstorycustomattributes", + serializers.UserStoryCustomAttributeExportSerializer) + + if "taskcustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "taskcustomattributes", + serializers.TaskCustomAttributeExportSerializer) + + if "issuecustomattributes" in data: + service.store_custom_attributes(project_serialized.object, data, + "issuecustomattributes", + serializers.IssueCustomAttributeExportSerializer) + if "roles" in data: service.store_roles(project_serialized.object, data) @@ -152,7 +165,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi response_data = project_serialized.data response_data['id'] = project_serialized.object.id headers = self.get_success_headers(response_data) - return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(response_data, headers=headers) @list_route(methods=["POST"]) @method_decorator(atomic) @@ -181,12 +194,11 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi if settings.CELERY_ENABLED: task = tasks.load_project_dump.delay(request.user, dump) - return Response({"import_id": task.id}, status=status.HTTP_202_ACCEPTED) + return response.Accepted({"import_id": task.id}) project = dump_service.dict_to_project(dump, request.user.email) response_data = ProjectSerializer(project).data - return Response(response_data, status=status.HTTP_201_CREATED) - + return response.Created(response_data) @detail_route(methods=['post']) @method_decorator(atomic) @@ -195,7 +207,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi self.check_permissions(request, 'import_item', project) signals.pre_save.disconnect(sender=Issue, - dispatch_uid="set_finished_date_when_edit_issue") + dispatch_uid="set_finished_date_when_edit_issue") issue = service.store_issue(project, request.DATA.copy()) @@ -204,7 +216,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(issue.data) - return Response(issue.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(issue.data, headers=headers) @detail_route(methods=['post']) @method_decorator(atomic) @@ -219,7 +231,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(task.data) - return Response(task.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(task.data, headers=headers) @detail_route(methods=['post']) @method_decorator(atomic) @@ -234,7 +246,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(us.data) - return Response(us.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(us.data, headers=headers) @detail_route(methods=['post']) @method_decorator(atomic) @@ -249,7 +261,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(milestone.data) - return Response(milestone.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(milestone.data, headers=headers) @detail_route(methods=['post']) @method_decorator(atomic) @@ -264,7 +276,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(wiki_page.data) - return Response(wiki_page.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(wiki_page.data, headers=headers) @detail_route(methods=['post']) @method_decorator(atomic) @@ -279,4 +291,4 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi raise exc.BadRequest(errors) headers = self.get_success_headers(wiki_link.data) - return Response(wiki_link.data, status=status.HTTP_201_CREATED, headers=headers) + return response.Created(wiki_link.data, headers=headers) diff --git a/taiga/export_import/dump_service.py b/taiga/export_import/dump_service.py index b300ab84..2ef615a1 100644 --- a/taiga/export_import/dump_service.py +++ b/taiga/export_import/dump_service.py @@ -14,8 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.db.models import signals - from taiga.projects.models import Membership from . import serializers @@ -105,6 +103,16 @@ def dict_to_project(data, owner=None): if service.get_errors(clear=False): raise TaigaImportError('error importing default choices') + service.store_custom_attributes(proj, data, "userstorycustomattributes", + serializers.UserStoryCustomAttributeExportSerializer) + service.store_custom_attributes(proj, data, "taskcustomattributes", + serializers.TaskCustomAttributeExportSerializer) + service.store_custom_attributes(proj, data, "issuecustomattributes", + serializers.IssueCustomAttributeExportSerializer) + + if service.get_errors(clear=False): + raise TaigaImportError('error importing custom attributes') + service.store_roles(proj, data) if service.get_errors(clear=False): diff --git a/taiga/export_import/management/commands/load_dump.py b/taiga/export_import/management/commands/load_dump.py index 3840e349..4566cf88 100644 --- a/taiga/export_import/management/commands/load_dump.py +++ b/taiga/export_import/management/commands/load_dump.py @@ -14,19 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import signals from optparse import make_option import json -import pprint from taiga.projects.models import Project from taiga.export_import.renderers import ExportRenderer from taiga.export_import.dump_service import dict_to_project, TaigaImportError from taiga.export_import.service import get_errors + class Command(BaseCommand): args = ' ' help = 'Export a project to json' @@ -34,10 +34,10 @@ class Command(BaseCommand): renderer = ExportRenderer() option_list = BaseCommand.option_list + ( make_option('--overwrite', - action='store_true', - dest='overwrite', - default=False, - help='Delete project if exists'), + action='store_true', + dest='overwrite', + default=False, + help='Delete project if exists'), ) def handle(self, *args, **options): diff --git a/taiga/export_import/renderers.py b/taiga/export_import/renderers.py index 3d906f7a..8f540d07 100644 --- a/taiga/export_import/renderers.py +++ b/taiga/export_import/renderers.py @@ -16,5 +16,6 @@ from rest_framework.renderers import UnicodeJSONRenderer + class ExportRenderer(UnicodeJSONRenderer): pass diff --git a/taiga/export_import/serializers.py b/taiga/export_import/serializers.py index 8f0e0534..7b27c427 100644 --- a/taiga/export_import/serializers.py +++ b/taiga/export_import/serializers.py @@ -20,11 +20,14 @@ from collections import OrderedDict from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from taiga.projects import models as projects_models +from taiga.projects.custom_attributes import models as custom_attributes_models from taiga.projects.userstories import models as userstories_models from taiga.projects.tasks import models as tasks_models from taiga.projects.issues import models as issues_models @@ -81,14 +84,15 @@ class RelatedNoneSafeField(serializers.RelatedField): return value = self.get_default_value() + key = self.source or field_name if value in self.null_values: if self.required: raise ValidationError(self.error_messages['required']) - into[(self.source or field_name)] = None + into[key] = None elif self.many: - into[(self.source or field_name)] = [self.from_native(item) for item in value if self.from_native(item) is not None] + into[key] = [self.from_native(item) for item in value if self.from_native(item) is not None] else: - into[(self.source or field_name)] = self.from_native(value) + into[key] = self.from_native(value) class UserRelatedField(RelatedNoneSafeField): @@ -251,7 +255,8 @@ class AttachmentExportSerializerMixin(serializers.ModelSerializer): def get_attachments(self, obj): content_type = ContentType.objects.get_for_model(obj.__class__) - attachments_qs = attachments_models.Attachment.objects.filter(object_id=obj.pk, content_type=content_type) + attachments_qs = attachments_models.Attachment.objects.filter(object_id=obj.pk, + content_type=content_type) return AttachmentExportSerializer(attachments_qs, many=True).data @@ -305,6 +310,114 @@ class RoleExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') +class UserStoryCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.UserStoryCustomAttribute + exclude = ('id', 'project') + + +class TaskCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.TaskCustomAttribute + exclude = ('id', 'project') + + +class IssueCustomAttributeExportSerializer(serializers.ModelSerializer): + modified_date = serializers.DateTimeField(required=False) + + class Meta: + model = custom_attributes_models.IssueCustomAttribute + exclude = ('id', 'project') + + +class CustomAttributesValuesExportSerializerMixin(serializers.ModelSerializer): + custom_attributes_values = serializers.SerializerMethodField("get_custom_attributes_values") + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + + +class BaseCustomAttributesValuesExportSerializer(serializers.ModelSerializer): + attributes_values = JsonField(source="attributes_values",required=True) + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contain invalid custom fields.")) + + return attrs + +class UserStoryCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesExportSerializer(BaseCustomAttributesValuesExportSerializer): + _custom_attribute_model = custom_attributes_models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesExportSerializer.Meta): + model = custom_attributes_models.IssueCustomAttributesValues + + class MembershipExportSerializer(serializers.ModelSerializer): user = UserRelatedField(required=False) role = ProjectRelatedField(slug_field="name") @@ -354,7 +467,8 @@ class MilestoneExportSerializer(serializers.ModelSerializer): exclude = ('id', 'project') -class TaskExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): +class TaskExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") user_story = ProjectRelatedField(slug_field="ref", required=False) @@ -367,8 +481,12 @@ class TaskExportSerializer(HistoryExportSerializerMixin, AttachmentExportSeriali model = tasks_models.Task exclude = ('id', 'project') + def custom_attributes_queryset(self, project): + return project.taskcustomattributes.all() -class UserStoryExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + +class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): role_points = RolePointsExportSerializer(many=True, required=False) owner = UserRelatedField(required=False) assigned_to = UserRelatedField(required=False) @@ -382,8 +500,12 @@ class UserStoryExportSerializer(HistoryExportSerializerMixin, AttachmentExportSe model = userstories_models.UserStory exclude = ('id', 'project', 'points', 'tasks') + def custom_attributes_queryset(self, project): + return project.userstorycustomattributes.all() -class IssueExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + +class IssueExportSerializer(CustomAttributesValuesExportSerializerMixin, HistoryExportSerializerMixin, + AttachmentExportSerializerMixin, serializers.ModelSerializer): owner = UserRelatedField(required=False) status = ProjectRelatedField(slug_field="name") assigned_to = UserRelatedField(required=False) @@ -395,15 +517,19 @@ class IssueExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerial votes = serializers.SerializerMethodField("get_votes") modified_date = serializers.DateTimeField(required=False) - def get_votes(self, obj): - return [x.email for x in votes_service.get_voters(obj)] - class Meta: model = issues_models.Issue exclude = ('id', 'project') + def get_votes(self, obj): + return [x.email for x in votes_service.get_voters(obj)] -class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, serializers.ModelSerializer): + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + + +class WikiPageExportSerializer(HistoryExportSerializerMixin, AttachmentExportSerializerMixin, + serializers.ModelSerializer): owner = UserRelatedField(required=False) last_modifier = UserRelatedField(required=False) watchers = UserRelatedField(many=True, required=False) @@ -437,6 +563,9 @@ class ProjectExportSerializer(serializers.ModelSerializer): priorities = PriorityExportSerializer(many=True, required=False) severities = SeverityExportSerializer(many=True, required=False) issue_types = IssueTypeExportSerializer(many=True, required=False) + userstorycustomattributes = UserStoryCustomAttributeExportSerializer(many=True, required=False) + taskcustomattributes = TaskCustomAttributeExportSerializer(many=True, required=False) + issuecustomattributes = IssueCustomAttributeExportSerializer(many=True, required=False) roles = RoleExportSerializer(many=True, required=False) milestones = MilestoneExportSerializer(many=True, required=False) wiki_pages = WikiPageExportSerializer(many=True, required=False) diff --git a/taiga/export_import/service.py b/taiga/export_import/service.py index 797ae81f..8eb8cd42 100644 --- a/taiga/export_import/service.py +++ b/taiga/export_import/service.py @@ -20,6 +20,7 @@ from unidecode import unidecode from django.template.defaultfilters import slugify from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from taiga.projects.history.services import make_key_from_model_object from taiga.projects.references import sequences as seq @@ -57,7 +58,8 @@ def store_project(data): "default_priority", "default_severity", "default_issue_status", "default_issue_type", "memberships", "points", "us_statuses", "task_statuses", "issue_statuses", "priorities", "severities", - "issue_types", "roles", "milestones", "wiki_pages", + "issue_types", "userstorycustomattributes", "taskcustomattributes", + "issuecustomattributes", "roles", "milestones", "wiki_pages", "wiki_links", "notify_policies", "user_stories", "issues", "tasks", ] if key not in excluded_fields: @@ -72,7 +74,7 @@ def store_project(data): return None -def store_choice(project, data, field, serializer): +def _store_choice(project, data, field, serializer): serialized = serializer(data=data) if serialized.is_valid(): serialized.object.project = project @@ -86,10 +88,58 @@ def store_choice(project, data, field, serializer): def store_choices(project, data, field, serializer): result = [] for choice_data in data.get(field, []): - result.append(store_choice(project, choice_data, field, serializer)) + result.append(_store_choice(project, choice_data, field, serializer)) return result +def _store_custom_attribute(project, data, field, serializer): + serialized = serializer(data=data) + if serialized.is_valid(): + serialized.object.project = project + serialized.object._importing = True + serialized.save() + return serialized.object + add_errors(field, serialized.errors) + return None + + +def store_custom_attributes(project, data, field, serializer): + result = [] + for custom_attribute_data in data.get(field, []): + result.append(_store_custom_attribute(project, custom_attribute_data, field, serializer)) + return result + + +def store_custom_attributes_values(obj, data_values, obj_field, serializer_class): + data = { + obj_field: obj.id, + "attributes_values": data_values, + } + + try: + custom_attributes_values = obj.custom_attributes_values + serializer = serializer_class(custom_attributes_values, data=data) + except ObjectDoesNotExist: + serializer = serializer_class(data=data) + + if serializer.is_valid(): + serializer.save() + return serializer + + add_errors("custom_attributes_values", serializer.errors) + return None + + +def _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(attr["name"], None) + if value is not None: + ret[str(attr["id"])] = value + + return ret + + def store_role(project, role): serialized = serializers.RoleExportSerializer(data=role) if serialized.is_valid(): @@ -103,7 +153,7 @@ def store_role(project, role): def store_roles(project, data): results = [] - for role in data.get('roles', []): + for role in data.get("roles", []): results.append(store_role(project, role)) return results @@ -145,16 +195,16 @@ def store_membership(project, membership): def store_memberships(project, data): results = [] - for membership in data.get('memberships', []): + for membership in data.get("memberships", []): results.append(store_membership(project, membership)) return results -def store_task(project, task): - if 'status' not in task and project.default_task_status: - task['status'] = project.default_task_status.name +def store_task(project, data): + if "status" not in data and project.default_task_status: + data["status"] = project.default_task_status.name - serialized = serializers.TaskExportSerializer(data=task, context={"project": project}) + serialized = serializers.TaskExportSerializer(data=data, context={"project": project}) if serialized.is_valid(): serialized.object.project = project if serialized.object.owner is None: @@ -173,12 +223,20 @@ def store_task(project, task): serialized.object.ref, _ = refs.make_reference(serialized.object, project) serialized.object.save() - for task_attachment in task.get('attachments', []): + for task_attachment in data.get("attachments", []): store_attachment(project, serialized.object, task_attachment) - for history in task.get('history', []): + for history in data.get("history", []): store_history(project, serialized.object, history) + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.taskcustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "task", serializers.TaskCustomAttributesValuesExportSerializer) + return serialized add_errors("tasks", serialized.errors) @@ -192,8 +250,8 @@ def store_milestone(project, milestone): serialized.object._importing = True serialized.save() - for task_without_us in milestone.get('tasks_without_us', []): - task_without_us['user_story'] = None + for task_without_us in milestone.get("tasks_without_us", []): + task_without_us["user_story"] = None store_task(project, task_without_us) return serialized @@ -232,7 +290,7 @@ def store_history(project, obj, history): def store_wiki_page(project, wiki_page): - wiki_page['slug'] = slugify(unidecode(wiki_page.get('slug', ''))) + wiki_page["slug"] = slugify(unidecode(wiki_page.get("slug", ""))) serialized = serializers.WikiPageExportSerializer(data=wiki_page) if serialized.is_valid(): serialized.object.project = project @@ -242,10 +300,10 @@ def store_wiki_page(project, wiki_page): serialized.object._not_notify = True serialized.save() - for attachment in wiki_page.get('attachments', []): + for attachment in wiki_page.get("attachments", []): store_attachment(project, serialized.object, attachment) - for history in wiki_page.get('history', []): + for history in wiki_page.get("history", []): store_history(project, serialized.object, history) return serialized @@ -276,61 +334,12 @@ def store_role_point(project, us, role_point): return None -def store_user_story(project, userstory): - if 'status' not in userstory and project.default_us_status: - userstory['status'] = project.default_us_status.name +def store_user_story(project, data): + if "status" not in data and project.default_us_status: + data["status"] = project.default_us_status.name - userstory_data = {} - for key, value in userstory.items(): - if key != 'role_points': - userstory_data[key] = value - serialized_us = serializers.UserStoryExportSerializer(data=userstory_data, context={"project": project}) - if serialized_us.is_valid(): - serialized_us.object.project = project - if serialized_us.object.owner is None: - serialized_us.object.owner = serialized_us.object.project.owner - serialized_us.object._importing = True - serialized_us.object._not_notify = True - - serialized_us.save() - - if serialized_us.object.ref: - sequence_name = refs.make_sequence_name(project) - if not seq.exists(sequence_name): - seq.create(sequence_name) - seq.set_max(sequence_name, serialized_us.object.ref) - else: - serialized_us.object.ref, _ = refs.make_reference(serialized_us.object, project) - serialized_us.object.save() - - for us_attachment in userstory.get('attachments', []): - store_attachment(project, serialized_us.object, us_attachment) - - for role_point in userstory.get('role_points', []): - store_role_point(project, serialized_us.object, role_point) - - for history in userstory.get('history', []): - store_history(project, serialized_us.object, history) - - return serialized_us - add_errors("user_stories", serialized_us.errors) - return None - - -def store_issue(project, data): - serialized = serializers.IssueExportSerializer(data=data, context={"project": project}) - - if 'type' not in data and project.default_issue_type: - data['type'] = project.default_issue_type.name - - if 'status' not in data and project.default_issue_status: - data['status'] = project.default_issue_status.name - - if 'priority' not in data and project.default_priority: - data['priority'] = project.default_priority.name - - if 'severity' not in data and project.default_severity: - data['severity'] = project.default_severity.name + us_data = {key: value for key, value in data.items() if key not in ["role_points", "custom_attributes_values"]} + serialized = serializers.UserStoryExportSerializer(data=us_data, context={"project": project}) if serialized.is_valid(): serialized.object.project = project @@ -350,10 +359,77 @@ def store_issue(project, data): serialized.object.ref, _ = refs.make_reference(serialized.object, project) serialized.object.save() - for attachment in data.get('attachments', []): - store_attachment(project, serialized.object, attachment) - for history in data.get('history', []): + for us_attachment in data.get("attachments", []): + store_attachment(project, serialized.object, us_attachment) + + for role_point in data.get("role_points", []): + store_role_point(project, serialized.object, role_point) + + for history in data.get("history", []): store_history(project, serialized.object, history) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.userstorycustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "user_story", serializers.UserStoryCustomAttributesValuesExportSerializer) + return serialized + + add_errors("user_stories", serialized.errors) + return None + + +def store_issue(project, data): + serialized = serializers.IssueExportSerializer(data=data, context={"project": project}) + + if "type" not in data and project.default_issue_type: + data["type"] = project.default_issue_type.name + + if "status" not in data and project.default_issue_status: + data["status"] = project.default_issue_status.name + + if "priority" not in data and project.default_priority: + data["priority"] = project.default_priority.name + + if "severity" not in data and project.default_severity: + data["severity"] = project.default_severity.name + + if serialized.is_valid(): + serialized.object.project = project + if serialized.object.owner is None: + serialized.object.owner = serialized.object.project.owner + serialized.object._importing = True + serialized.object._not_notify = True + + serialized.save() + + if serialized.object.ref: + sequence_name = refs.make_sequence_name(project) + if not seq.exists(sequence_name): + seq.create(sequence_name) + seq.set_max(sequence_name, serialized.object.ref) + else: + serialized.object.ref, _ = refs.make_reference(serialized.object, project) + serialized.object.save() + + for attachment in data.get("attachments", []): + store_attachment(project, serialized.object, attachment) + + for history in data.get("history", []): + store_history(project, serialized.object, history) + + custom_attributes_values = data.get("custom_attributes_values", None) + if custom_attributes_values: + custom_attributes = serialized.object.project.issuecustomattributes.all().values('id', 'name') + custom_attributes_values = _use_id_instead_name_as_key_in_custom_attributes_values(custom_attributes, + custom_attributes_values) + store_custom_attributes_values(serialized.object, custom_attributes_values, + "issue", serializers.IssueCustomAttributesValuesExportSerializer) + + return serialized + add_errors("issues", serialized.errors) return None diff --git a/taiga/export_import/tasks.py b/taiga/export_import/tasks.py index 3158f7ba..635eb63e 100644 --- a/taiga/export_import/tasks.py +++ b/taiga/export_import/tasks.py @@ -53,7 +53,6 @@ def dump_project(self, user, project): email.send() return - deletion_date = timezone.now() + datetime.timedelta(seconds=settings.EXPORTS_TTL) ctx = { "url": url, diff --git a/taiga/export_import/throttling.py b/taiga/export_import/throttling.py index 77b1bb34..a59d7e33 100644 --- a/taiga/export_import/throttling.py +++ b/taiga/export_import/throttling.py @@ -20,5 +20,6 @@ from taiga.base import throttling class ImportModeRateThrottle(throttling.UserRateThrottle): scope = "import-mode" + class ImportDumpModeRateThrottle(throttling.UserRateThrottle): scope = "import-dump-mode" diff --git a/taiga/hooks/api.py b/taiga/hooks/api.py index 105d0189..a95d6fbe 100644 --- a/taiga/hooks/api.py +++ b/taiga/hooks/api.py @@ -14,11 +14,11 @@ # 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 import response +from taiga.base.api.viewsets import GenericViewSet from taiga.base.utils import json from taiga.projects.models import Project @@ -75,4 +75,4 @@ class BaseWebhookApiViewSet(GenericViewSet): except ActionSyntaxException as e: raise exc.BadRequest(e) - return Response({}) + return response.NoContent() diff --git a/taiga/hooks/bitbucket/api.py b/taiga/hooks/bitbucket/api.py index cb1c75e8..562b5763 100644 --- a/taiga/hooks/bitbucket/api.py +++ b/taiga/hooks/bitbucket/api.py @@ -14,18 +14,14 @@ # 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 @@ -61,9 +57,11 @@ class BitBucketViewSet(BaseWebhookApiViewSet): if not project_secret: return False - valid_origin_ips = project.modules_config.config.get("bitbucket", {}).get("valid_origin_ips", settings.BITBUCKET_VALID_ORIGIN_IPS) + bitbucket_config = project.modules_config.config.get("bitbucket", {}) + valid_origin_ips = bitbucket_config.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): + if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips): return False return project_secret == secret_key diff --git a/taiga/hooks/bitbucket/event_hooks.py b/taiga/hooks/bitbucket/event_hooks.py index a149923d..d82d3d4e 100644 --- a/taiga/hooks/bitbucket/event_hooks.py +++ b/taiga/hooks/bitbucket/event_hooks.py @@ -15,12 +15,11 @@ # 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.models import IssueStatus, TaskStatus, UserStoryStatus from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task from taiga.projects.userstories.models import UserStory @@ -33,6 +32,7 @@ from .services import get_bitbucket_user import json + class PushEventHook(BaseEventHook): def process_event(self): if self.payload is None: diff --git a/taiga/hooks/bitbucket/migrations/0001_initial.py b/taiga/hooks/bitbucket/migrations/0001_initial.py index 372d93bb..9836b8d5 100644 --- a/taiga/hooks/bitbucket/migrations/0001_initial.py +++ b/taiga/hooks/bitbucket/migrations/0001_initial.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import 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 diff --git a/taiga/hooks/bitbucket/services.py b/taiga/hooks/bitbucket/services.py index bcb74f56..625c91a8 100644 --- a/taiga/hooks/bitbucket/services.py +++ b/taiga/hooks/bitbucket/services.py @@ -35,7 +35,7 @@ def get_or_generate_config(project): url = reverse("bitbucket-hook-list") url = get_absolute_url(url) - url = "%s?project=%s&key=%s"%(url, project.id, g_config["secret"]) + url = "%s?project=%s&key=%s" % (url, project.id, g_config["secret"]) g_config["webhooks_url"] = url return g_config diff --git a/taiga/hooks/github/api.py b/taiga/hooks/github/api.py index c0f32c16..8251858f 100644 --- a/taiga/hooks/github/api.py +++ b/taiga/hooks/github/api.py @@ -14,13 +14,6 @@ # 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 @@ -51,8 +44,9 @@ class GitHubViewSet(BaseWebhookApiViewSet): 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) + secret = project.modules_config.config.get("github", {}).get("secret", "") + secret = bytes(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): diff --git a/taiga/hooks/github/event_hooks.py b/taiga/hooks/github/event_hooks.py index d7231d72..8123bf00 100644 --- a/taiga/hooks/github/event_hooks.py +++ b/taiga/hooks/github/event_hooks.py @@ -16,7 +16,7 @@ from django.utils.translation import ugettext_lazy as _ -from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus +from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task diff --git a/taiga/hooks/github/migrations/0001_initial.py b/taiga/hooks/github/migrations/0001_initial.py index 75e43abf..ba41a989 100644 --- a/taiga/hooks/github/migrations/0001_initial.py +++ b/taiga/hooks/github/migrations/0001_initial.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import 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 diff --git a/taiga/hooks/github/services.py b/taiga/hooks/github/services.py index a02482af..bc16c380 100644 --- a/taiga/hooks/github/services.py +++ b/taiga/hooks/github/services.py @@ -19,6 +19,7 @@ import uuid from django.core.urlresolvers import reverse from taiga.users.models import User +from taiga.users.models import AuthData from taiga.base.utils.urls import get_absolute_url @@ -27,22 +28,22 @@ def get_or_generate_config(project): if config and "github" in config: g_config = project.modules_config.config["github"] else: - g_config = {"secret": uuid.uuid4().hex } + g_config = {"secret": uuid.uuid4().hex} url = reverse("github-hook-list") url = get_absolute_url(url) - url = "%s?project=%s"%(url, project.id) + url = "%s?project=%s" % (url, project.id) g_config["webhooks_url"] = url return g_config -def get_github_user(user_id): +def get_github_user(github_id): user = None - if user_id: + if github_id: try: - user = User.objects.get(github_id=user_id) - except User.DoesNotExist: + user = AuthData.objects.get(key="github", value=github_id).user + except AuthData.DoesNotExist: pass if user is None: diff --git a/taiga/hooks/gitlab/api.py b/taiga/hooks/gitlab/api.py index a7596910..01e455c5 100644 --- a/taiga/hooks/gitlab/api.py +++ b/taiga/hooks/gitlab/api.py @@ -14,20 +14,17 @@ # 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 ipware.ip import get_real_ip + 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 = { @@ -51,7 +48,8 @@ class GitLabViewSet(BaseWebhookApiViewSet): if not project_secret: return False - valid_origin_ips = project.modules_config.config.get("gitlab", {}).get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS) + gitlab_config = project.modules_config.config.get("gitlab", {}) + valid_origin_ips = gitlab_config.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 diff --git a/taiga/hooks/gitlab/event_hooks.py b/taiga/hooks/gitlab/event_hooks.py index 3a84a20d..426ab259 100644 --- a/taiga/hooks/gitlab/event_hooks.py +++ b/taiga/hooks/gitlab/event_hooks.py @@ -19,7 +19,7 @@ import os from django.utils.translation import ugettext_lazy as _ -from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus +from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus from taiga.projects.issues.models import Issue from taiga.projects.tasks.models import Task diff --git a/taiga/hooks/gitlab/migrations/0001_initial.py b/taiga/hooks/gitlab/migrations/0001_initial.py index 683d3956..8002d965 100644 --- a/taiga/hooks/gitlab/migrations/0001_initial.py +++ b/taiga/hooks/gitlab/migrations/0001_initial.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import 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 diff --git a/taiga/mdrender/extensions/autolink.py b/taiga/mdrender/extensions/autolink.py index 7676bd18..4413353a 100644 --- a/taiga/mdrender/extensions/autolink.py +++ b/taiga/mdrender/extensions/autolink.py @@ -36,7 +36,6 @@ class AutolinkExtension(markdown.Extension): * GitHub only accepts URLs with protocols or "www.", whereas Gruber's regex accepts things like "foo.com/bar". """ - def extendMarkdown(self, md, md_globals): url_re = r'(?i)\b((?:(?:ftp|https?)://|www\d{0,3}[.])([^\s<>]+))' autolink = AutolinkPattern(url_re, md) diff --git a/taiga/mdrender/extensions/references.py b/taiga/mdrender/extensions/references.py index 1ff6ac04..28f81a50 100644 --- a/taiga/mdrender/extensions/references.py +++ b/taiga/mdrender/extensions/references.py @@ -73,7 +73,7 @@ class TaigaReferencesPattern(Pattern): a = etree.Element('a') a.text = link_text a.set('href', url) - a.set('title', subject) + a.set('title', "#{} {}".format(obj_ref, subject)) a.set('class', html_classes) self.md.extracted_data['references'].append(instance.content_object) diff --git a/taiga/mdrender/extensions/target_link.py b/taiga/mdrender/extensions/target_link.py new file mode 100644 index 00000000..26cc6a5f --- /dev/null +++ b/taiga/mdrender/extensions/target_link.py @@ -0,0 +1,46 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 David Barragán +# Copyright (C) 2015 Alejandro Alonso +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import re +import markdown + +from markdown.treeprocessors import Treeprocessor + +from taiga.front import resolve + + +class TargetBlankLinkExtension(markdown.Extension): + """An extension that add target="_blank" to all external links.""" + def extendMarkdown(self, md, md_globals): + md.treeprocessors.add("target_blank_links", + TargetBlankLinksTreeprocessor(md), + " +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.contrib import admin + +from . import models + + + +@admin.register(models.UserStoryCustomAttribute) +class UserStoryCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] + + +@admin.register(models.TaskCustomAttribute) +class TaskCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] + + +@admin.register(models.IssueCustomAttribute) +class IssueCustomAttributeAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project", "order"] + list_display_links = ["id", "name"] + fieldsets = ( + (None, { + "fields": ("name", "description", ("project", "order")) + }), + ("Advanced options", { + "classes": ("collapse",), + "fields": (("created_date", "modified_date"),) + }) + ) + readonly_fields = ("created_date", "modified_date") + search_fields = ["id", "name", "project__name", "project__slug"] diff --git a/taiga/projects/custom_attributes/api.py b/taiga/projects/custom_attributes/api.py new file mode 100644 index 00000000..c93bb790 --- /dev/null +++ b/taiga/projects/custom_attributes/api.py @@ -0,0 +1,119 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.utils.translation import ugettext_lazy as _ + +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelUpdateRetrieveViewSet +from taiga.base import exceptions as exc +from taiga.base import filters +from taiga.base import response + +from taiga.projects.mixins.ordering import BulkUpdateOrderMixin +from taiga.projects.history.mixins import HistoryResourceMixin +from taiga.projects.notifications.mixins import WatchedResourceMixin +from taiga.projects.occ.mixins import OCCResourceMixin + +from . import models +from . import serializers +from . import permissions +from . import services + + +###################################################### +# Custom Attribute ViewSets +####################################################### + +class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.UserStoryCustomAttribute + serializer_class = serializers.UserStoryCustomAttributeSerializer + permission_classes = (permissions.UserStoryCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_userstory_custom_attributes" + bulk_update_perm = "change_userstory_custom_attributes" + bulk_update_order_action = services.bulk_update_userstory_custom_attribute_order + + +class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.TaskCustomAttribute + serializer_class = serializers.TaskCustomAttributeSerializer + permission_classes = (permissions.TaskCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_task_custom_attributes" + bulk_update_perm = "change_task_custom_attributes" + bulk_update_order_action = services.bulk_update_task_custom_attribute_order + + +class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): + model = models.IssueCustomAttribute + serializer_class = serializers.IssueCustomAttributeSerializer + permission_classes = (permissions.IssueCustomAttributePermission,) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ("project",) + bulk_update_param = "bulk_issue_custom_attributes" + bulk_update_perm = "change_issue_custom_attributes" + bulk_update_order_action = services.bulk_update_issue_custom_attribute_order + + +###################################################### +# Custom Attributes Values ViewSets +####################################################### + +class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, + ModelUpdateRetrieveViewSet): + def get_object_for_snapshot(self, obj): + return getattr(obj, self.content_object) + + +class UserStoryCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.UserStoryCustomAttributesValues + serializer_class = serializers.UserStoryCustomAttributesValuesSerializer + permission_classes = (permissions.UserStoryCustomAttributesValuesPermission,) + lookup_field = "user_story_id" + content_object = "user_story" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("user_story", "user_story__project") + return qs + + +class TaskCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.TaskCustomAttributesValues + serializer_class = serializers.TaskCustomAttributesValuesSerializer + permission_classes = (permissions.TaskCustomAttributesValuesPermission,) + lookup_field = "task_id" + content_object = "task" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("task", "task__project") + return qs + + +class IssueCustomAttributesValuesViewSet(BaseCustomAttributesValuesViewSet): + model = models.IssueCustomAttributesValues + serializer_class = serializers.IssueCustomAttributesValuesSerializer + permission_classes = (permissions.IssueCustomAttributesValuesPermission,) + lookup_field = "issue_id" + content_object = "issue" + + def get_queryset(self): + qs = self.model.objects.all() + qs = qs.select_related("issue", "issue__project") + return qs diff --git a/taiga/projects/custom_attributes/migrations/0001_initial.py b/taiga/projects/custom_attributes/migrations/0001_initial.py new file mode 100644 index 00000000..5814507d --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='issuecustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'issue custom attribute', + 'verbose_name_plural': 'issue custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='taskcustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'task custom attribute', + 'verbose_name_plural': 'task custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttribute', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('name', models.CharField(verbose_name='name', max_length=64)), + ('description', models.TextField(blank=True, verbose_name='description')), + ('order', models.IntegerField(verbose_name='order', default=10000)), + ('created_date', models.DateTimeField(verbose_name='created date', default=django.utils.timezone.now)), + ('modified_date', models.DateTimeField(verbose_name='modified date')), + ('project', models.ForeignKey(to='projects.Project', verbose_name='project', related_name='userstorycustomattributes')), + ], + options={ + 'ordering': ['project', 'order', 'name'], + 'verbose_name': 'user story custom attribute', + 'verbose_name_plural': 'user story custom attributes', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='userstorycustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='taskcustomattribute', + unique_together=set([('project', 'name')]), + ), + migrations.AlterUniqueTogether( + name='issuecustomattribute', + unique_together=set([('project', 'name')]), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py new file mode 100644 index 00000000..8c1848db --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import django_pgjson.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0005_auto_20150114_0954'), + ('issues', '0004_auto_20150114_0954'), + ('userstories', '0009_remove_userstory_is_archived'), + ('custom_attributes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IssueCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('issue', models.OneToOneField(verbose_name='issue', to='issues.Issue', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'issue custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'issue ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TaskCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('task', models.OneToOneField(verbose_name='task', to='tasks.Task', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'task custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'task ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UserStoryCustomAttributesValues', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('version', models.IntegerField(default=1, verbose_name='version')), + ('attributes_values', django_pgjson.fields.JsonField(default={}, verbose_name='attributes_values')), + ('user_story', models.OneToOneField(verbose_name='user story', to='userstories.UserStory', related_name='custom_attributes_values')), + ], + options={ + 'verbose_name_plural': 'user story custom attributes values', + 'ordering': ['id'], + 'verbose_name': 'user story ustom attributes values', + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py new file mode 100644 index 00000000..43285e38 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0003_triggers_on_delete_customattribute.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0002_issuecustomattributesvalues_taskcustomattributesvalues_userstorycustomattributesvalues'), + ] + + operations = [ + # Function: Remove a key in a json field + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + RETURNS json + LANGUAGE sql + IMMUTABLE + STRICT + AS $function$ + SELECT COALESCE ((SELECT ('{' || string_agg(to_json("key") || ':' || "value", ',') || '}') + FROM json_each("json") + WHERE "key" <> ALL ("keys_to_delete")), + '{}')::json $function$; + """, + reverse_sql="""DROP FUNCTION IF EXISTS "json_object_delete_keys"("json" json, VARIADIC "keys_to_delete" text[]) + CASCADE;""" + ), + + # Function: Romeve a key in the json field of *_custom_attributes_values.values + migrations.RunSQL( + """ + CREATE OR REPLACE FUNCTION "clean_key_in_custom_attributes_values"() + RETURNS trigger + AS $clean_key_in_custom_attributes_values$ + DECLARE + key text; + tablename text; + BEGIN + key := OLD.id::text; + tablename := TG_ARGV[0]::text; + + EXECUTE 'UPDATE ' || quote_ident(tablename) || ' + SET attributes_values = json_object_delete_keys(attributes_values, ' || + quote_literal(key) || ')'; + + RETURN NULL; + END; $clean_key_in_custom_attributes_values$ + LANGUAGE plpgsql; + + """, + reverse_sql="""DROP FUNCTION IF EXISTS "clean_key_in_custom_attributes_values"() + CASCADE;""" + ), + + # Trigger: Clean userstorycustomattributes values before remove a userstorycustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_userstorycustomvalues_after_remove_userstorycustomattribute" + AFTER DELETE ON custom_attributes_userstorycustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_userstorycustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_userstorycustomvalues_after_remove_userstorycustomattribute" + ON custom_attributes_userstorycustomattribute + CASCADE;""" + ), + + # Trigger: Clean taskcustomattributes values before remove a taskcustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_taskcustomvalues_after_remove_taskcustomattribute" + AFTER DELETE ON custom_attributes_taskcustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_taskcustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_taskcustomvalues_after_remove_taskcustomattribute" + ON custom_attributes_taskcustomattribute + CASCADE;""" + ), + + # Trigger: Clean issuecustomattributes values before remove a issuecustomattribute + migrations.RunSQL( + """ + CREATE TRIGGER "update_issuecustomvalues_after_remove_issuecustomattribute" + AFTER DELETE ON custom_attributes_issuecustomattribute + FOR EACH ROW + EXECUTE PROCEDURE clean_key_in_custom_attributes_values('custom_attributes_issuecustomattributesvalues'); + """, + reverse_sql="""DROP TRIGGER IF EXISTS "update_issuecustomvalues_after_remove_issuecustomattribute" + ON custom_attributes_issuecustomattribute + CASCADE;""" + ) + ] diff --git a/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py new file mode 100644 index 00000000..9d807ad4 --- /dev/null +++ b/taiga/projects/custom_attributes/migrations/0004_create_empty_customattributesvalues_for_existen_object.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +def create_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + obj_model = apps.get_model("userstories", "UserStory") + db_alias = schema_editor.connection.alias + + data = [] + for user_story in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(user_story, "custom_attributes_values"): + data.append(cav_model(user_story=user_story,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_user_story_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "UserStoryCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + obj_model = apps.get_model("tasks", "Task") + db_alias = schema_editor.connection.alias + + data = [] + for task in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(task, "custom_attributes_values"): + data.append(cav_model(task=task,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_task_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "TaskCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +def create_empty_issues_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + obj_model = apps.get_model("issues", "Issue") + db_alias = schema_editor.connection.alias + + data = [] + for issue in obj_model.objects.using(db_alias).all().select_related("custom_attributes_values"): + if not hasattr(issue, "custom_attributes_values"): + data.append(cav_model(issue=issue,attributes_values={})) + + cav_model.objects.using(db_alias).bulk_create(data) + + +def delete_empty_issue_custom_attrributes_values(apps, schema_editor): + cav_model = apps.get_model("custom_attributes", "IssueCustomAttributesValues") + db_alias = schema_editor.connection.alias + + cav_model.objects.using(db_alias).extra(where=["attributes_values::text <> '{}'::text"]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('custom_attributes', '0003_triggers_on_delete_customattribute'), + ] + + operations = [ + migrations.RunPython(create_empty_user_story_custom_attrributes_values, + reverse_code=delete_empty_user_story_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_task_custom_attrributes_values, + reverse_code=delete_empty_task_custom_attrributes_values, + atomic=True), + migrations.RunPython(create_empty_issues_custom_attrributes_values, + reverse_code=delete_empty_issue_custom_attrributes_values, + atomic=True), + ] diff --git a/taiga/projects/custom_attributes/migrations/__init__.py b/taiga/projects/custom_attributes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/taiga/projects/custom_attributes/models.py b/taiga/projects/custom_attributes/models.py new file mode 100644 index 00000000..2fc0cf5c --- /dev/null +++ b/taiga/projects/custom_attributes/models.py @@ -0,0 +1,130 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone + +from django_pgjson.fields import JsonField + +from taiga.projects.occ.mixins import OCCModelMixin + + +###################################################### +# Custom Attribute Models +####################################################### + +class AbstractCustomAttribute(models.Model): + name = models.CharField(null=False, blank=False, max_length=64, verbose_name=_("name")) + description = models.TextField(null=False, blank=True, verbose_name=_("description")) + order = models.IntegerField(null=False, blank=False, default=10000, verbose_name=_("order")) + project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="%(class)ss", + verbose_name=_("project")) + + created_date = models.DateTimeField(null=False, blank=False, default=timezone.now, + verbose_name=_("created date")) + modified_date = models.DateTimeField(null=False, blank=False, + verbose_name=_("modified date")) + _importing = None + + class Meta: + abstract = True + ordering = ["project", "order", "name"] + unique_together = ("project", "name") + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self._importing or not self.modified_date: + self.modified_date = timezone.now() + + return super().save(*args, **kwargs) + + +class UserStoryCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "user story custom attribute" + verbose_name_plural = "user story custom attributes" + + +class TaskCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "task custom attribute" + verbose_name_plural = "task custom attributes" + + +class IssueCustomAttribute(AbstractCustomAttribute): + class Meta(AbstractCustomAttribute.Meta): + verbose_name = "issue custom attribute" + verbose_name_plural = "issue custom attributes" + + +###################################################### +# Custom Attributes Values Models +####################################################### + +class AbstractCustomAttributesValues(OCCModelMixin, models.Model): + attributes_values = JsonField(null=False, blank=False, default={}, verbose_name=_("attributes_values")) + + class Meta: + abstract = True + ordering = ["id"] + + +class UserStoryCustomAttributesValues(AbstractCustomAttributesValues): + user_story = models.OneToOneField("userstories.UserStory", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("user story")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "user story ustom attributes values" + verbose_name_plural = "user story custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.user_story.project + + +class TaskCustomAttributesValues(AbstractCustomAttributesValues): + task = models.OneToOneField("tasks.Task", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("task")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "task ustom attributes values" + verbose_name_plural = "task custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.task.project + + +class IssueCustomAttributesValues(AbstractCustomAttributesValues): + issue = models.OneToOneField("issues.Issue", + null=False, blank=False, related_name="custom_attributes_values", + verbose_name=_("issue")) + + class Meta(AbstractCustomAttributesValues.Meta): + verbose_name = "issue ustom attributes values" + verbose_name_plural = "issue custom attributes values" + + @property + def project(self): + # NOTE: This property simplifies checking permissions + return self.issue.project diff --git a/taiga/projects/custom_attributes/permissions.py b/taiga/projects/custom_attributes/permissions.py new file mode 100644 index 00000000..160d340e --- /dev/null +++ b/taiga/projects/custom_attributes/permissions.py @@ -0,0 +1,83 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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 TaigaResourcePermission +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsProjectOwner +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsSuperUser + + +###################################################### +# Custom Attribute Permissions +####################################################### + +class UserStoryCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +class TaskCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +class IssueCustomAttributePermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() + bulk_update_order_perms = IsProjectOwner() + + +###################################################### +# Custom Attributes Values Permissions +####################################################### + +class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_us') + update_perms = HasProjectPerm('modify_us') + + +class TaskCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_tasks') + update_perms = HasProjectPerm('modify_task') + + +class IssueCustomAttributesValuesPermission(TaigaResourcePermission): + enought_perms = IsProjectOwner() | IsSuperUser() + global_perms = None + retrieve_perms = HasProjectPerm('view_issues') + update_perms = HasProjectPerm('modify_issue') diff --git a/taiga/projects/custom_attributes/serializers.py b/taiga/projects/custom_attributes/serializers.py new file mode 100644 index 00000000..dbc367f4 --- /dev/null +++ b/taiga/projects/custom_attributes/serializers.py @@ -0,0 +1,146 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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 apps +from django.utils.translation import ugettext_lazy as _ + +from rest_framework.serializers import ValidationError + +from taiga.base.serializers import ModelSerializer +from taiga.base.serializers import JsonField + +from . import models + + +###################################################### +# Custom Attribute Serializer +####################################################### + +class BaseCustomAttributeSerializer(ModelSerializer): + class Meta: + read_only_fields = ('id',) + exclude = ('created_date', 'modified_date') + + def _validate_integrity_between_project_and_name(self, attrs, source): + """ + Check the name is not duplicated in the project. Check when: + - create a new one + - update the name + - update the project (move to another project) + """ + data_id = attrs.get("id", None) + data_name = attrs.get("name", None) + data_project = attrs.get("project", None) + + if self.object: + data_id = data_id or self.object.id + data_name = data_name or self.object.name + data_project = data_project or self.object.project + + model = self.Meta.model + qs = (model.objects.filter(project=data_project, name=data_name) + .exclude(id=data_id)) + if qs.exists(): + raise ValidationError(_("Already exists one with the same name.")) + + return attrs + + def validate_name(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + def validate_project(self, attrs, source): + return self._validate_integrity_between_project_and_name(attrs, source) + + +class UserStoryCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.UserStoryCustomAttribute + + +class TaskCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.TaskCustomAttribute + + +class IssueCustomAttributeSerializer(BaseCustomAttributeSerializer): + class Meta(BaseCustomAttributeSerializer.Meta): + model = models.IssueCustomAttribute + + +###################################################### +# Custom Attribute Serializer +####################################################### + + +class BaseCustomAttributesValuesSerializer(ModelSerializer): + attributes_values = JsonField(source="attributes_values", label="attributes values") + _custom_attribute_model = None + _container_field = None + + class Meta: + exclude = ("id",) + + def validate_attributes_values(self, attrs, source): + # values must be a dict + data_values = attrs.get("attributes_values", None) + if self.object: + data_values = (data_values or self.object.attributes_values) + + if type(data_values) is not dict: + raise ValidationError(_("Invalid content. It must be {\"key\": \"value\",...}")) + + # Values keys must be in the container object project + data_container = attrs.get(self._container_field, None) + if data_container: + project_id = data_container.project_id + elif self.object: + project_id = getattr(self.object, self._container_field).project_id + else: + project_id = None + + values_ids = list(data_values.keys()) + qs = self._custom_attribute_model.objects.filter(project=project_id, + id__in=values_ids) + if qs.count() != len(values_ids): + raise ValidationError(_("It contain invalid custom fields.")) + + return attrs + + +class UserStoryCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer): + _custom_attribute_model = models.UserStoryCustomAttribute + _container_model = "userstories.UserStory" + _container_field = "user_story" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.UserStoryCustomAttributesValues + + +class TaskCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): + _custom_attribute_model = models.TaskCustomAttribute + _container_field = "task" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.TaskCustomAttributesValues + + +class IssueCustomAttributesValuesSerializer(BaseCustomAttributesValuesSerializer, ModelSerializer): + _custom_attribute_model = models.IssueCustomAttribute + _container_field = "issue" + + class Meta(BaseCustomAttributesValuesSerializer.Meta): + model = models.IssueCustomAttributesValues diff --git a/taiga/projects/custom_attributes/services.py b/taiga/projects/custom_attributes/services.py new file mode 100644 index 00000000..7cbea6c4 --- /dev/null +++ b/taiga/projects/custom_attributes/services.py @@ -0,0 +1,69 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.db import transaction +from django.db import connection + + +@transaction.atomic +def bulk_update_userstory_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_userstorycustomattribute set "order" = $1 + where custom_attributes_userstorycustomattribute.id = $2 and + custom_attributes_userstorycustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_task_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_taskcustomattribute set "order" = $1 + where custom_attributes_taskcustomattribute.id = $2 and + custom_attributes_taskcustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() + + +@transaction.atomic +def bulk_update_issue_custom_attribute_order(project, user, data): + cursor = connection.cursor() + + sql = """ + prepare bulk_update_order as update custom_attributes_issuecustomattribute set "order" = $1 + where custom_attributes_issuecustomattribute.id = $2 and + custom_attributes_issuecustomattribute.project_id = $3; + """ + cursor.execute(sql) + for id, order in data: + cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);", + (order, id, project.id)) + cursor.execute("DEALLOCATE bulk_update_order") + cursor.close() diff --git a/taiga/projects/custom_attributes/signals.py b/taiga/projects/custom_attributes/signals.py new file mode 100644 index 00000000..fa90bb10 --- /dev/null +++ b/taiga/projects/custom_attributes/signals.py @@ -0,0 +1,35 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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 models + + +def create_custom_attribute_value_when_create_user_story(sender, instance, created, **kwargs): + if created: + models.UserStoryCustomAttributesValues.objects.get_or_create(user_story=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_task(sender, instance, created, **kwargs): + if created: + models.TaskCustomAttributesValues.objects.get_or_create(task=instance, + defaults={"attributes_values":{}}) + + +def create_custom_attribute_value_when_create_issue(sender, instance, created, **kwargs): + if created: + models.IssueCustomAttributesValues.objects.get_or_create(issue=instance, + defaults={"attributes_values":{}}) diff --git a/taiga/projects/history/api.py b/taiga/projects/history/api.py index ad011af9..49023f94 100644 --- a/taiga/projects/history/api.py +++ b/taiga/projects/history/api.py @@ -15,14 +15,12 @@ # along with this program. If not, see . from django.contrib.contenttypes.models import ContentType -from django.shortcuts import get_object_or_404 from django.utils import timezone -from rest_framework.response import Response -from rest_framework import status - +from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import ReadOnlyListViewSet +from taiga.base.api.utils import get_object_or_404 from . import permissions from . import serializers @@ -54,7 +52,7 @@ class HistoryViewSet(ReadOnlyListViewSet): else: serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return response.Ok(serializer.data) @detail_route(methods=['post']) def delete_comment(self, request, pk): @@ -65,15 +63,15 @@ class HistoryViewSet(ReadOnlyListViewSet): self.check_permissions(request, 'delete_comment', comment) if comment is None: - return Response(status=status.HTTP_404_NOT_FOUND) + return response.NotFound() if comment.delete_comment_date or comment.delete_comment_user: - return Response({"error": "Comment already deleted"}, status=status.HTTP_400_BAD_REQUEST) + return response.BadRequest({"error": "Comment already deleted"}) comment.delete_comment_date = timezone.now() comment.delete_comment_user = {"pk": request.user.pk, "name": request.user.get_full_name()} comment.save() - return Response(status=status.HTTP_200_OK) + return response.Ok() @detail_route(methods=['post']) def undelete_comment(self, request, pk): @@ -84,20 +82,20 @@ class HistoryViewSet(ReadOnlyListViewSet): self.check_permissions(request, 'undelete_comment', comment) if comment is None: - return Response(status=status.HTTP_404_NOT_FOUND) + return response.NotFound() if not comment.delete_comment_date and not comment.delete_comment_user: - return Response({"error": "Comment not deleted"}, status=status.HTTP_400_BAD_REQUEST) + return response.BadRequest({"error": "Comment not deleted"}) comment.delete_comment_date = None comment.delete_comment_user = None comment.save() - return Response(status=status.HTTP_200_OK) + return response.Ok() # Just for restframework! Because it raises # 404 on main api root if this method not exists. def list(self, request): - return Response({}) + return response.NotFound() def retrieve(self, request, pk): obj = self.get_object() diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py index 1af7bcf0..54c9eefb 100644 --- a/taiga/projects/history/freeze_impl.py +++ b/taiga/projects/history/freeze_impl.py @@ -14,9 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from contextlib import suppress + from functools import partial from django.apps import apps from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist + from taiga.base.utils.iterators import as_tuple from taiga.base.utils.iterators import as_dict from taiga.mdrender.service import render as mdrender @@ -49,6 +53,16 @@ def _get_users_values(ids:set) -> dict: yield str(user.pk), user.get_full_name() +@as_dict +def _get_user_story_values(ids:set) -> dict: + userstory_model = apps.get_model("userstories", "UserStory") + ids = filter(lambda x: x is not None, ids) + qs = userstory_model.objects.filter(pk__in=tuple(ids)) + + for userstory in qs: + yield str(userstory.pk), "#{} {}".format(userstory.ref, userstory.subject) + + _get_us_status_values = partial(_get_generic_values, typename="projects.userstorystatus") _get_task_status_values = partial(_get_generic_values, typename="projects.taskstatus") _get_issue_status_values = partial(_get_generic_values, typename="projects.issuestatus") @@ -137,6 +151,8 @@ def task_values(diff): values["status"] = _get_task_status_values(diff["status"]) if "milestone" in diff: values["milestone"] = _get_milestone_values(diff["milestone"]) + if "user_story" in diff: + values["user_story"] = _get_user_story_values(diff["user_story"]) return values @@ -169,6 +185,42 @@ def extract_attachments(obj) -> list: "order": attach.order} +@as_tuple +def extract_user_story_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.userstorycustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + +@as_tuple +def extract_task_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.taskcustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + +@as_tuple +def extract_issue_custom_attributes(obj) -> list: + with suppress(ObjectDoesNotExist): + custom_attributes_values = obj.custom_attributes_values.attributes_values + for attr in obj.project.issuecustomattributes.all(): + with suppress(KeyError): + value = custom_attributes_values[str(attr.id)] + yield {"id": attr.id, + "name": attr.name, + "value": value} + + def project_freezer(project) -> dict: fields = ("name", "slug", @@ -228,6 +280,10 @@ def userstory_freezer(us) -> dict: "tags": us.tags, "points": points, "from_issue": us.generated_from_issue_id, + "is_blocked": us.is_blocked, + "blocked_note": us.blocked_note, + "blocked_note_html": mdrender(us.project, us.blocked_note), + "custom_attributes": extract_user_story_custom_attributes(us), } return snapshot @@ -249,6 +305,10 @@ def issue_freezer(issue) -> dict: "watchers": [x.pk for x in issue.watchers.all()], "attachments": extract_attachments(issue), "tags": issue.tags, + "is_blocked": issue.is_blocked, + "blocked_note": issue.blocked_note, + "blocked_note_html": mdrender(issue.project, issue.blocked_note), + "custom_attributes": extract_issue_custom_attributes(issue), } return snapshot @@ -271,6 +331,10 @@ def task_freezer(task) -> dict: "tags": task.tags, "user_story": task.user_story_id, "is_iocaine": task.is_iocaine, + "is_blocked": task.is_blocked, + "blocked_note": task.blocked_note, + "blocked_note_html": mdrender(task.project, task.blocked_note), + "custom_attributes": extract_task_custom_attributes(task), } return snapshot diff --git a/taiga/projects/history/migrations/0006_fix_json_field_not_null.py b/taiga/projects/history/migrations/0006_fix_json_field_not_null.py new file mode 100644 index 00000000..8c8ae187 --- /dev/null +++ b/taiga/projects/history/migrations/0006_fix_json_field_not_null.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django_pgjson.fields import JsonField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0005_auto_20141120_1119'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "user" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "diff" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "snapshot" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "values" DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE history_historyentry ALTER COLUMN "delete_comment_user" DROP NOT NULL;', + ), + ] diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py index 0cf9d0e5..468eb439 100644 --- a/taiga/projects/history/models.py +++ b/taiga/projects/history/models.py @@ -99,6 +99,19 @@ class HistoryEntry(models.Model): result = {} users_keys = ["assigned_to", "owner"] + def resolve_diff_value(key): + value = None + diff = get_diff_of_htmls( + self.diff[key][0] or "", + self.diff[key][1] or "" + ) + + if diff: + key = "{}_diff".format(key) + value = (None, diff) + + return (key, value) + def resolve_value(field, key): data = self.values[field] key = str(key) @@ -114,24 +127,12 @@ class HistoryEntry(models.Model): # on old HistoryEntry objects. if key == "description_diff": continue - elif key == "description": - description_diff = get_diff_of_htmls( - self.diff[key][0], - self.diff[key][1] - ) - - if description_diff: - key = "description_diff" - value = (None, description_diff) - elif key == "content": - content_diff = get_diff_of_htmls( - self.diff[key][0], - self.diff[key][1] - ) - - if content_diff: - key = "content_diff" - value = (None, content_diff) + elif key == "content_diff": + continue + elif key == "blocked_note_diff": + continue + elif key in["description", "content", "blocked_note"]: + (key, value) = resolve_diff_value(key) elif key in users_keys: value = [resolve_value("users", x) for x in self.diff[key]] elif key == "watchers": @@ -196,6 +197,35 @@ class HistoryEntry(models.Model): if attachments["new"] or attachments["changed"] or attachments["deleted"]: value = attachments + elif key == "custom_attributes": + custom_attributes = { + "new": [], + "changed": [], + "deleted": [], + } + + oldcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][0] or []} + newcustattrs = {x["id"]:x for x in self.diff["custom_attributes"][1] or []} + + for aid in set(tuple(oldcustattrs.keys()) + tuple(newcustattrs.keys())): + if aid in oldcustattrs and aid in newcustattrs: + changes = make_diff_from_dicts(oldcustattrs[aid], newcustattrs[aid], + excluded_keys=("name")) + + if changes: + change = { + "name": newcustattrs.get(aid, {}).get("name", ""), + "changes": changes + } + custom_attributes["changed"].append(change) + elif aid in oldcustattrs and aid not in newcustattrs: + custom_attributes["deleted"].append(oldcustattrs[aid]) + elif aid not in oldcustattrs and aid in newcustattrs: + custom_attributes["new"].append(newcustattrs[aid]) + + if custom_attributes["new"] or custom_attributes["changed"] or custom_attributes["deleted"]: + value = custom_attributes + elif key in self.values: value = [resolve_value(key, x) for x in self.diff[key]] else: diff --git a/taiga/projects/history/services.py b/taiga/projects/history/services.py index c47fab2a..3be5e8da 100644 --- a/taiga/projects/history/services.py +++ b/taiga/projects/history/services.py @@ -37,6 +37,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.paginator import Paginator, InvalidPage from django.apps import apps from django.db import transaction as tx +from django_pglocks import advisory_lock from taiga.mdrender.service import render as mdrender from taiga.base.utils.db import get_typename_for_model_class @@ -269,6 +270,7 @@ def get_modified_fields(obj:object, last_modifications): return modified_fields + @tx.atomic def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): """ @@ -280,56 +282,57 @@ def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): """ key = make_key_from_model_object(obj) - typename = get_typename_for_model_class(obj.__class__) + with advisory_lock(key) as acquired_key_lock: + typename = get_typename_for_model_class(obj.__class__) - new_fobj = freeze_model_instance(obj) - old_fobj, need_real_snapshot = get_last_snapshot_for_key(key) + new_fobj = freeze_model_instance(obj) + old_fobj, need_real_snapshot = get_last_snapshot_for_key(key) - entry_model = apps.get_model("history", "HistoryEntry") - user_id = None if user is None else user.id - user_name = "" if user is None else user.get_full_name() + entry_model = apps.get_model("history", "HistoryEntry") + user_id = None if user is None else user.id + user_name = "" if user is None else user.get_full_name() - # Determine history type - if delete: - entry_type = HistoryType.delete - elif new_fobj and not old_fobj: - entry_type = HistoryType.create - elif new_fobj and old_fobj: - entry_type = HistoryType.change - else: - raise RuntimeError("Unexpected condition") + # Determine history type + if delete: + entry_type = HistoryType.delete + elif new_fobj and not old_fobj: + entry_type = HistoryType.create + elif new_fobj and old_fobj: + entry_type = HistoryType.change + else: + raise RuntimeError("Unexpected condition") - fdiff = make_diff(old_fobj, new_fobj) + fdiff = make_diff(old_fobj, new_fobj) - # If diff and comment are empty, do - # not create empty history entry - if (not fdiff.diff and not comment - and old_fobj is not None - and entry_type != HistoryType.delete): + # If diff and comment are empty, do + # not create empty history entry + if (not fdiff.diff and not comment + and old_fobj is not None + and entry_type != HistoryType.delete): - return None + return None - fvals = make_diff_values(typename, fdiff) + fvals = make_diff_values(typename, fdiff) - if len(comment) > 0: - is_hidden = False - else: - is_hidden = is_hidden_snapshot(fdiff) + if len(comment) > 0: + is_hidden = False + else: + is_hidden = is_hidden_snapshot(fdiff) - kwargs = { - "user": {"pk": user_id, "name": user_name}, - "key": key, - "type": entry_type, - "snapshot": fdiff.snapshot if need_real_snapshot else None, - "diff": fdiff.diff, - "values": fvals, - "comment": comment, - "comment_html": mdrender(obj.project, comment), - "is_hidden": is_hidden, - "is_snapshot": need_real_snapshot, - } + kwargs = { + "user": {"pk": user_id, "name": user_name}, + "key": key, + "type": entry_type, + "snapshot": fdiff.snapshot if need_real_snapshot else None, + "diff": fdiff.diff, + "values": fvals, + "comment": comment, + "comment_html": mdrender(obj.project, comment), + "is_hidden": is_hidden, + "is_snapshot": need_real_snapshot, + } - return entry_model.objects.create(**kwargs) + return entry_model.objects.create(**kwargs) # High level query api 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 2d86404e..84a2d462 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja @@ -6,7 +6,8 @@ "backlog_order", "kanban_order", "taskboard_order", - "us_order" + "us_order", + "custom_attributes" ] %} {% for field_name, values in changed_fields.items() %} @@ -20,13 +21,13 @@ {{ _("from") }}
- {{ points.1 }} + {{ points.0 }} {{ _("to") }}
- {{ points.0 }} + {{ points.1 }} {% endfor %} @@ -80,9 +81,7 @@

{{ _("Deleted attachment") }}

- {% if att.changes.description %}

{{ att.filename|linebreaksbr }}

- {% endif %} {% endfor %} @@ -91,19 +90,23 @@ {% elif field_name in ["tags", "watchers"] %} {% set values_from = values.0 or [] %} {% set values_to = values.1 or [] %} + {% set values_added = lists_diff(values_to, values_from) %} + {% set values_removed = lists_diff(values_from, values_to) %} + -

{{ field_name }}

+

{{ verbose_name(obj_class, field_name) }}

- {{ _("from") }}
- {{ ', '.join(values_from) }} - - - - - {{ _("to") }}
- {{ ', '.join(values_to) }} + {% if values_added %} + {{ _("added") }}
+ {{ ', '.join(values_added) }} + {% endif %} + + {% if values_removed %} + {{ _("removed") }}
+ {{ ', '.join(values_removed) }} + {% endif %} {# DESCRIPTIONS #} @@ -126,7 +129,7 @@ {% elif field_name == "assigned_to" %} -

{{ field_name }}

+

{{ verbose_name(obj_class, field_name) }}

{% if values.0 != None and values.0 != "" %} @@ -151,10 +154,9 @@ {# * #} {% else %} - -

{{ field_name }}

+

{{ verbose_name(obj_class, field_name) }}

{{ _("from") }}
@@ -168,5 +170,52 @@ {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% if values.new %} + {% for attr in values['new']%} + + +

{{ attr.name }}

+ + + + + {{ _("to") }}
+ {{ attr.value|linebreaksbr }} + + + {% endfor %} + {% endif %} + {% if values.changed %} + {% for attr in values['changed'] %} + + +

{{ attr.name }}

+ + + {{ _("from") }}
+ {{ attr.changes.value.0|linebreaksbr }} + + + + + {{ _("to") }}
+ {{ attr.changes.value.1|linebreaksbr }} + + + {% endfor %} + {% endif %} + {% if values.deleted %} + {% for attr in values['deleted']%} + + +

{{ attr.name }}

+

{{ _("-deleted-") }}

+ + + {% endfor %} + {% endif %} {% endif %} {% endfor %} 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 d53a205b..5ecbf496 100644 --- a/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja +++ b/taiga/projects/history/templates/emails/includes/fields_diff-text.jinja @@ -6,16 +6,20 @@ "backlog_order", "kanban_order", "taskboard_order", - "us_order" + "us_order", + "blocked_note_diff", + "blocked_note_html", + "custom_attributes" ] %} {% for field_name, values in changed_fields.items() %} {% if field_name not in excluded_fields %} - - {{ verbose_name(object, field_name) }}: + - {{ verbose_name(obj_class, field_name) }}: {# POINTS #} {% if field_name == "points" %} {% for role, points in values.items() %} * {{ role }} {{ _("to:") }} {{ points.1 }} {{ _("from:") }} {{ points.0 }} {% endfor %} + {# ATTACHMENTS #} {% elif field_name == "attachments" %} {% if values.new %} @@ -38,20 +42,50 @@ - {{ att.filename }} {% endfor %} {% endif %} + {# TAGS AND WATCHERS #} {% elif field_name in ["tags", "watchers"] %} - * {{ _("to:") }} {{ ', '.join(values.1) }} - {% if values.0 %} - * {{ _("from:") }} {{ ', '.join(values.0) }} + {% set values_from = values.0 or [] %} + {% set values_to = values.1 or [] %} + {% set values_added = lists_diff(values_to, values_from) %} + {% set values_removed = lists_diff(values_from, values_to) %} + + {% if values_added %} + * {{ _("added:") }} {{ ', '.join(values_added) }} {% endif %} + {% if values_removed %} + * {{ _("removed:") }} {{ ', '.join(values_removed) }} + {% endif %} + {# * #} {% else %} - {% if values.1 != None and values.1 != "" %} - * {{ _("to:") }} {{ values.1|linebreaksbr }} - {% endif %} - {% if values.0 != None and values.0 != "" %} - * {{ _("from:") }} {{ values.0|linebreaksbr }} - {% endif %} + * {{ _("From:") }} {{ values.0 }} + * {{ _("To:") }} {{ values.1 }} {% endif %} + + {% elif field_name == "custom_attributes" %} + {# CUSTOM ATTRIBUTES #} + {% elif field_name == "attachments" %} + {% if values.new %} + {% for attr in values['new']%} + - {{ attr.name }}: + * {{ attr.value }} + {% endfor %} + {% endif %} + + {% if values.changed %} + {% for attr in values['changed'] %} + - {{ attr.name }}: + * {{ _("From:") }} {{ attr.changes.value.0 }} + * {{ _("To:") }} {{ attr.changes.value.1 }} + {% endfor %} + {% endif %} + + {% if values.deleted %} + {% for attr in values['deleted']%} + - {{ attr.name }}: {{ _("-deleted-") }} + * {{ attr.value }} + {% endfor %} + {% endif %} {% endif %} {% endfor %} diff --git a/taiga/projects/history/templatetags/functions.py b/taiga/projects/history/templatetags/functions.py index c66ef67f..eef5133b 100644 --- a/taiga/projects/history/templatetags/functions.py +++ b/taiga/projects/history/templatetags/functions.py @@ -23,16 +23,21 @@ register = library.Library() EXTRA_FIELD_VERBOSE_NAMES = { "description_diff": _("description"), - "content_diff": _("content") + "content_diff": _("content"), + "blocked_note_diff": _("blocked note") } @register.global_function -def verbose_name(obj:object, field_name:str) -> str: +def verbose_name(obj_class, field_name): if field_name in EXTRA_FIELD_VERBOSE_NAMES: return EXTRA_FIELD_VERBOSE_NAMES[field_name] try: - return obj._meta.get_field(field_name).verbose_name + return obj_class._meta.get_field(field_name).verbose_name except Exception: return field_name + +@register.global_function +def lists_diff(list1, list2): + return (list(set(list1) - set(list2))) diff --git a/taiga/projects/issues/api.py b/taiga/projects/issues/api.py index cdae587f..d7879640 100644 --- a/taiga/projects/issues/api.py +++ b/taiga/projects/issues/api.py @@ -15,18 +15,15 @@ # along with this program. If not, see . from django.utils.translation import ugettext_lazy as _ -from django.shortcuts import get_object_or_404 from django.db.models import Q -from django.http import Http404 +from django.http import Http404, HttpResponse -from rest_framework.response import Response -from rest_framework import status - -from taiga.base import filters, response +from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base import response from taiga.base.decorators import detail_route, list_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet -from taiga.base import tags +from taiga.base.api.utils import get_object_or_404 from taiga.users.models import User @@ -139,19 +136,24 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, super().pre_conditions_on_save(obj) if obj.milestone and obj.milestone.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions to set this milestone to this issue.")) + raise exc.PermissionDenied(_("You don't have permissions to set this sprint " + "to this issue.")) if obj.status and obj.status.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions to set this status to this issue.")) + raise exc.PermissionDenied(_("You don't have permissions to set this status " + "to this issue.")) if obj.severity and obj.severity.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions to set this severity to this issue.")) + raise exc.PermissionDenied(_("You don't have permissions to set this severity " + "to this issue.")) if obj.priority and obj.priority.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions to set this priority to this issue.")) + raise exc.PermissionDenied(_("You don't have permissions to set this priority " + "to this issue.")) if obj.type and obj.type.project != obj.project: - raise exc.PermissionDenied(_("You don't have permissions to set this type to this issue.")) + raise exc.PermissionDenied(_("You don't have permissions to set this type " + "to this issue.")) @list_route(methods=["GET"]) def by_ref(self, request): @@ -160,6 +162,19 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, issue = get_object_or_404(models.Issue, ref=ref, project_id=project_id) return self.retrieve(request, pk=issue.pk) + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_404(Project, issues_csv_uuid=uuid) + queryset = project.issues.all().order_by('ref') + data = services.issues_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv') + csv_response['Content-Disposition'] = 'attachment; filename="issues.csv"' + return csv_response + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.IssuesBulkSerializer(data=request.DATA) @@ -185,7 +200,7 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, self.check_permissions(request, 'upvote', issue) votes_service.add_vote(issue, user=request.user) - return Response(status=status.HTTP_200_OK) + return response.Ok() @detail_route(methods=['post']) def downvote(self, request, pk=None): @@ -194,7 +209,7 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, self.check_permissions(request, 'downvote', issue) votes_service.remove_vote(issue, user=request.user) - return Response(status=status.HTTP_200_OK) + return response.Ok() class VotersViewSet(ModelListViewSet): @@ -215,7 +230,7 @@ class VotersViewSet(ModelListViewSet): raise Http404 serializer = self.get_serializer(self.object) - return Response(serializer.data) + return response.Ok(serializer.data) def list(self, request, *args, **kwargs): issue_id = kwargs.get("issue_id", None) diff --git a/taiga/projects/issues/apps.py b/taiga/projects/issues/apps.py index 4b8714ff..3972ef48 100644 --- a/taiga/projects/issues/apps.py +++ b/taiga/projects/issues/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -39,3 +40,8 @@ class IssuesAppConfig(AppConfig): sender=apps.get_model("issues", "Issue")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("issues", "Issue")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue, + sender=apps.get_model("issues", "Issue"), + dispatch_uid="create_custom_attribute_value_when_create_issue") diff --git a/taiga/projects/issues/permissions.py b/taiga/projects/issues/permissions.py index b5689a7e..0a106092 100644 --- a/taiga/projects/issues/permissions.py +++ b/taiga/projects/issues/permissions.py @@ -28,6 +28,7 @@ class IssuePermission(TaigaResourcePermission): update_perms = HasProjectPerm('modify_issue') destroy_perms = HasProjectPerm('delete_issue') list_perms = AllowAny() + csv_perms = AllowAny() upvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues') downvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues') bulk_create_perms = HasProjectPerm('add_issue') diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 711cefd9..a951c369 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, +from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, PgArrayField, ModelSerializer) from taiga.mdrender.service import render as mdrender diff --git a/taiga/projects/issues/services.py b/taiga/projects/issues/services.py index ac2f98ce..0733bc87 100644 --- a/taiga/projects/issues/services.py +++ b/taiga/projects/issues/services.py @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import io +import csv + from taiga.base.utils import db, text from . import models @@ -58,3 +61,42 @@ def update_issues_order_in_bulk(bulk_data): issue_ids.append(issue_id) new_order_values.append({"order": new_order_value}) db.update_in_bulk_with_ids(issue_ids, new_order_values, model=models.Issue) + + +def issues_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["ref", "subject", "description", "milestone", "owner", + "owner_full_name", "assigned_to", "assigned_to_full_name", + "status", "severity", "priority", "type", "is_closed", + "attachments", "external_reference"] + for custom_attr in project.issuecustomattributes.all(): + fieldnames.append(custom_attr.name) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for issue in queryset: + issue_data = { + "ref": issue.ref, + "subject": issue.subject, + "description": issue.description, + "milestone": issue.milestone.name if issue.milestone else None, + "owner": issue.owner.username, + "owner_full_name": issue.owner.get_full_name(), + "assigned_to": issue.assigned_to.username if issue.assigned_to else None, + "assigned_to_full_name": issue.assigned_to.get_full_name() if issue.assigned_to else None, + "status": issue.status.name, + "severity": issue.severity.name, + "priority": issue.priority.name, + "type": issue.type.name, + "is_closed": issue.is_closed, + "attachments": issue.attachments.count(), + "external_reference": issue.external_reference, + } + + for custom_attr in project.issuecustomattributes.all(): + value = issue.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + issue_data[custom_attr.name] = value + + writer.writerow(issue_data) + + return csv_data diff --git a/taiga/projects/management/commands/sample_data.py b/taiga/projects/management/commands/sample_data.py index f88c2ff0..d81cfeda 100644 --- a/taiga/projects/management/commands/sample_data.py +++ b/taiga/projects/management/commands/sample_data.py @@ -27,6 +27,7 @@ from django.contrib.contenttypes.models import ContentType from sampledatahelper.helper import SampleDataHelper from taiga.users.models import * +from taiga.permissions.permissions import ANON_PERMISSIONS from taiga.projects.models import * from taiga.projects.milestones.models import * from taiga.projects.userstories.models import * @@ -34,7 +35,7 @@ from taiga.projects.tasks.models import * from taiga.projects.issues.models import * from taiga.projects.wiki.models import * from taiga.projects.attachments.models import * - +from taiga.projects.custom_attributes.models import * from taiga.projects.history.services import take_snapshot from taiga.events.apps import disconnect_events_signals @@ -150,6 +151,27 @@ class Command(BaseCommand): if role.computable: computable_project_roles.add(role) + # added custom attributes + if self.sd.boolean: + for i in range(1, 4): + UserStoryCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + if self.sd.boolean: + for i in range(1, 4): + TaskCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + if self.sd.boolean: + for i in range(1, 4): + IssueCustomAttribute.objects.create(name=self.sd.words(1, 3), + description=self.sd.words(3, 12), + project=project, + order=i) + + if x < NUM_PROJECTS: start_date = now() - datetime.timedelta(55) @@ -248,6 +270,14 @@ class Command(BaseCommand): project=project)), tags=self.sd.words(1, 10).split(" ")) + bug.save() + + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.issuecustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + bug.custom_attributes_values.attributes_values = custom_attributes_values + bug.custom_attributes_values.save() + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(bug, i+1) @@ -291,6 +321,12 @@ class Command(BaseCommand): task.save() + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.taskcustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + task.custom_attributes_values.attributes_values = custom_attributes_values + task.custom_attributes_values.save() + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(task, i+1) @@ -328,6 +364,15 @@ class Command(BaseCommand): role_points.save() + us.save() + + custom_attributes_values = {str(ca.id): self.sd.words(1, 12) for ca in project.userstorycustomattributes.all() + if self.sd.boolean()} + if custom_attributes_values: + us.custom_attributes_values.attributes_values = custom_attributes_values + us.custom_attributes_values.save() + + for i in range(self.sd.int(*NUM_ATTACHMENTS)): attachment = self.create_attachment(us, i+1) @@ -345,7 +390,7 @@ class Command(BaseCommand): take_snapshot(us, comment=self.sd.paragraph(), user=us.owner) - + return us def create_milestone(self, project, start_date, end_date): @@ -364,10 +409,15 @@ class Command(BaseCommand): return milestone def create_project(self, counter): + is_private=self.sd.boolean() + anon_permissions = not is_private and list(map(lambda perm: perm[0], ANON_PERMISSIONS)) or [] + public_permissions = not is_private and list(map(lambda perm: perm[0], ANON_PERMISSIONS)) or [] project = Project.objects.create(name='Project Example {0}'.format(counter), description='Project example {0} description'.format(counter), owner=random.choice(self.users), - is_private=False, + is_private=is_private, + anon_permissions=anon_permissions, + public_permissions=public_permissions, total_story_points=self.sd.int(600, 3000), total_milestones=self.sd.int(5,10)) @@ -375,9 +425,9 @@ class Command(BaseCommand): def create_user(self, counter=None, username=None, full_name=None, email=None): counter = counter or self.sd.int() - username = username or 'user{0}'.format(counter) + username = username or "user{0}".format(counter) full_name = full_name or "{} {}".format(self.sd.name('es'), self.sd.surname('es', number=1)) - email = email or self.sd.email() + email = email or "user{0}@taigaio.demo".format(counter) user = User.objects.create(username=username, full_name=full_name, diff --git a/taiga/projects/migrations/0016_fix_json_field_not_null.py b/taiga/projects/migrations/0016_fix_json_field_not_null.py new file mode 100644 index 00000000..aaa04543 --- /dev/null +++ b/taiga/projects/migrations/0016_fix_json_field_not_null.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django_pgjson.fields import JsonField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0015_auto_20141230_1212'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE projects_projectmodulesconfig ALTER COLUMN config DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN default_options DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN us_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN points DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN task_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN issue_statuses DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN issue_types DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN priorities DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN severities DROP NOT NULL;', + ), + migrations.RunSQL( + sql='ALTER TABLE projects_projecttemplate ALTER COLUMN roles DROP NOT NULL;', + ), + ] diff --git a/taiga/projects/migrations/0017_fix_is_private_for_projects.py b/taiga/projects/migrations/0017_fix_is_private_for_projects.py new file mode 100644 index 00000000..c94e6a63 --- /dev/null +++ b/taiga/projects/migrations/0017_fix_is_private_for_projects.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + +def update_existing_projects(apps, schema_editor): + Project = apps.get_model("projects", "Project") + Project.objects.filter(is_private=False).update(is_private=True) + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0016_fix_json_field_not_null'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='is_private', + field=models.BooleanField(verbose_name='is private', default=True), + preserve_default=True, + ), + migrations.RunPython(update_existing_projects), + ] diff --git a/taiga/projects/migrations/0018_auto_20150219_1606.py b/taiga/projects/migrations/0018_auto_20150219_1606.py new file mode 100644 index 00000000..a5f326b4 --- /dev/null +++ b/taiga/projects/migrations/0018_auto_20150219_1606.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0017_fix_is_private_for_projects'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='issues_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='tasks_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + migrations.AddField( + model_name='project', + name='userstories_csv_uuid', + field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True), + preserve_default=True, + ), + ] diff --git a/taiga/projects/migrations/0019_auto_20150311_0821.py b/taiga/projects/migrations/0019_auto_20150311_0821.py new file mode 100644 index 00000000..034bd3ff --- /dev/null +++ b/taiga/projects/migrations/0019_auto_20150311_0821.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import djorm_pgarray.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0018_auto_20150219_1606'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='public_permissions', + field=djorm_pgarray.fields.TextArrayField(choices=[('view_project', 'View project'), ('view_milestones', 'View milestones'), ('add_milestone', 'Add milestone'), ('modify_milestone', 'Modify milestone'), ('delete_milestone', 'Delete milestone'), ('view_us', 'View user story'), ('add_us', 'Add user story'), ('modify_us', 'Modify user story'), ('delete_us', 'Delete user story'), ('view_tasks', 'View tasks'), ('add_task', 'Add task'), ('modify_task', 'Modify task'), ('delete_task', 'Delete task'), ('view_issues', 'View issues'), ('vote_issues', 'Vote issues'), ('add_issue', 'Add issue'), ('modify_issue', 'Modify issue'), ('delete_issue', 'Delete issue'), ('view_wiki_pages', 'View wiki pages'), ('add_wiki_page', 'Add wiki page'), ('modify_wiki_page', 'Modify wiki page'), ('delete_wiki_page', 'Delete wiki page'), ('view_wiki_links', 'View wiki links'), ('add_wiki_link', 'Add wiki link'), ('modify_wiki_link', 'Modify wiki link'), ('delete_wiki_link', 'Delete wiki link')], verbose_name='user permissions', dbtype='text', default=[]), + preserve_default=True, + ), + ] diff --git a/taiga/projects/milestones/api.py b/taiga/projects/milestones/api.py index d669a5bf..132f9bf2 100644 --- a/taiga/projects/milestones/api.py +++ b/taiga/projects/milestones/api.py @@ -14,16 +14,11 @@ # 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 django.shortcuts import get_object_or_404 - -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - from taiga.base import filters -from taiga.base import exceptions as exc +from taiga.base import response from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet +from taiga.base.api.utils import get_object_or_404 from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin @@ -97,4 +92,4 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView current_date = current_date + datetime.timedelta(days=1) optimal_points -= optimal_points_per_day - return Response(milestone_stats) + return response.Ok(milestone_stats) diff --git a/taiga/projects/milestones/services.py b/taiga/projects/milestones/services.py index cac04870..a94be521 100644 --- a/taiga/projects/milestones/services.py +++ b/taiga/projects/milestones/services.py @@ -21,7 +21,8 @@ from . import models def calculate_milestone_is_closed(milestone): - return (all([task.status.is_closed for task in milestone.tasks.all()]) and + return (milestone.user_stories.all().count() > 0 and + all([task.status.is_closed for task in milestone.tasks.all()]) and all([user_story.is_closed for user_story in milestone.user_stories.all()])) diff --git a/taiga/projects/mixins/on_destroy.py b/taiga/projects/mixins/on_destroy.py new file mode 100644 index 00000000..6fe69c7b --- /dev/null +++ b/taiga/projects/mixins/on_destroy.py @@ -0,0 +1,46 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.db import transaction as tx + +from taiga.base.api.utils import get_object_or_404 + + +############################################# +# ViewSets +############################################# + +class MoveOnDestroyMixin: + @tx.atomic + def destroy(self, request, *args, **kwargs): + move_to = self.request.QUERY_PARAMS.get('moveTo', None) + if move_to is None: + return super().destroy(request, *args, **kwargs) + + obj = self.get_object_or_none() + move_item = get_object_or_404(self.model, id=move_to) + + self.check_permissions(request, 'destroy', obj) + + qs = self.move_on_destroy_related_class.objects.filter(**{self.move_on_destroy_related_field: obj}) + qs.update(**{self.move_on_destroy_related_field: move_item}) + + if getattr(obj.project, self.move_on_destroy_project_default_field) == obj: + setattr(obj.project, self.move_on_destroy_project_default_field, move_item) + obj.project.save() + + return super().destroy(request, *args, **kwargs) diff --git a/taiga/projects/mixins/ordering.py b/taiga/projects/mixins/ordering.py new file mode 100644 index 00000000..e9438d27 --- /dev/null +++ b/taiga/projects/mixins/ordering.py @@ -0,0 +1,58 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.utils.translation import ugettext_lazy as _ + +from taiga.base import response +from taiga.base import exceptions as exc +from taiga.base.api.utils import get_object_or_404 +from taiga.base.decorators import list_route + +from taiga.projects.models import Project + + +############################################# +# ViewSets +############################################# + +class BulkUpdateOrderMixin: + """ + This mixin need three fields in the child class: + + - bulk_update_param: that the name of the field of the data received from + the cliente that contains the pairs (id, order) to sort the objects. + - bulk_update_perm: that containts the codename of the permission needed to sort. + - bulk_update_order: method with bulk update order logic + """ + + @list_route(methods=["POST"]) + def bulk_update_order(self, request, **kwargs): + bulk_data = request.DATA.get(self.bulk_update_param, None) + + if bulk_data is None: + raise exc.BadRequest(_("{param} parameter is mandatory".format(param=self.bulk_update_param))) + + project_id = request.DATA.get('project', None) + if project_id is None: + raise exc.BadRequest(_("project parameter is mandatory")) + + project = get_object_or_404(Project, id=project_id) + + self.check_permissions(request, 'bulk_update_order', project) + + self.__class__.bulk_update_order_action(project, request.user, bulk_data) + return response.NoContent(data=None) diff --git a/taiga/projects/models.py b/taiga/projects/models.py index 7912ceab..39d294db 100644 --- a/taiga/projects/models.py +++ b/taiga/projects/models.py @@ -15,6 +15,8 @@ # along with this program. If not, see . import itertools +import uuid + from django.core.exceptions import ValidationError from django.db import models @@ -28,7 +30,7 @@ from django.utils import timezone from django_pgjson.fields import JsonField from djorm_pgarray.fields import TextArrayField -from taiga.permissions.permissions import ANON_PERMISSIONS, USER_PERMISSIONS +from taiga.permissions.permissions import ANON_PERMISSIONS, MEMBERS_PERMISSIONS from taiga.base.tags import TaggedMixin from taiga.base.utils.slug import slugify_uniquely @@ -159,10 +161,19 @@ class Project(ProjectDefaults, TaggedMixin, models.Model): public_permissions = TextArrayField(blank=True, null=True, default=[], verbose_name=_("user permissions"), - choices=USER_PERMISSIONS) - is_private = models.BooleanField(default=False, null=False, blank=True, + choices=MEMBERS_PERMISSIONS) + is_private = models.BooleanField(default=True, null=False, blank=True, verbose_name=_("is private")) + userstories_csv_uuid = models.CharField(max_length=32, editable=False, + null=True, blank=True, + default=None, db_index=True) + tasks_csv_uuid = models.CharField(max_length=32, editable=False, null=True, + blank=True, default=None, db_index=True) + issues_csv_uuid = models.CharField(max_length=32, editable=False, + null=True, blank=True, default=None, + db_index=True) + tags_colors = TextArrayField(dimension=2, null=False, blank=True, verbose_name=_("tags colors"), default=[]) _importing = None diff --git a/taiga/projects/notifications/api.py b/taiga/projects/notifications/api.py index a6cf8e52..2431c7c2 100644 --- a/taiga/projects/notifications/api.py +++ b/taiga/projects/notifications/api.py @@ -14,21 +14,12 @@ # 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 django.shortcuts import get_object_or_404 from django.db.models import Q -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - -from taiga.base import filters -from taiga.base import exceptions as exc -from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet -from taiga.projects.models import Project - from taiga.projects.notifications.choices import NotifyLevel +from taiga.projects.models import Project from . import serializers from . import models diff --git a/taiga/projects/notifications/services.py b/taiga/projects/notifications/services.py index 8accc657..189d4f6a 100644 --- a/taiga/projects/notifications/services.py +++ b/taiga/projects/notifications/services.py @@ -139,6 +139,10 @@ def _filter_by_permissions(obj, user): return False +def _filter_notificable(user): + return user.is_active and not user.is_system + + def get_users_to_notify(obj, *, discard_users=None) -> list: """ Get filtered set of users to notify for specified @@ -168,7 +172,8 @@ def get_users_to_notify(obj, *, discard_users=None) -> list: candidates = candidates - set(discard_users) candidates = filter(partial(_filter_by_permissions, obj), candidates) - + # Filter disabled and system users + candidates = filter(partial(_filter_notificable), candidates) return frozenset(candidates) @@ -249,8 +254,10 @@ def send_sync_notifications(notification_id): history_entries = tuple(notification.history_entries.all().order_by("created_at")) obj, _ = get_last_snapshot_for_key(notification.key) + obj_class = get_model_from_key(obj.key) context = { + "obj_class": obj_class, "snapshot": obj.snapshot, "project": notification.project, "changer": notification.owner, diff --git a/taiga/projects/permissions.py b/taiga/projects/permissions.py index aaea97f4..08b900a7 100644 --- a/taiga/projects/permissions.py +++ b/taiga/projects/permissions.py @@ -1,4 +1,5 @@ -# Copyright (C) 2014 Andrey Antukh # Copyright (C) 2014 Jesús Espino +# 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 @@ -15,9 +16,13 @@ from django.utils.translation import ugettext_lazy as _ -from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, - IsAuthenticated, IsProjectOwner, - AllowAny, IsSuperUser, PermissionComponent) +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsAuthenticated +from taiga.base.api.permissions import IsProjectOwner +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsSuperUser +from taiga.base.api.permissions import PermissionComponent from taiga.base import exceptions as exc from taiga.projects.models import Membership @@ -31,8 +36,8 @@ class CanLeaveProject(PermissionComponent): 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")) - + raise exc.PermissionDenied(_("You can't leave the project if there are no " + "more owners")) return True except Membership.DoesNotExist: return False @@ -47,12 +52,15 @@ class ProjectPermission(TaigaResourcePermission): destroy_perms = IsProjectOwner() modules_perms = IsProjectOwner() list_perms = AllowAny() - stats_perms = AllowAny() + stats_perms = HasProjectPerm('view_project') member_stats_perms = HasProjectPerm('view_project') + regenerate_userstories_csv_uuid_perms = IsProjectOwner() + regenerate_issues_csv_uuid_perms = IsProjectOwner() + regenerate_tasks_csv_uuid_perms = IsProjectOwner() star_perms = IsAuthenticated() unstar_perms = IsAuthenticated() - issues_stats_perms = AllowAny() - issues_filters_data_perms = AllowAny() + issues_stats_perms = HasProjectPerm('view_project') + issues_filters_data_perms = HasProjectPerm('view_project') tags_perms = HasProjectPerm('view_project') tags_colors_perms = HasProjectPerm('view_project') fans_perms = HasProjectPerm('view_project') @@ -139,14 +147,6 @@ class IssueTypePermission(TaigaResourcePermission): bulk_update_order_perms = IsProjectOwner() -class RolesPermission(TaigaResourcePermission): - retrieve_perms = HasProjectPerm('view_project') - create_perms = IsProjectOwner() - update_perms = IsProjectOwner() - destroy_perms = IsProjectOwner() - list_perms = AllowAny() - - # Project Templates class ProjectTemplatePermission(TaigaResourcePermission): diff --git a/taiga/projects/references/api.py b/taiga/projects/references/api.py index ad8f7169..97b41de4 100644 --- a/taiga/projects/references/api.py +++ b/taiga/projects/references/api.py @@ -15,15 +15,14 @@ # along with this program. If not, see . from django.apps import apps -from django.shortcuts import get_object_or_404 - -from rest_framework.response import Response from taiga.base import exceptions as exc +from taiga.base import response from taiga.base.api import viewsets -from .serializers import ResolverSerializer +from taiga.base.api.utils import get_object_or_404 from taiga.permissions.service import user_has_perm +from .serializers import ResolverSerializer from . import permissions @@ -42,19 +41,22 @@ class ResolverViewSet(viewsets.ViewSet): self.check_permissions(request, "list", project) - result = { - "project": project.pk - } + result = {"project": project.pk} if data["us"] and user_has_perm(request.user, "view_us", project): - result["us"] = get_object_or_404(project.user_stories.all(), ref=data["us"]).pk + result["us"] = get_object_or_404(project.user_stories.all(), + ref=data["us"]).pk if data["task"] and user_has_perm(request.user, "view_tasks", project): - result["task"] = get_object_or_404(project.tasks.all(), ref=data["task"]).pk + result["task"] = get_object_or_404(project.tasks.all(), + ref=data["task"]).pk if data["issue"] and user_has_perm(request.user, "view_issues", project): - result["issue"] = get_object_or_404(project.issues.all(), ref=data["issue"]).pk + result["issue"] = get_object_or_404(project.issues.all(), + ref=data["issue"]).pk if data["milestone"] and user_has_perm(request.user, "view_milestones", project): - result["milestone"] = get_object_or_404(project.milestones.all(), slug=data["milestone"]).pk + result["milestone"] = get_object_or_404(project.milestones.all(), + slug=data["milestone"]).pk if data["wikipage"] and user_has_perm(request.user, "view_wiki_pages", project): - result["wikipage"] = get_object_or_404(project.wiki_pages.all(), slug=data["wikipage"]).pk + result["wikipage"] = get_object_or_404(project.wiki_pages.all(), + slug=data["wikipage"]).pk - return Response(result) + return response.Ok(result) diff --git a/taiga/projects/serializers.py b/taiga/projects/serializers.py index 5c89484f..5d9d3a4d 100644 --- a/taiga/projects/serializers.py +++ b/taiga/projects/serializers.py @@ -14,27 +14,35 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from os import path from django.utils.translation import ugettext_lazy as _ from django.db.models import Q from rest_framework import serializers -from taiga.base.serializers import JsonField, PgArrayField, ModelSerializer, TagsColorsField -from taiga.users.models import Role, User +from taiga.base.serializers import JsonField +from taiga.base.serializers import PgArrayField +from taiga.base.serializers import ModelSerializer +from taiga.base.serializers import TagsColorsField from taiga.users.services import get_photo_or_gravatar_url from taiga.users.serializers import UserSerializer +from taiga.users.serializers import ProjectRoleSerializer from taiga.users.validators import RoleExistsValidator -from taiga.permissions.service import get_user_project_permissions, is_project_owner +from taiga.permissions.service import get_user_project_permissions +from taiga.permissions.service import is_project_owner from . import models from . import services -from . validators import ProjectExistsValidator +from .validators import ProjectExistsValidator +from .custom_attributes.serializers import UserStoryCustomAttributeSerializer +from .custom_attributes.serializers import TaskCustomAttributeSerializer +from .custom_attributes.serializers import IssueCustomAttributeSerializer -# User Stories common serializers +###################################################### +## Custom values for selectors +###################################################### class PointsSerializer(ModelSerializer): class Meta: @@ -69,10 +77,12 @@ class UserStoryStatusSerializer(ModelSerializer): qs = None # If the user story status exists: if self.object and attrs.get("name", None): - qs = models.UserStoryStatus.objects.filter(project=self.object.project, name=attrs[source]) + qs = models.UserStoryStatus.objects.filter(project=self.object.project, + name=attrs[source]) if not self.object and attrs.get("project", None) and attrs.get("name", None): - qs = models.UserStoryStatus.objects.filter(project=attrs["project"], name=attrs[source]) + qs = models.UserStoryStatus.objects.filter(project=attrs["project"], + name=attrs[source]) if qs and qs.exists(): raise serializers.ValidationError("Name duplicated for the project") @@ -80,8 +90,6 @@ class UserStoryStatusSerializer(ModelSerializer): return attrs -# Task common serializers - class TaskStatusSerializer(ModelSerializer): class Meta: model = models.TaskStatus @@ -103,7 +111,6 @@ class TaskStatusSerializer(ModelSerializer): return attrs -# Issues common serializers class SeveritySerializer(ModelSerializer): class Meta: @@ -142,13 +149,16 @@ class IssueTypeSerializer(ModelSerializer): model = models.IssueType -# Projects +###################################################### +## Members +###################################################### class MembershipSerializer(ModelSerializer): role_name = serializers.CharField(source='role.name', required=False, read_only=True) full_name = serializers.CharField(source='user.get_full_name', required=False, read_only=True) user_email = serializers.EmailField(source='user.email', required=False, read_only=True) - is_user_active = serializers.BooleanField(source='user.is_active', required=False, read_only=True) + is_user_active = serializers.BooleanField(source='user.is_active', required=False, + read_only=True) email = serializers.EmailField(required=True) color = serializers.CharField(source='user.color', required=False, read_only=True) photo = serializers.SerializerMethodField("get_photo") @@ -158,8 +168,10 @@ class MembershipSerializer(ModelSerializer): class Meta: model = models.Membership + # IMPORTANT: Maintain the MembershipAdminSerializer Meta up to date + # with this info (excluding here user_email and email) read_only_fields = ("user",) - exclude = ("token",) + exclude = ("token", "user_email", "email") def get_photo(self, project): return get_photo_or_gravatar_url(project.user) @@ -210,12 +222,22 @@ class MembershipSerializer(ModelSerializer): if project is None: project = self.object.project - if self.object and not services.project_has_valid_owners(project, exclude_user=self.object.user): + 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 MembershipAdminSerializer(MembershipSerializer): + class Meta: + model = models.Membership + # IMPORTANT: Maintain the MembershipSerializer Meta up to date + # with this info (excluding there user_email and email) + read_only_fields = ("user",) + exclude = ("token",) + + class ProjectMembershipSerializer(ModelSerializer): role_name = serializers.CharField(source='role.name', required=False) full_name = serializers.CharField(source='user.get_full_name', required=False) @@ -229,6 +251,21 @@ class ProjectMembershipSerializer(ModelSerializer): return get_photo_or_gravatar_url(project.user) +class MemberBulkSerializer(RoleExistsValidator, serializers.Serializer): + email = serializers.EmailField() + role_id = serializers.IntegerField() + + +class MembersBulkSerializer(ProjectExistsValidator, serializers.Serializer): + project_id = serializers.IntegerField() + bulk_memberships = MemberBulkSerializer(many=True) + invitation_extra_text = serializers.CharField(required=False, max_length=255) + + +###################################################### +## Projects +###################################################### + class ProjectSerializer(ModelSerializer): tags = PgArrayField(required=False) anon_permissions = PgArrayField(required=False) @@ -243,7 +280,8 @@ class ProjectSerializer(ModelSerializer): class Meta: model = models.Project read_only_fields = ("created_date", "modified_date", "owner") - exclude = ("last_us_ref", "last_task_ref", "last_issue_ref") + exclude = ("last_us_ref", "last_task_ref", "last_issue_ref", + "issues_csv_uuid", "tasks_csv_uuid", "userstories_csv_uuid") def get_stars_number(self, obj): # The "stars_count" attribute is attached in the get_queryset of the viewset. @@ -286,6 +324,12 @@ class ProjectDetailSerializer(ProjectSerializer): issue_types = IssueTypeSerializer(many=True, required=False) priorities = PrioritySerializer(many=True, required=False) # Issues severities = SeveritySerializer(many=True, required=False) + userstory_custom_attributes = UserStoryCustomAttributeSerializer(source="userstorycustomattributes", + many=True, required=False) + task_custom_attributes = TaskCustomAttributeSerializer(source="taskcustomattributes", + many=True, required=False) + issue_custom_attributes = IssueCustomAttributeSerializer(source="issuecustomattributes", + many=True, required=False) def get_memberships(self, obj): qs = obj.memberships.filter(user__isnull=False) @@ -300,24 +344,28 @@ class ProjectDetailSerializer(ProjectSerializer): return serializer.data -class ProjectRoleSerializer(ModelSerializer): +class ProjectDetailAdminSerializer(ProjectDetailSerializer): class Meta: - model = Role - fields = ('id', 'name', 'slug', 'order', 'computable') + model = models.Project + read_only_fields = ("created_date", "modified_date", "owner") + exclude = ("last_us_ref", "last_task_ref", "last_issue_ref") -class RoleSerializer(ModelSerializer): - members_count = serializers.SerializerMethodField("get_members_count") - permissions = PgArrayField(required=False) +###################################################### +## Starred +###################################################### +class StarredSerializer(ModelSerializer): class Meta: - model = Role - fields = ('id', 'name', 'permissions', 'computable', 'project', 'order', 'members_count') + model = models.Project + fields = ['id', 'name', 'slug'] - def get_members_count(self, obj): - return obj.memberships.count() +###################################################### +## Project Templates +###################################################### + class ProjectTemplateSerializer(ModelSerializer): default_options = JsonField(required=False, label=_("Default options")) us_statuses = JsonField(required=False, label=_("User story's statuses")) @@ -332,20 +380,3 @@ class ProjectTemplateSerializer(ModelSerializer): class Meta: model = models.ProjectTemplate read_only_fields = ("created_date", "modified_date") - - -class StarredSerializer(ModelSerializer): - class Meta: - model = models.Project - fields = ['id', 'name', 'slug'] - - -class MemberBulkSerializer(RoleExistsValidator, serializers.Serializer): - email = serializers.EmailField() - role_id = serializers.IntegerField() - - -class MembersBulkSerializer(ProjectExistsValidator, serializers.Serializer): - project_id = serializers.IntegerField() - bulk_memberships = MemberBulkSerializer(many=True) - invitation_extra_text = serializers.CharField(required=False, max_length=255) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index a4e6361d..01bfa11f 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -15,13 +15,14 @@ # 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.api.utils import get_object_or_404 from taiga.base import filters, response from taiga.base import exceptions as exc from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet from taiga.projects.models import Project +from django.http import HttpResponse from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin @@ -71,6 +72,19 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, task = get_object_or_404(models.Task, ref=ref, project_id=project_id) return self.retrieve(request, pk=task.pk) + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_404(Project, tasks_csv_uuid=uuid) + queryset = project.tasks.all().order_by('ref') + data = services.tasks_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv') + csv_response['Content-Disposition'] = 'attachment; filename="tasks.csv"' + return csv_response + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.TasksBulkSerializer(data=request.DATA) @@ -98,8 +112,8 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, self.check_permissions(request, "bulk_update_order", project) services.update_tasks_order_in_bulk(data["bulk_tasks"], - project=project, - field=order_field) + project=project, + field=order_field) services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user) return response.NoContent() diff --git a/taiga/projects/tasks/apps.py b/taiga/projects/tasks/apps.py index 752560de..a6597339 100644 --- a/taiga/projects/tasks/apps.py +++ b/taiga/projects/tasks/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -44,3 +45,8 @@ class TasksAppConfig(AppConfig): sender=apps.get_model("tasks", "Task")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("tasks", "Task")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_task, + sender=apps.get_model("tasks", "Task"), + dispatch_uid="create_custom_attribute_value_when_create_task") diff --git a/taiga/projects/tasks/permissions.py b/taiga/projects/tasks/permissions.py index 1983d7a6..f97215b8 100644 --- a/taiga/projects/tasks/permissions.py +++ b/taiga/projects/tasks/permissions.py @@ -26,5 +26,6 @@ class TaskPermission(TaigaResourcePermission): update_perms = HasProjectPerm('modify_task') destroy_perms = HasProjectPerm('delete_task') list_perms = AllowAny() + csv_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 9e2d2f4a..7ee80fa3 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -18,7 +18,7 @@ from rest_framework import serializers from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, PgArrayField, ModelSerializer) - + from taiga.mdrender.service import render as mdrender from taiga.projects.validators import ProjectExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator diff --git a/taiga/projects/tasks/services.py b/taiga/projects/tasks/services.py index 379d1321..d4a00a1e 100644 --- a/taiga/projects/tasks/services.py +++ b/taiga/projects/tasks/services.py @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import io +import csv + from taiga.base.utils import db, text from taiga.projects.history.services import take_snapshot from taiga.events import events @@ -75,3 +78,42 @@ def snapshot_tasks_in_bulk(bulk_data, user): take_snapshot(task, user=user) except models.UserStory.DoesNotExist: pass + + +def tasks_to_csv(project, queryset): + csv_data = io.StringIO() + fieldnames = ["ref", "subject", "description", "user_story", "milestone", "owner", + "owner_full_name", "assigned_to", "assigned_to_full_name", + "status", "is_iocaine", "is_closed", "us_order", + "taskboard_order", "attachments", "external_reference"] + for custom_attr in project.taskcustomattributes.all(): + fieldnames.append(custom_attr.name) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for task in queryset: + task_data = { + "ref": task.ref, + "subject": task.subject, + "description": task.description, + "user_story": task.user_story.ref if task.user_story else None, + "milestone": task.milestone.name if task.milestone else None, + "owner": task.owner.username, + "owner_full_name": task.owner.get_full_name(), + "assigned_to": task.assigned_to.username if task.assigned_to else None, + "assigned_to_full_name": task.assigned_to.get_full_name() if task.assigned_to else None, + "status": task.status.name, + "is_iocaine": task.is_iocaine, + "is_closed": task.status.is_closed, + "us_order": task.us_order, + "taskboard_order": task.taskboard_order, + "attachments": task.attachments.count(), + "external_reference": task.external_reference, + } + for custom_attr in project.taskcustomattributes.all(): + value = task.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + task_data[custom_attr.name] = value + + writer.writerow(task_data) + + return csv_data diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index e667355d..1e270dc1 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -16,19 +16,20 @@ from contextlib import suppress +from rest_framework import status + from django.apps import apps from django.db import transaction from django.utils.translation import ugettext as _ -from django.shortcuts import get_object_or_404 from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponse -from rest_framework.response import Response -from rest_framework import status - -from taiga.base import filters, response +from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base import response from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet +from taiga.base.api.utils import get_object_or_404 from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin @@ -102,6 +103,19 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi userstory = get_object_or_404(models.UserStory, ref=ref, project_id=project_id) return self.retrieve(request, pk=userstory.pk) + @list_route(methods=["GET"]) + def csv(self, request): + uuid = request.QUERY_PARAMS.get("uuid", None) + if uuid is None: + return response.NotFound() + + project = get_object_or_404(Project, userstories_csv_uuid=uuid) + queryset = project.user_stories.all().order_by('ref') + data = services.userstories_to_csv(project, queryset) + csv_response = HttpResponse(data.getvalue(), content_type='application/csv') + csv_response['Content-Disposition'] = 'attachment; filename="userstories.csv"' + return csv_response + @list_route(methods=["POST"]) def bulk_create(self, request, **kwargs): serializer = serializers.UserStoriesBulkSerializer(data=request.DATA) diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py index 299f1cfc..f6b1bb77 100644 --- a/taiga/projects/userstories/apps.py +++ b/taiga/projects/userstories/apps.py @@ -19,6 +19,7 @@ from django.apps import apps from django.db.models import signals from taiga.projects import signals as generic_handlers +from taiga.projects.custom_attributes import signals as custom_attributes_handlers from . import signals as handlers @@ -52,3 +53,8 @@ class UserStoriesAppConfig(AppConfig): sender=apps.get_model("userstories", "UserStory")) signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item, sender=apps.get_model("userstories", "UserStory")) + + # Custom Attributes + signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story, + sender=apps.get_model("userstories", "UserStory"), + dispatch_uid="create_custom_attribute_value_when_create_user_story") diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py index 52772ca6..5424cb7f 100644 --- a/taiga/projects/userstories/models.py +++ b/taiga/projects/userstories/models.py @@ -22,10 +22,10 @@ from django.utils import timezone from djorm_pgarray.fields import TextArrayField +from taiga.base.tags import TaggedMixin from taiga.projects.occ import OCCModelMixin from taiga.projects.notifications.mixins import WatchedModelMixin from taiga.projects.mixins.blocked import BlockedMixin -from taiga.base.tags import TaggedMixin class RolePoints(models.Model): @@ -48,6 +48,9 @@ class RolePoints(models.Model): def __str__(self): return "{}: {}".format(self.role.name, self.points.name) + @property + def project(self): + return self.user_story.project class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.Model): ref = models.BigIntegerField(db_index=True, null=True, blank=True, default=None, diff --git a/taiga/projects/userstories/permissions.py b/taiga/projects/userstories/permissions.py index c0a7a5bc..3b836cb6 100644 --- a/taiga/projects/userstories/permissions.py +++ b/taiga/projects/userstories/permissions.py @@ -25,5 +25,6 @@ class UserStoryPermission(TaigaResourcePermission): update_perms = HasProjectPerm('modify_us') destroy_perms = HasProjectPerm('delete_us') list_perms = AllowAny() + csv_perms = AllowAny() bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us')) bulk_update_order_perms = HasProjectPerm('modify_us') diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index f90970f3..ae81cf20 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -14,15 +14,19 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json from django.apps import apps from rest_framework import serializers -from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin, - PgArrayField, ModelSerializer) +from taiga.base.serializers import Serializer +from taiga.base.serializers import TagsField +from taiga.base.serializers import NeighborsSerializerMixin +from taiga.base.serializers import PgArrayField +from taiga.base.serializers import ModelSerializer +from taiga.base.utils import json from taiga.mdrender.service import render as mdrender -from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator +from taiga.projects.validators import ProjectExistsValidator +from taiga.projects.validators import UserStoryStatusExistsValidator from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.notifications.validators import WatchersValidator @@ -92,7 +96,6 @@ class UserStorySerializer(WatchersValidator, ModelSerializer): class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): - def serialize_neighbor(self, neighbor): return NeighborUserStorySerializer(neighbor).data @@ -104,8 +107,7 @@ class NeighborUserStorySerializer(ModelSerializer): depth = 0 -class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, - Serializer): +class UserStoriesBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, Serializer): project_id = serializers.IntegerField() status_id = serializers.IntegerField(required=False) bulk_stories = serializers.CharField() @@ -118,8 +120,6 @@ class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, Serializer): order = serializers.IntegerField() -class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, - UserStoryStatusExistsValidator, - Serializer): +class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, Serializer): project_id = serializers.IntegerField() bulk_stories = _UserStoryOrderBulkSerializer(many=True) diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py index 0d70cb1e..fefc15c3 100644 --- a/taiga/projects/userstories/services.py +++ b/taiga/projects/userstories/services.py @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import csv +import io + from django.utils import timezone from taiga.base.utils import db, text @@ -104,3 +107,64 @@ def open_userstory(us): us.is_closed = False us.finish_date = None us.save(update_fields=["is_closed", "finish_date"]) + + +def userstories_to_csv(project,queryset): + csv_data = io.StringIO() + fieldnames = ["ref", "subject", "description", "milestone", "owner", + "owner_full_name", "assigned_to", "assigned_to_full_name", + "status", "is_closed"] + for role in project.roles.filter(computable=True).order_by('name'): + fieldnames.append("{}-points".format(role.slug)) + fieldnames.append("total-points") + + fieldnames += ["backlog_order", "sprint_order", "kanban_order", + "created_date", "modified_date", "finish_date", + "client_requirement", "team_requirement", "attachments", + "generated_from_issue", "external_reference", "tasks"] + + for custom_attr in project.userstorycustomattributes.all(): + fieldnames.append(custom_attr.name) + + writer = csv.DictWriter(csv_data, fieldnames=fieldnames) + writer.writeheader() + for us in queryset: + row = { + "ref": us.ref, + "subject": us.subject, + "description": us.description, + "milestone": us.milestone.name if us.milestone else None, + "owner": us.owner.username, + "owner_full_name": us.owner.get_full_name(), + "assigned_to": us.assigned_to.username if us.assigned_to else None, + "assigned_to_full_name": us.assigned_to.get_full_name() if us.assigned_to else None, + "status": us.status.name, + "is_closed": us.is_closed, + "backlog_order": us.backlog_order, + "sprint_order": us.sprint_order, + "kanban_order": us.kanban_order, + "created_date": us.created_date, + "modified_date": us.modified_date, + "finish_date": us.finish_date, + "client_requirement": us.client_requirement, + "team_requirement": us.team_requirement, + "attachments": us.attachments.count(), + "generated_from_issue": us.generated_from_issue.ref if us.generated_from_issue else None, + "external_reference": us.external_reference, + "tasks": ",".join([str(task.ref) for task in us.tasks.all()]), + } + + for role in us.project.roles.filter(computable=True).order_by('name'): + if us.role_points.filter(role_id=role.id).count() == 1: + row["{}-points".format(role.slug)] = us.role_points.get(role_id=role.id).points.value + else: + row["{}-points".format(role.slug)] = 0 + row['total-points'] = us.get_total_points() + + for custom_attr in project.userstorycustomattributes.all(): + value = us.custom_attributes_values.attributes_values.get(str(custom_attr.id), None) + row[custom_attr.name] = value + + writer.writerow(row) + + return csv_data diff --git a/taiga/projects/wiki/api.py b/taiga/projects/wiki/api.py index 8ce56eac..f67150bf 100644 --- a/taiga/projects/wiki/api.py +++ b/taiga/projects/wiki/api.py @@ -15,15 +15,14 @@ # along with this program. If not, see . from django.utils.translation import ugettext_lazy as _ -from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework import status from taiga.base import filters from taiga.base import exceptions as exc +from taiga.base import response from taiga.base.api import ModelCrudViewSet +from taiga.base.api.utils import get_object_or_404 from taiga.base.decorators import list_route from taiga.projects.models import Project from taiga.mdrender.service import render as mdrender @@ -58,17 +57,17 @@ class WikiViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, project_id = request.DATA.get("project_id", None) if not content: - raise exc.WrongArguments({"content": "No content parameter"}) + raise exc.WrongArguments({"content": _("No content parameter")}) if not project_id: - raise exc.WrongArguments({"project_id": "No project_id parameter"}) + raise exc.WrongArguments({"project_id": _("No project_id parameter")}) project = get_object_or_404(Project, pk=project_id) self.check_permissions(request, "render", project) data = mdrender(project, content) - return Response({"data": data}) + return response.Ok({"data": data}) def pre_save(self, obj): if not obj.owner: diff --git a/taiga/projects/wiki/permissions.py b/taiga/projects/wiki/permissions.py index 3e370290..684880a8 100644 --- a/taiga/projects/wiki/permissions.py +++ b/taiga/projects/wiki/permissions.py @@ -22,6 +22,7 @@ class WikiPagePermission(TaigaResourcePermission): enought_perms = IsProjectOwner() | IsSuperUser() global_perms = None retrieve_perms = HasProjectPerm('view_wiki_pages') + by_slug_perms = HasProjectPerm('view_wiki_pages') create_perms = HasProjectPerm('add_wiki_page') update_perms = HasProjectPerm('modify_wiki_page') partial_update_perms = HasProjectPerm('modify_wiki_page') diff --git a/taiga/routers.py b/taiga/routers.py index 7ca20aa6..8a09b80e 100644 --- a/taiga/routers.py +++ b/taiga/routers.py @@ -18,41 +18,30 @@ from taiga.base import routers router = routers.DefaultRouter(trailing_slash=False) -# taiga.users -from taiga.users.api import UsersViewSet + +# Users & Roles from taiga.auth.api import AuthViewSet +from taiga.users.api import UsersViewSet +from taiga.users.api import RolesViewSet -router.register(r"users", UsersViewSet, base_name="users") router.register(r"auth", AuthViewSet, base_name="auth") +router.register(r"users", UsersViewSet, base_name="users") +router.register(r"roles", RolesViewSet, base_name="roles") -#taiga.userstorage +# User Storage from taiga.userstorage.api import StorageEntriesViewSet router.register(r"user-storage", StorageEntriesViewSet, base_name="user-storage") -# Resolver -from taiga.projects.references.api import ResolverViewSet +# Notify policies +from taiga.projects.notifications.api import NotifyPolicyViewSet -router.register(r"resolver", ResolverViewSet, base_name="resolver") +router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") -# Search -from taiga.searches.api import SearchViewSet - -router.register(r"search", SearchViewSet, base_name="search") - - -# Importer -from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet - -router.register(r"importer", ProjectImporterViewSet, base_name="importer") -router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") - - -# Projects & Types -from taiga.projects.api import RolesViewSet +# Projects & Selectors from taiga.projects.api import ProjectViewSet from taiga.projects.api import MembershipViewSet from taiga.projects.api import InvitationViewSet @@ -65,8 +54,6 @@ from taiga.projects.api import PriorityViewSet from taiga.projects.api import SeverityViewSet from taiga.projects.api import ProjectTemplateViewSet - -router.register(r"roles", RolesViewSet, base_name="roles") router.register(r"projects", ProjectViewSet, base_name="projects") router.register(r"project-templates", ProjectTemplateViewSet, base_name="project-templates") router.register(r"memberships", MembershipViewSet, base_name="memberships") @@ -79,21 +66,71 @@ router.register(r"issue-types", IssueTypeViewSet, base_name="issue-types") router.register(r"priorities", PriorityViewSet, base_name="priorities") router.register(r"severities",SeverityViewSet , base_name="severities") + +# Custom Attributes +from taiga.projects.custom_attributes.api import UserStoryCustomAttributeViewSet +from taiga.projects.custom_attributes.api import TaskCustomAttributeViewSet +from taiga.projects.custom_attributes.api import IssueCustomAttributeViewSet +from taiga.projects.custom_attributes.api import UserStoryCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import TaskCustomAttributesValuesViewSet +from taiga.projects.custom_attributes.api import IssueCustomAttributesValuesViewSet + +router.register(r"userstory-custom-attributes", UserStoryCustomAttributeViewSet, + base_name="userstory-custom-attributes") +router.register(r"task-custom-attributes", TaskCustomAttributeViewSet, + base_name="task-custom-attributes") +router.register(r"issue-custom-attributes", IssueCustomAttributeViewSet, + base_name="issue-custom-attributes") + +router.register(r"userstories/custom-attributes-values", UserStoryCustomAttributesValuesViewSet, + base_name="userstory-custom-attributes-values") +router.register(r"tasks/custom-attributes-values", TaskCustomAttributesValuesViewSet, + base_name="task-custom-attributes-values") +router.register(r"issues/custom-attributes-values", IssueCustomAttributesValuesViewSet, + base_name="issue-custom-attributes-values") + + +# Search +from taiga.searches.api import SearchViewSet + +router.register(r"search", SearchViewSet, base_name="search") + + +# Resolver +from taiga.projects.references.api import ResolverViewSet + +router.register(r"resolver", ResolverViewSet, base_name="resolver") + + # Attachments from taiga.projects.attachments.api import UserStoryAttachmentViewSet from taiga.projects.attachments.api import IssueAttachmentViewSet from taiga.projects.attachments.api import TaskAttachmentViewSet from taiga.projects.attachments.api import WikiAttachmentViewSet -router.register(r"userstories/attachments", UserStoryAttachmentViewSet, base_name="userstory-attachments") +router.register(r"userstories/attachments", UserStoryAttachmentViewSet, + base_name="userstory-attachments") router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-attachments") router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments") router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") -# Webhooks -from taiga.webhooks.api import WebhookViewSet, WebhookLogViewSet -router.register(r"webhooks", WebhookViewSet, base_name="webhooks") -router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") + +# Project components +from taiga.projects.milestones.api import MilestoneViewSet +from taiga.projects.userstories.api import UserStoryViewSet +from taiga.projects.tasks.api import TaskViewSet +from taiga.projects.issues.api import IssueViewSet +from taiga.projects.issues.api import VotersViewSet +from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet + +router.register(r"milestones", MilestoneViewSet, base_name="milestones") +router.register(r"userstories", UserStoryViewSet, base_name="userstories") +router.register(r"tasks", TaskViewSet, base_name="tasks") +router.register(r"issues", IssueViewSet, base_name="issues") +router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") +router.register(r"wiki", WikiViewSet, base_name="wiki") +router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") + # History & Components from taiga.projects.history.api import UserStoryHistory @@ -115,38 +152,38 @@ router.register(r"timeline/user", UserTimeline, base_name="user-timeline") router.register(r"timeline/project", ProjectTimeline, base_name="project-timeline") -# Project components -from taiga.projects.milestones.api import MilestoneViewSet -from taiga.projects.userstories.api import UserStoryViewSet -from taiga.projects.tasks.api import TaskViewSet -from taiga.projects.issues.api import IssueViewSet -from taiga.projects.issues.api import VotersViewSet -from taiga.projects.wiki.api import WikiViewSet, WikiLinkViewSet +# Webhooks +from taiga.webhooks.api import WebhookViewSet +from taiga.webhooks.api import WebhookLogViewSet -router.register(r"milestones", MilestoneViewSet, base_name="milestones") -router.register(r"userstories", UserStoryViewSet, base_name="userstories") -router.register(r"tasks", TaskViewSet, base_name="tasks") -router.register(r"issues", IssueViewSet, base_name="issues") -router.register(r"issues/(?P\d+)/voters", VotersViewSet, base_name="issue-voters") -router.register(r"wiki", WikiViewSet, base_name="wiki") -router.register(r"wiki-links", WikiLinkViewSet, base_name="wiki-links") +router.register(r"webhooks", WebhookViewSet, base_name="webhooks") +router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs") -# Notify policies -from taiga.projects.notifications.api import NotifyPolicyViewSet - -router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") # GitHub webhooks 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") + +# Importer +from taiga.export_import.api import ProjectImporterViewSet, ProjectExporterViewSet + +router.register(r"importer", ProjectImporterViewSet, base_name="importer") +router.register(r"exporter", ProjectExporterViewSet, base_name="exporter") + + # feedback # - see taiga.feedback.routers and taiga.feedback.apps diff --git a/taiga/searches/api.py b/taiga/searches/api.py index 4f585d91..0a8bb788 100644 --- a/taiga/searches/api.py +++ b/taiga/searches/api.py @@ -16,10 +16,10 @@ from django.apps import apps -from rest_framework.response import Response from rest_framework import viewsets -from taiga.base import exceptions as excp +from taiga.base import response +from taiga.base.api.utils import get_object_or_404 from taiga.projects.userstories.serializers import UserStorySerializer from taiga.projects.tasks.serializers import TaskSerializer from taiga.projects.issues.serializers import IssueSerializer @@ -31,15 +31,10 @@ from . import services class SearchViewSet(viewsets.ViewSet): def list(self, request, **kwargs): - project_model = apps.get_model("projects", "Project") - text = request.QUERY_PARAMS.get('text', "") project_id = request.QUERY_PARAMS.get('project', None) - try: - project = self._get_project(project_id) - except (project_model.DoesNotExist, TypeError): - raise excp.PermissionDenied({"detail": "Wrong project id"}) + project = self._get_project(project_id) result = {} if user_has_perm(request.user, "view_us", project): @@ -52,11 +47,11 @@ class SearchViewSet(viewsets.ViewSet): result["wikipages"] = self._search_wiki_pages(project, text) result["count"] = sum(map(lambda x: len(x), result.values())) - return Response(result) + return response.Ok(result) def _get_project(self, project_id): project_model = apps.get_model("projects", "Project") - return project_model.objects.get(pk=project_id) + return get_object_or_404(project_model, pk=project_id) def _search_user_stories(self, project, text): queryset = services.search_user_stories(project, text) diff --git a/taiga/timeline/api.py b/taiga/timeline/api.py index 2db4c7f6..4845b82a 100644 --- a/taiga/timeline/api.py +++ b/taiga/timeline/api.py @@ -15,16 +15,14 @@ # along with this program. If not, see . from django.contrib.contenttypes.models import ContentType -from django.shortcuts import get_object_or_404 - -from rest_framework.response import Response +from taiga.base import response +from taiga.base.api.utils import get_object_or_404 from taiga.base.api import GenericViewSet from . import serializers from . import service from . import permissions -from . import models class TimelineViewSet(GenericViewSet): @@ -52,12 +50,12 @@ class TimelineViewSet(GenericViewSet): else: serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return response.Ok(serializer.data) # Just for restframework! Because it raises # 404 on main api root if this method not exists. def list(self, request): - return Response({}) + return response.NotFound() def retrieve(self, request, pk): obj = self.get_object() diff --git a/taiga/timeline/apps.py b/taiga/timeline/apps.py index 3b0d7d90..c5d8e4a2 100644 --- a/taiga/timeline/apps.py +++ b/taiga/timeline/apps.py @@ -36,5 +36,3 @@ class TimelineAppConfig(AppConfig): sender=apps.get_model("projects", "Membership")) signals.post_delete.connect(handlers.delete_membership_push_to_timeline, sender=apps.get_model("projects", "Membership")) - - diff --git a/taiga/timeline/permissions.py b/taiga/timeline/permissions.py index 95110a61..f07ee929 100644 --- a/taiga/timeline/permissions.py +++ b/taiga/timeline/permissions.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, - IsProjectOwner, AllowAny) + AllowAny) class UserTimelinePermission(TaigaResourcePermission): diff --git a/taiga/users/api.py b/taiga/users/api.py index 6407b8b9..93f79be3 100644 --- a/taiga/users/api.py +++ b/taiga/users/api.py @@ -18,29 +18,28 @@ import uuid from django.apps import apps from django.db.models import Q -from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ from django.core.validators import validate_email from django.core.exceptions import ValidationError from django.conf import settings -from easy_thumbnails.source_generators import pil_image - - -from rest_framework.response import Response -from rest_framework.filters import BaseFilterBackend -from rest_framework import status - -from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail - -from taiga.auth.tokens import get_user_for_token -from taiga.base.decorators import list_route, detail_route from taiga.base import exceptions as exc +from taiga.base import filters +from taiga.base import response +from taiga.auth.tokens import get_user_for_token +from taiga.base.decorators import list_route +from taiga.base.decorators import detail_route from taiga.base.api import ModelCrudViewSet -from taiga.base.utils.slug import slugify_uniquely +from taiga.base.filters import PermissionBasedFilterBackend +from taiga.base.api.utils import get_object_or_404 +from taiga.base.filters import MembersFilterBackend from taiga.projects.votes import services as votes_service from taiga.projects.serializers import StarredSerializer -from taiga.permissions.service import is_project_owner + +from easy_thumbnails.source_generators import pil_image + +from djmail.template_mail import MagicMailBuilder +from djmail.template_mail import InlineCSSTemplateMail from . import models from . import serializers @@ -48,33 +47,27 @@ from . import permissions from .signals import user_cancel_account as user_cancel_account_signal -class MembersFilterBackend(BaseFilterBackend): - def filter_queryset(self, request, queryset, view): - project_id = request.QUERY_PARAMS.get('project', None) - 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(): - return queryset.filter(memberships__project=project).distinct() - else: - raise exc.PermissionDenied(_("You don't have permisions to see this project users.")) - - if request.user.is_superuser: - return queryset - - return [] - - class UsersViewSet(ModelCrudViewSet): permission_classes = (permissions.UserPermission,) + admin_serializer_class = serializers.UserAdminSerializer serializer_class = serializers.UserSerializer queryset = models.User.objects.all() + filter_backends = (MembersFilterBackend,) + + def get_serializer_class(self): + if self.action in ["partial_update", "update", "retrieve"]: + user = self.get_object() + if self.request.user == user: + return self.admin_serializer_class + return self.serializer_class def create(self, *args, **kwargs): raise exc.NotSupported() def list(self, request, *args, **kwargs): - self.object_list = MembersFilterBackend().filter_queryset(request, self.get_queryset(), self) + self.object_list = MembersFilterBackend().filter_queryset(request, + self.get_queryset(), + self) page = self.paginate_queryset(self.object_list) if page is not None: @@ -82,7 +75,7 @@ class UsersViewSet(ModelCrudViewSet): else: serializer = self.get_serializer(self.object_list, many=True) - return Response(serializer.data) + return response.Ok(serializer.data) @list_route(methods=["POST"]) def password_recovery(self, request, pk=None): @@ -107,8 +100,8 @@ class UsersViewSet(ModelCrudViewSet): email = mbuilder.password_recovery(user.email, {"user": user}) email.send() - return Response({"detail": _("Mail sended successful!"), - "email": user.email}) + return response.Ok({"detail": _("Mail sended successful!"), + "email": user.email}) @list_route(methods=["POST"]) def change_password_from_recovery(self, request, pk=None): @@ -131,7 +124,7 @@ class UsersViewSet(ModelCrudViewSet): user.token = None user.save(update_fields=["password", "token"]) - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() @list_route(methods=["POST"]) def change_password(self, request, pk=None): @@ -159,7 +152,7 @@ class UsersViewSet(ModelCrudViewSet): request.user.set_password(password) request.user.save(update_fields=["password"]) - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() @list_route(methods=["POST"]) def change_avatar(self, request): @@ -180,9 +173,9 @@ class UsersViewSet(ModelCrudViewSet): request.user.photo = avatar request.user.save(update_fields=["photo"]) - user_data = serializers.UserSerializer(request.user).data + user_data = self.admin_serializer_class(request.user).data - return Response(user_data, status=status.HTTP_200_OK) + return response.Ok(user_data) @list_route(methods=["POST"]) def remove_avatar(self, request): @@ -192,8 +185,8 @@ class UsersViewSet(ModelCrudViewSet): self.check_permissions(request, "remove_avatar", None) request.user.photo = None request.user.save(update_fields=["photo"]) - user_data = serializers.UserSerializer(request.user).data - return Response(user_data, status=status.HTTP_200_OK) + user_data = self.admin_serializer_class(request.user).data + return response.Ok(user_data) @detail_route(methods=["GET"]) def starred(self, request, pk=None): @@ -202,7 +195,7 @@ class UsersViewSet(ModelCrudViewSet): stars = votes_service.get_voted(user.pk, model=apps.get_model('projects', 'Project')) stars_data = StarredSerializer(stars, many=True) - return Response(stars_data.data) + return response.Ok(stars_data.data) #TODO: commit_on_success def partial_update(self, request, *args, **kwargs): @@ -250,12 +243,14 @@ class UsersViewSet(ModelCrudViewSet): """ serializer = serializers.ChangeEmailSerializer(data=request.DATA, many=False) if not serializer.is_valid(): - raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you didn't use it before?")) + raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you " + "didn't use it before?")) try: user = models.User.objects.get(email_token=serializer.data["email_token"]) except models.User.DoesNotExist: - raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you didn't use it before?")) + raise exc.WrongArguments(_("Invalid, are you sure the token is correct and you " + "didn't use it before?")) self.check_permissions(request, "change_email", user) user.email = user.new_email @@ -263,7 +258,16 @@ class UsersViewSet(ModelCrudViewSet): user.email_token = None user.save(update_fields=["email", "new_email", "email_token"]) - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() + + @list_route(methods=["GET"]) + def me(self, request, pk=None): + """ + Get me. + """ + self.check_permissions(request, "me", None) + user_data = self.admin_serializer_class(request.user).data + return response.Ok(user_data) @list_route(methods=["POST"]) def cancel(self, request, pk=None): @@ -286,7 +290,7 @@ class UsersViewSet(ModelCrudViewSet): raise exc.WrongArguments(_("Invalid, are you sure the token is correct?")) user.cancel() - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() def destroy(self, request, pk=None): user = self.get_object() @@ -295,4 +299,26 @@ class UsersViewSet(ModelCrudViewSet): request_data = stream is not None and stream.GET or None user_cancel_account_signal.send(sender=user.__class__, user=user, request_data=request_data) user.cancel() - return Response(status=status.HTTP_204_NO_CONTENT) + return response.NoContent() + + +###################################################### +## Role +###################################################### + +class RolesViewSet(ModelCrudViewSet): + model = models.Role + serializer_class = serializers.RoleSerializer + permission_classes = (permissions.RolesPermission, ) + filter_backends = (filters.CanViewProjectFilterBackend,) + filter_fields = ('project',) + + def pre_delete(self, obj): + move_to = self.request.QUERY_PARAMS.get('moveTo', None) + if move_to: + membership_model = apps.get_model("projects", "Membership") + role_dest = get_object_or_404(self.model, project=obj.project, id=move_to) + qs = membership_model.objects.filter(project_id=obj.project.pk, role=obj) + qs.update(role=role_dest) + + super().pre_delete(obj) diff --git a/taiga/users/fixtures/initial_user.json b/taiga/users/fixtures/initial_user.json index ba098629..e13bcb95 100644 --- a/taiga/users/fixtures/initial_user.json +++ b/taiga/users/fixtures/initial_user.json @@ -13,7 +13,6 @@ "default_timezone": "", "is_superuser": true, "token": "", - "github_id": null, "last_login": "2013-04-04T07:36:09.880Z", "password": "pbkdf2_sha256$10000$oRIbCKOL1U3w$/gaYMnOlc/GnN4mn3UUXvXpk2Hx0vvht6Uqhu46aikI=", "email": "admin@admin.com", diff --git a/taiga/users/migrations/0007_auto_20150209_1611.py b/taiga/users/migrations/0007_auto_20150209_1611.py new file mode 100644 index 00000000..4f1bc2ca --- /dev/null +++ b/taiga/users/migrations/0007_auto_20150209_1611.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings +import django_pgjson.fields + + +def migrate_github_id(apps, schema_editor): + AuthData = apps.get_model("users", "AuthData") + User = apps.get_model("users", "User") + for user in User.objects.all(): + if user.github_id: + AuthData.objects.create(user=user, key="github", value=user.github_id, extra={}) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_auto_20141030_1132'), + ] + + operations = [ + migrations.CreateModel( + name='AuthData', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), + ('key', models.SlugField()), + ('value', models.CharField(max_length=300)), + ('extra', django_pgjson.fields.JsonField()), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name='authdata', + unique_together=set([('key', 'value')]), + ), + migrations.RunPython(migrate_github_id), + migrations.RemoveField( + model_name='user', + name='github_id', + ), + ] diff --git a/taiga/users/migrations/0008_auto_20150213_1701.py b/taiga/users/migrations/0008_auto_20150213_1701.py new file mode 100644 index 00000000..fb55b125 --- /dev/null +++ b/taiga/users/migrations/0008_auto_20150213_1701.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_auto_20150209_1611'), + ] + + operations = [ + migrations.AlterField( + model_name='authdata', + name='user', + field=models.ForeignKey(related_name='auth_data', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/taiga/users/models.py b/taiga/users/models.py index 69322589..e2ffe491 100644 --- a/taiga/users/models.py +++ b/taiga/users/models.py @@ -32,6 +32,7 @@ from django.utils import timezone from django.utils.encoding import force_bytes from django.template.defaultfilters import slugify +from django_pgjson.fields import JsonField from djorm_pgarray.fields import TextArrayField from taiga.auth.tokens import get_token_for_user @@ -129,7 +130,6 @@ class User(AbstractBaseUser, PermissionsMixin): new_email = models.EmailField(_('new email address'), null=True, blank=True) - github_id = models.IntegerField(null=True, blank=True, verbose_name=_("github ID"), db_index=True) is_system = models.BooleanField(null=False, blank=False, default=False) USERNAME_FIELD = 'username' @@ -170,9 +170,10 @@ class User(AbstractBaseUser, PermissionsMixin): self.default_timezone = "" self.colorize_tags = True self.token = None - self.github_id = None self.set_unusable_password() self.save() + self.auth_data.all().delete() + class Role(models.Model): name = models.CharField(max_length=200, null=False, blank=False, @@ -211,6 +212,16 @@ class Role(models.Model): return self.name +class AuthData(models.Model): + user = models.ForeignKey('users.User', related_name="auth_data") + key = models.SlugField(max_length=50) + value = models.CharField(max_length=300) + extra = JsonField() + + class Meta: + unique_together = ["key", "value"] + + # On Role object is changed, update all membership # related to current role. @receiver(models.signals.post_save, sender=Role, diff --git a/taiga/users/permissions.py b/taiga/users/permissions.py index c067fa19..cbabe22c 100644 --- a/taiga/users/permissions.py +++ b/taiga/users/permissions.py @@ -14,9 +14,13 @@ # 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 (TaigaResourcePermission, IsSuperUser, - AllowAny, PermissionComponent, - IsAuthenticated) +from taiga.base.api.permissions import TaigaResourcePermission +from taiga.base.api.permissions import IsSuperUser +from taiga.base.api.permissions import AllowAny +from taiga.base.api.permissions import IsAuthenticated +from taiga.base.api.permissions import HasProjectPerm +from taiga.base.api.permissions import IsProjectOwner +from taiga.base.api.permissions import PermissionComponent class IsTheSameUser(PermissionComponent): @@ -35,6 +39,15 @@ class UserPermission(TaigaResourcePermission): change_password_from_recovery_perms = AllowAny() change_password_perms = IsAuthenticated() change_avatar_perms = IsAuthenticated() + me_perms = IsAuthenticated() remove_avatar_perms = IsAuthenticated() starred_perms = AllowAny() change_email_perms = IsTheSameUser() + + +class RolesPermission(TaigaResourcePermission): + retrieve_perms = HasProjectPerm('view_project') + create_perms = IsProjectOwner() + update_perms = IsProjectOwner() + destroy_perms = IsProjectOwner() + list_perms = AllowAny() diff --git a/taiga/users/serializers.py b/taiga/users/serializers.py index 8132c7db..5bb0d1ff 100644 --- a/taiga/users/serializers.py +++ b/taiga/users/serializers.py @@ -16,39 +16,52 @@ from django.core import validators from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from taiga.base.serializers import Serializer +from taiga.base.serializers import ModelSerializer +from taiga.base.serializers import PgArrayField -from .models import User +from .models import User, Role from .services import get_photo_or_gravatar_url, get_big_photo_or_gravatar_url import re -class UserSerializer(serializers.ModelSerializer): +###################################################### +## User +###################################################### + +class UserSerializer(ModelSerializer): full_name_display = serializers.SerializerMethodField("get_full_name_display") photo = serializers.SerializerMethodField("get_photo") big_photo = serializers.SerializerMethodField("get_big_photo") class Meta: model = User - fields = ("id", "username", "full_name", "full_name_display", "email", - "github_id", "color", "bio", "default_language", + # IMPORTANT: Maintain the UserAdminSerializer Meta up to date + # with this info (including there the email) + fields = ("id", "username", "full_name", "full_name_display", + "color", "bio", "default_language", "default_timezone", "is_active", "photo", "big_photo") - read_only_fields = ("id", "email", "github_id") + read_only_fields = ("id",) def validate_username(self, attrs, source): value = attrs[source] - validator = validators.RegexValidator(re.compile('^[\w.-]+$'), "invalid username", "invalid") + validator = validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), + _("invalid")) try: validator(value) except ValidationError: - raise serializers.ValidationError("Required. 255 characters or fewer. Letters, numbers " - "and /./-/_ characters'") + raise serializers.ValidationError(_("Required. 255 characters or fewer. Letters, " + "numbers and /./-/_ characters'")) - if self.object and self.object.username != value and User.objects.filter(username=value).exists(): - raise serializers.ValidationError("Invalid username. Try with a different one.") + if (self.object and + self.object.username != value and + User.objects.filter(username=value).exists()): + raise serializers.ValidationError(_("Invalid username. Try with a different one.")) return attrs @@ -62,14 +75,47 @@ class UserSerializer(serializers.ModelSerializer): return get_big_photo_or_gravatar_url(user) -class RecoverySerializer(serializers.Serializer): +class UserAdminSerializer(UserSerializer): + class Meta: + model = User + # IMPORTANT: Maintain the UserSerializer Meta up to date + # with this info (including here the email) + fields = ("id", "username", "full_name", "full_name_display", "email", + "color", "bio", "default_language", + "default_timezone", "is_active", "photo", "big_photo") + read_only_fields = ("id", "email") + + +class RecoverySerializer(Serializer): token = serializers.CharField(max_length=200) password = serializers.CharField(min_length=6) -class ChangeEmailSerializer(serializers.Serializer): +class ChangeEmailSerializer(Serializer): email_token = serializers.CharField(max_length=200) -class CancelAccountSerializer(serializers.Serializer): +class CancelAccountSerializer(Serializer): cancel_token = serializers.CharField(max_length=200) + + +###################################################### +## Role +###################################################### + +class RoleSerializer(ModelSerializer): + members_count = serializers.SerializerMethodField("get_members_count") + permissions = PgArrayField(required=False) + + class Meta: + model = Role + fields = ('id', 'name', 'permissions', 'computable', 'project', 'order', 'members_count') + + def get_members_count(self, obj): + return obj.memberships.count() + + +class ProjectRoleSerializer(ModelSerializer): + class Meta: + model = Role + fields = ('id', 'name', 'slug', 'order', 'computable') diff --git a/taiga/userstorage/api.py b/taiga/userstorage/api.py index 1ed184a9..31324c17 100644 --- a/taiga/userstorage/api.py +++ b/taiga/userstorage/api.py @@ -17,8 +17,6 @@ from django.utils.translation import ugettext_lazy as _ from django.db import IntegrityError -from rest_framework.permissions import IsAuthenticated - from taiga.base.api import ModelCrudViewSet from taiga.base import exceptions as exc diff --git a/taiga/userstorage/filters.py b/taiga/userstorage/filters.py index 265d7fb3..a46b1e61 100644 --- a/taiga/userstorage/filters.py +++ b/taiga/userstorage/filters.py @@ -16,6 +16,7 @@ from taiga.base import filters + class StorageEntriesFilterBackend(filters.FilterBackend): def filter_queryset(self, request, queryset, view): queryset = super().filter_queryset(request, queryset, view) diff --git a/taiga/userstorage/migrations/0002_fix_json_field_not_null.py b/taiga/userstorage/migrations/0002_fix_json_field_not_null.py new file mode 100644 index 00000000..f993919a --- /dev/null +++ b/taiga/userstorage/migrations/0002_fix_json_field_not_null.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django_pgjson.fields import JsonField + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userstorage', '0001_initial'), + ] + + operations = [ + migrations.RunSQL( + sql='ALTER TABLE userstorage_storageentry ALTER COLUMN value DROP NOT NULL;', + ), + ] diff --git a/taiga/userstorage/models.py b/taiga/userstorage/models.py index 15da5992..70dfb9ab 100644 --- a/taiga/userstorage/models.py +++ b/taiga/userstorage/models.py @@ -22,7 +22,7 @@ from django_pgjson.fields import JsonField class StorageEntry(models.Model): owner = models.ForeignKey(settings.AUTH_USER_MODEL, blank=False, null=False, - related_name="storage_entries", verbose_name=_("owner")) + related_name="storage_entries", verbose_name=_("owner")) created_date = models.DateTimeField(auto_now_add=True, null=False, blank=False, verbose_name=_("created date")) modified_date = models.DateTimeField(auto_now=True, null=False, blank=False, diff --git a/taiga/webhooks/api.py b/taiga/webhooks/api.py index cd219574..cda88dd7 100644 --- a/taiga/webhooks/api.py +++ b/taiga/webhooks/api.py @@ -14,14 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json - -from django.shortcuts import get_object_or_404 - -from rest_framework.response import Response - from taiga.base import filters -from taiga.base.api import ModelCrudViewSet, ModelListViewSet +from taiga.base import response +from taiga.base.api import ModelCrudViewSet +from taiga.base.api import ModelListViewSet + from taiga.base.decorators import detail_route from . import models @@ -45,7 +42,8 @@ class WebhookViewSet(ModelCrudViewSet): webhooklog = tasks.test_webhook(webhook.id, webhook.url, webhook.key) log = serializers.WebhookLogSerializer(webhooklog) - return Response(log.data) + return response.Ok(log.data) + class WebhookLogViewSet(ModelListViewSet): model = models.WebhookLog @@ -61,7 +59,9 @@ class WebhookLogViewSet(ModelListViewSet): webhook = webhooklog.webhook - webhooklog = tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data) + webhooklog = tasks.resend_webhook(webhook.id, webhook.url, webhook.key, + webhooklog.request_data) + log = serializers.WebhookLogSerializer(webhooklog) - return Response(log.data) + return response.Ok(log.data) diff --git a/taiga/webhooks/migrations/0004_auto_20150202_0834.py b/taiga/webhooks/migrations/0004_auto_20150202_0834.py index 0517c952..434eefce 100644 --- a/taiga/webhooks/migrations/0004_auto_20150202_0834.py +++ b/taiga/webhooks/migrations/0004_auto_20150202_0834.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): diff --git a/taiga/webhooks/models.py b/taiga/webhooks/models.py index 6154b523..41f6fc84 100644 --- a/taiga/webhooks/models.py +++ b/taiga/webhooks/models.py @@ -24,7 +24,7 @@ class Webhook(models.Model): project = models.ForeignKey("projects.Project", null=False, blank=False, related_name="webhooks") name = models.CharField(max_length=250, null=False, blank=False, - verbose_name=_("name")) + verbose_name=_("name")) url = models.URLField(null=False, blank=False, verbose_name=_("URL")) key = models.TextField(null=False, blank=False, verbose_name=_("secret key")) diff --git a/taiga/webhooks/serializers.py b/taiga/webhooks/serializers.py index ec087484..17712cc3 100644 --- a/taiga/webhooks/serializers.py +++ b/taiga/webhooks/serializers.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.core.exceptions import ObjectDoesNotExist + from rest_framework import serializers from taiga.base.serializers import TagsField, PgArrayField, JsonField @@ -35,6 +37,7 @@ class HistoryDiffField(serializers.Field): class WebhookSerializer(serializers.ModelSerializer): logs_counter = serializers.SerializerMethodField("get_logs_counter") + class Meta: model = Webhook @@ -61,6 +64,31 @@ class UserSerializer(serializers.Serializer): def get_name(self, obj): return obj.full_name + +class CustomAttributesValuesWebhookSerializerMixin(serializers.ModelSerializer): + custom_attributes_values = serializers.SerializerMethodField("get_custom_attributes_values") + + def custom_attributes_queryset(self, project): + raise NotImplementedError() + + def get_custom_attributes_values(self, obj): + def _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values): + ret = {} + for attr in custom_attributes: + value = values.get(str(attr["id"]), None) + if value is not None: + ret[attr["name"]] = value + + return ret + + try: + values = obj.custom_attributes_values.attributes_values + custom_attributes = self.custom_attributes_queryset(obj.project).values('id', 'name') + + return _use_name_instead_id_as_key_in_custom_attributes_values(custom_attributes, values) + except ObjectDoesNotExist: + return None + class PointSerializer(serializers.Serializer): id = serializers.SerializerMethodField("get_pk") name = serializers.SerializerMethodField("get_name") @@ -76,7 +104,7 @@ class PointSerializer(serializers.Serializer): return obj.value -class UserStorySerializer(serializers.ModelSerializer): +class UserStorySerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) external_reference = PgArrayField(required=False) owner = UserSerializer() @@ -88,8 +116,11 @@ class UserStorySerializer(serializers.ModelSerializer): model = us_models.UserStory exclude = ("backlog_order", "sprint_order", "kanban_order", "version") + def custom_attributes_queryset(self, project): + return project.userstorycustomattributes.all() -class TaskSerializer(serializers.ModelSerializer): + +class TaskSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() @@ -98,8 +129,11 @@ class TaskSerializer(serializers.ModelSerializer): class Meta: model = task_models.Task + def custom_attributes_queryset(self, project): + return project.taskcustomattributes.all() -class IssueSerializer(serializers.ModelSerializer): + +class IssueSerializer(CustomAttributesValuesWebhookSerializerMixin, serializers.ModelSerializer): tags = TagsField(default=[], required=False) owner = UserSerializer() assigned_to = UserSerializer() @@ -108,6 +142,9 @@ class IssueSerializer(serializers.ModelSerializer): class Meta: model = issue_models.Issue + def custom_attributes_queryset(self, project): + return project.issuecustomattributes.all() + class WikiPageSerializer(serializers.ModelSerializer): owner = UserSerializer() diff --git a/taiga/webhooks/tasks.py b/taiga/webhooks/tasks.py index 7a0f6dcd..2692d15c 100644 --- a/taiga/webhooks/tasks.py +++ b/taiga/webhooks/tasks.py @@ -86,7 +86,7 @@ def _send_request(webhook_id, url, key, data): duration=0) session.close() - ids = [webhook_log.id for webhook_log in WebhookLog.objects.filter(webhook_id=webhook_id).order_by("-id")[10:]] + ids = [log.id for log in WebhookLog.objects.filter(webhook_id=webhook_id).order_by("-id")[10:]] WebhookLog.objects.filter(id__in=ids).delete() return webhook_log @@ -135,4 +135,3 @@ def test_webhook(webhook_id, url, key): data['type'] = "test" return _send_request(webhook_id, url, key, data) - diff --git a/tests/conftest.py b/tests/conftest.py index f7fd7cf7..ed81ee80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,4 +34,3 @@ def pytest_runtest_setup(item): def pytest_configure(config): django.setup() - diff --git a/tests/factories.py b/tests/factories.py index 14f120a8..4e9b9d0c 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -155,16 +155,6 @@ class WikiAttachmentFactory(Factory): strategy = factory.CREATE_STRATEGY -class RolePointsFactory(Factory): - class Meta: - model = "userstories.RolePoints" - strategy = factory.CREATE_STRATEGY - - user_story = factory.SubFactory("tests.factories.UserStoryFactory") - role = factory.SubFactory("tests.factories.RoleFactory") - points = factory.SubFactory("tests.factories.PointsFactory") - - class UserFactory(Factory): class Meta: model = "users.User" @@ -216,8 +206,8 @@ class WebhookLogFactory(Factory): webhook = factory.SubFactory("tests.factories.WebhookFactory") url = "http://localhost:8080/test" status = "200" - request_data = "test-request" - response_data = "test-response" + request_data = {"text": "test-request-data"} + response_data = {"text": "test-response-data"} class StorageEntryFactory(Factory): @@ -227,7 +217,7 @@ class StorageEntryFactory(Factory): owner = factory.SubFactory("tests.factories.UserFactory") key = factory.Sequence(lambda n: "key-{}".format(n)) - value = factory.Sequence(lambda n: "value {}".format(n)) + value = factory.Sequence(lambda n: {"value": "value-{}".format(n)}) class UserStoryFactory(Factory): @@ -252,7 +242,6 @@ class UserStoryStatusFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") - class TaskStatusFactory(Factory): class Meta: model = "projects.TaskStatus" @@ -279,6 +268,7 @@ class IssueFactory(Factory): model = "issues.Issue" strategy = factory.CREATE_STRATEGY + ref = factory.Sequence(lambda n: n) subject = factory.Sequence(lambda n: "Issue {}".format(n)) description = factory.Sequence(lambda n: "Issue {} description".format(n)) owner = factory.SubFactory("tests.factories.UserFactory") @@ -337,15 +327,6 @@ class IssueStatusFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") -class UserStoryStatusFactory(Factory): - class Meta: - model = "projects.UserStoryStatus" - strategy = factory.CREATE_STRATEGY - - name = factory.Sequence(lambda n: "User Story Status {}".format(n)) - project = factory.SubFactory("tests.factories.ProjectFactory") - - class SeverityFactory(Factory): class Meta: model = "projects.Severity" @@ -373,6 +354,63 @@ class IssueTypeFactory(Factory): project = factory.SubFactory("tests.factories.ProjectFactory") +class UserStoryCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "UserStory Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for UserStory Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class TaskCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Task Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Task Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class IssueCustomAttributeFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttribute" + strategy = factory.CREATE_STRATEGY + + name = factory.Sequence(lambda n: "Issue Custom Attribute {}".format(n)) + description = factory.Sequence(lambda n: "Description for Issue Custom Attribute {}".format(n)) + project = factory.SubFactory("tests.factories.ProjectFactory") + + +class UserStoryCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.UserStoryCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + user_story = factory.SubFactory("tests.factories.UserStoryFactory") + + +class TaskCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.TaskCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + task = factory.SubFactory("tests.factories.TaskFactory") + + +class IssueCustomAttributesValuesFactory(Factory): + class Meta: + model = "custom_attributes.IssueCustomAttributesValues" + strategy = factory.CREATE_STRATEGY + + attributes_values = {} + issue = factory.SubFactory("tests.factories.IssueFactory") + + # class FanFactory(Factory): # project = factory.SubFactory("tests.factories.ProjectFactory") # user = factory.SubFactory("tests.factories.UserFactory") @@ -431,6 +469,7 @@ class HistoryEntryFactory(Factory): type = 1 + def create_issue(**kwargs): "Create an issue and along with its dependencies." owner = kwargs.pop("owner", None) diff --git a/tests/integration/resources_permissions/test_attachment_resources.py b/tests/integration/resources_permissions/test_attachment_resources.py index d198bd98..520e266a 100644 --- a/tests/integration/resources_permissions/test_attachment_resources.py +++ b/tests/integration/resources_permissions/test_attachment_resources.py @@ -49,9 +49,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -83,6 +83,7 @@ def data(): return m + @pytest.fixture def data_us(data): m = type("Models", (object,), {}) @@ -97,6 +98,7 @@ def data_us(data): content_object=m.private_user_story2) return m + @pytest.fixture def data_task(data): m = type("Models", (object,), {}) @@ -108,6 +110,7 @@ def data_task(data): m.private_task2_attachment = f.TaskAttachmentFactory(project=data.private_project2, content_object=m.private_task2) return m + @pytest.fixture def data_issue(data): m = type("Models", (object,), {}) @@ -119,6 +122,7 @@ def data_issue(data): m.private_issue2_attachment = f.IssueAttachmentFactory(project=data.private_project2, content_object=m.private_issue2) return m + @pytest.fixture def data_wiki(data): m = type("Models", (object,), {}) @@ -130,6 +134,7 @@ def data_wiki(data): m.private_wiki2_attachment = f.WikiAttachmentFactory(project=data.private_project2, content_object=m.private_wiki2) return m + def test_user_story_attachment_retrieve(client, data, data_us): public_url = reverse('userstory-attachments-detail', kwargs={"pk": data_us.public_user_story_attachment.pk}) private_url1 = reverse('userstory-attachments-detail', kwargs={"pk": data_us.private_user_story1_attachment.pk}) @@ -507,6 +512,7 @@ def test_wiki_attachment_delete(client, data, data_wiki): results = helper_test_http_method(client, 'delete', private_url2, None, users) assert results == [401, 403, 403, 204] + def test_user_story_attachment_create(client, data, data_us): url = reverse('userstory-attachments-list') diff --git a/tests/integration/resources_permissions/test_auth_resources.py b/tests/integration/resources_permissions/test_auth_resources.py index 8f51080a..0cc4cedc 100644 --- a/tests/integration/resources_permissions/test_auth_resources.py +++ b/tests/integration/resources_permissions/test_auth_resources.py @@ -1,6 +1,5 @@ from django.core.urlresolvers import reverse -from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from taiga.base.utils import json from tests import factories as f diff --git a/tests/integration/resources_permissions/test_history_resources.py b/tests/integration/resources_permissions/test_history_resources.py index fe85eda2..4faf909d 100644 --- a/tests/integration/resources_permissions/test_history_resources.py +++ b/tests/integration/resources_permissions/test_history_resources.py @@ -41,9 +41,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -75,6 +75,7 @@ def data(): is_owner=True) return m + @pytest.fixture def data_us(data): m = type("Models", (object,), {}) @@ -83,6 +84,7 @@ def data_us(data): m.private_user_story2 = f.UserStoryFactory(project=data.private_project2, ref=9) return m + @pytest.fixture def data_task(data): m = type("Models", (object,), {}) @@ -91,6 +93,7 @@ def data_task(data): m.private_task2 = f.TaskFactory(project=data.private_project2, ref=10) return m + @pytest.fixture def data_issue(data): m = type("Models", (object,), {}) @@ -99,6 +102,7 @@ def data_issue(data): m.private_issue2 = f.IssueFactory(project=data.private_project2, ref=11) return m + @pytest.fixture def data_wiki(data): m = type("Models", (object,), {}) diff --git a/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py new file mode 100644 index 00000000..e8e87048 --- /dev/null +++ b/tests/integration/resources_permissions/test_issues_custom_attributes_resource.py @@ -0,0 +1,398 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + 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, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + 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, + email=m.project_member_without_perms.email, + 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_ca = f.IssueCustomAttributeFactory(project=m.public_project) + m.private_issue_ca1 = f.IssueCustomAttributeFactory(project=m.private_project1) + m.private_issue_ca2 = f.IssueCustomAttributeFactory(project=m.private_project2) + + m.public_issue = f.IssueFactory(project=m.public_project, + status__project=m.public_project, + severity__project=m.public_project, + priority__project=m.public_project, + type__project=m.public_project, + milestone__project=m.public_project) + m.private_issue1 = f.IssueFactory(project=m.private_project1, + status__project=m.private_project1, + severity__project=m.private_project1, + priority__project=m.private_project1, + type__project=m.private_project1, + milestone__project=m.private_project1) + m.private_issue2 = f.IssueFactory(project=m.private_project2, + status__project=m.private_project2, + severity__project=m.private_project2, + priority__project=m.private_project2, + type__project=m.private_project2, + milestone__project=m.private_project2) + + m.public_issue_cav = m.public_issue.custom_attributes_values + m.private_issue_cav1 = m.private_issue1.custom_attributes_values + m.private_issue_cav2 = m.private_issue2.custom_attributes_values + + return m + + +######################################################### +# Issue Custom Attribute +######################################################### + +def test_issue_custom_attribute_retrieve(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attribute_create(client, data): + public_url = reverse('issue-custom-attributes-list') + private1_url = reverse('issue-custom-attributes-list') + private2_url = reverse('issue-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = {"name": "test-new", "project": data.public_project.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project1.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + issue_ca_data = {"name": "test-new", "project": data.private_project2.id} + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'post', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_issue_custom_attribute_update(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.public_issue_ca).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', public_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca1).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private1_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + issue_ca_data = serializers.IssueCustomAttributeSerializer(data.private_issue_ca2).data + issue_ca_data["name"] = "test" + issue_ca_data = json.dumps(issue_ca_data) + results = helper_test_http_method(client, 'put', private2_url, issue_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_issue_custom_attribute_delete(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + + +def test_issue_custom_attribute_list(client, data): + url = reverse('issue-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_issue_custom_attribute_patch(client, data): + public_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.public_issue_ca.pk}) + private1_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca1.pk}) + private2_url = reverse('issue-custom-attributes-detail', kwargs={"pk": data.private_issue_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + + +def test_issue_custom_attribute_action_bulk_update_order(client, data): + url = reverse('issue-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_issue_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + +######################################################### +# Issue Custom Attribute +######################################################### + + +def test_issue_custom_attributes_values_retrieve(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_issue_custom_attributes_values_update(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + issue_data = serializers.IssueCustomAttributesValuesSerializer(data.public_issue_cav).data + issue_data["attributes_values"] = {str(data.public_issue_ca.pk): "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 = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav1).data + issue_data["attributes_values"] = {str(data.private_issue_ca1.pk): "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 = serializers.IssueCustomAttributesValuesSerializer(data.private_issue_cav2).data + issue_data["attributes_values"] = {str(data.private_issue_ca2.pk): "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_custom_attributes_values_patch(client, data): + public_url = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.public_issue.pk}) + private_url1 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue1.pk}) + private_url2 = reverse('issue-custom-attributes-values-detail', kwargs={"issue_id": data.private_issue2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_issue_ca.pk): "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({"attributes_values": {str(data.private_issue_ca1.pk): "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({"attributes_values": {str(data.private_issue_ca2.pk): "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] diff --git a/tests/integration/resources_permissions/test_issues_resources.py b/tests/integration/resources_permissions/test_issues_resources.py index d4895443..c6c99f2d 100644 --- a/tests/integration/resources_permissions/test_issues_resources.py +++ b/tests/integration/resources_permissions/test_issues_resources.py @@ -1,3 +1,5 @@ +import uuid + from django.core.urlresolvers import reverse from taiga.projects.issues.serializers import IssueSerializer @@ -36,20 +38,23 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], - owner=m.project_owner) + owner=m.project_owner, + issues_csv_uuid=uuid.uuid4().hex) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -135,7 +140,7 @@ def test_issue_update(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): issue_data = IssueSerializer(data.public_issue).data issue_data["subject"] = "test" issue_data = json.dumps(issue_data) @@ -205,6 +210,25 @@ def test_issue_list(client, data): assert response.status_code == 200 +def test_issue_list_filter_by_project_ok(client, data): + url = "{}?project={}".format(reverse("issues-list"), data.public_project.pk) + + client.login(data.project_owner) + response = client.get(url) + + assert response.status_code == 200 + assert len(response.data) == 1 + + +def test_issue_list_filter_by_project_error(client, data): + url = "{}?project={}".format(reverse("issues-list"), "-ERROR-") + + client.login(data.project_owner) + response = client.get(url) + + assert response.status_code == 400 + + def test_issue_create(client, data): url = reverse('issues-list') @@ -266,7 +290,7 @@ def test_issue_patch(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): 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] @@ -393,6 +417,7 @@ def test_issue_voters_list(client, data): results = helper_test_http_method(client, 'get', private_url2, None, users) assert results == [401, 403, 403, 200, 200] + def test_issue_voters_retrieve(client, data): add_vote(data.public_issue, data.project_owner) public_url = reverse('issue-voters-detail', kwargs={"issue_id": data.public_issue.pk, "pk": data.project_owner.pk}) @@ -417,3 +442,27 @@ def test_issue_voters_retrieve(client, data): results = helper_test_http_method(client, 'get', private_url2, None, users) assert results == [401, 403, 403, 200, 200] + + +def test_issues_csv(client, data): + url = reverse('issues-csv') + csv_public_uuid = data.public_project.issues_csv_uuid + csv_private1_uuid = data.private_project1.issues_csv_uuid + csv_private2_uuid = data.private_project1.issues_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_milestones_resources.py b/tests/integration/resources_permissions/test_milestones_resources.py index ce6fb6de..955754f9 100644 --- a/tests/integration/resources_permissions/test_milestones_resources.py +++ b/tests/integration/resources_permissions/test_milestones_resources.py @@ -44,9 +44,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, diff --git a/tests/integration/resources_permissions/test_projects_choices_resources.py b/tests/integration/resources_permissions/test_projects_choices_resources.py index ed0a6a77..c94ec9cc 100644 --- a/tests/integration/resources_permissions/test_projects_choices_resources.py +++ b/tests/integration/resources_permissions/test_projects_choices_resources.py @@ -2,6 +2,7 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.projects import serializers +from taiga.users.serializers import RoleSerializer from taiga.permissions.permissions import MEMBERS_PERMISSIONS from tests import factories as f @@ -35,10 +36,10 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - email=m.project_member_with_perms.email, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, email=m.project_member_with_perms.email, @@ -140,19 +141,19 @@ def test_roles_update(client, data): data.project_owner ] - role_data = serializers.RoleSerializer(data.public_project.roles.all()[0]).data + role_data = RoleSerializer(data.public_project.roles.all()[0]).data role_data["name"] = "test" role_data = json.dumps(role_data) results = helper_test_http_method(client, 'put', public_url, role_data, users) assert results == [401, 403, 403, 403, 200] - role_data = serializers.RoleSerializer(data.private_project1.roles.all()[0]).data + role_data = RoleSerializer(data.private_project1.roles.all()[0]).data role_data["name"] = "test" role_data = json.dumps(role_data) results = helper_test_http_method(client, 'put', private1_url, role_data, users) assert results == [401, 403, 403, 403, 200] - role_data = serializers.RoleSerializer(data.private_project2.roles.all()[0]).data + role_data = RoleSerializer(data.private_project2.roles.all()[0]).data role_data["name"] = "test" role_data = json.dumps(role_data) results = helper_test_http_method(client, 'put', private2_url, role_data, users) @@ -374,21 +375,21 @@ def test_points_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_points": [(1,2)], + "bulk_points": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_points": [(1,2)], + "bulk_points": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_points": [(1,2)], + "bulk_points": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -535,21 +536,21 @@ def test_user_story_status_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_userstory_statuses": [(1,2)], + "bulk_userstory_statuses": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_userstory_statuses": [(1,2)], + "bulk_userstory_statuses": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_userstory_statuses": [(1,2)], + "bulk_userstory_statuses": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -696,21 +697,21 @@ def test_task_status_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_task_statuses": [(1,2)], + "bulk_task_statuses": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_task_statuses": [(1,2)], + "bulk_task_statuses": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_task_statuses": [(1,2)], + "bulk_task_statuses": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -857,21 +858,21 @@ def test_issue_status_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_issue_statuses": [(1,2)], + "bulk_issue_statuses": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_issue_statuses": [(1,2)], + "bulk_issue_statuses": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_issue_statuses": [(1,2)], + "bulk_issue_statuses": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -1018,21 +1019,21 @@ def test_issue_type_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_issue_types": [(1,2)], + "bulk_issue_types": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_issue_types": [(1,2)], + "bulk_issue_types": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_issue_types": [(1,2)], + "bulk_issue_types": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -1179,21 +1180,21 @@ def test_priority_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_priorities": [(1,2)], + "bulk_priorities": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_priorities": [(1,2)], + "bulk_priorities": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_priorities": [(1,2)], + "bulk_priorities": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -1340,21 +1341,21 @@ def test_severity_action_bulk_update_order(client, data): ] post_data = json.dumps({ - "bulk_severities": [(1,2)], + "bulk_severities": [(1, 2)], "project": data.public_project.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_severities": [(1,2)], + "bulk_severities": [(1, 2)], "project": data.private_project1.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 403, 204] post_data = json.dumps({ - "bulk_severities": [(1,2)], + "bulk_severities": [(1, 2)], "project": data.private_project2.pk }) results = helper_test_http_method(client, 'post', url, post_data, users) @@ -1488,6 +1489,7 @@ def test_membership_patch(client, data): results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) assert results == [401, 403, 403, 403, 200] + def test_membership_create(client, data): url = reverse('memberships-list') diff --git a/tests/integration/resources_permissions/test_projects_resource.py b/tests/integration/resources_permissions/test_projects_resource.py index 6518734b..0c97c952 100644 --- a/tests/integration/resources_permissions/test_projects_resource.py +++ b/tests/integration/resources_permissions/test_projects_resource.py @@ -369,3 +369,66 @@ def test_invitations_retrieve(client, data): ] results = helper_test_http_method(client, 'get', url, None, users) assert results == [200, 200, 200, 200] + + +def test_regenerate_userstories_csv_uuid(client, data): + public_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 403, 200] + + +def test_regenerate_tasks_csv_uuid(client, data): + public_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 403, 200] + + +def test_regenerate_issues_csv_uuid(client, data): + public_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.public_project.pk}) + private1_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project1.pk}) + private2_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_with_perms, + data.project_owner + ] + results = helper_test_http_method(client, 'post', public_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private1_url, None, users) + assert results == [401, 403, 403, 200] + + results = helper_test_http_method(client, 'post', private2_url, None, users) + assert results == [404, 404, 403, 200] diff --git a/tests/integration/resources_permissions/test_resolver_resources.py b/tests/integration/resources_permissions/test_resolver_resources.py index 009500ff..0cb484a3 100644 --- a/tests/integration/resources_permissions/test_resolver_resources.py +++ b/tests/integration/resources_permissions/test_resolver_resources.py @@ -1,6 +1,5 @@ from django.core.urlresolvers import reverse -from taiga.base.utils import json from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from tests import factories as f @@ -45,9 +44,9 @@ def data(): slug="private2") m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -78,14 +77,14 @@ def data(): is_owner=True) m.view_only_membership = f.MembershipFactory(project=m.private_project2, - user=m.other_user, - role__project=m.private_project2, - role__permissions=["view_project"]) + user=m.other_user, + role__project=m.private_project2, + role__permissions=["view_project"]) - f.UserStoryFactory(project=m.private_project2, ref=1, pk=1) - f.TaskFactory(project=m.private_project2, ref=2, pk=1) - f.IssueFactory(project=m.private_project2, ref=3, pk=1) - m.milestone = f.MilestoneFactory(project=m.private_project2, slug=4, pk=1) + m.us = f.UserStoryFactory(project=m.private_project2, ref=1) + m.task = f.TaskFactory(project=m.private_project2, ref=2) + m.issue = f.IssueFactory(project=m.private_project2, ref=3) + m.milestone = f.MilestoneFactory(project=m.private_project2, slug="milestone-test-1") return m @@ -101,17 +100,31 @@ def test_resolver_list(client, data): data.project_owner ] - results = helper_test_http_method(client, 'get', "{}?project=public".format(url), None, users) + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.public_project.slug), None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', "{}?project=private1".format(url), None, users) + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.private_project1.slug), None, users) assert results == [200, 200, 200, 200, 200] - results = helper_test_http_method(client, 'get', "{}?project=private2".format(url), None, users) + results = helper_test_http_method(client, 'get', "{}?project={}".format(url, data.private_project2.slug), None, users) assert results == [401, 403, 403, 200, 200] client.login(data.other_user) - response = client.get("{}?project=private2&us=1&task=2&issue=3&milestone=4".format(url)) - assert json.loads(response.content.decode('utf-8')) == {"project": data.private_project2.pk} + response = client.json.get("{}?project={}&us={}&task={}&issue={}&milestone={}".format(url, + data.private_project2.slug, + data.us.ref, + data.task.ref, + data.issue.ref, + data.milestone.slug)) + assert response.data == {"project": data.private_project2.pk} client.login(data.project_owner) - response = client.get("{}?project=private2&us=1&task=2&issue=3&milestone=4".format(url)) - assert json.loads(response.content.decode('utf-8')) == {"project": data.private_project2.pk, "us": 1, "task": 1, "issue": 1, "milestone": data.milestone.pk} + response = client.json.get("{}?project={}&us={}&task={}&issue={}&milestone={}".format(url, + data.private_project2.slug, + data.us.ref, + data.task.ref, + data.issue.ref, + data.milestone.slug)) + assert response.data == {"project": data.private_project2.pk, + "us": data.us.pk, + "task": data.task.pk, + "issue": data.issue.pk, + "milestone": data.milestone.pk} diff --git a/tests/integration/resources_permissions/test_search_resources.py b/tests/integration/resources_permissions/test_search_resources.py index ef35d050..5bca5dc5 100644 --- a/tests/integration/resources_permissions/test_search_resources.py +++ b/tests/integration/resources_permissions/test_search_resources.py @@ -1,6 +1,5 @@ from django.core.urlresolvers import reverse -from taiga.projects.issues.serializers import IssueSerializer from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from tests import factories as f @@ -42,9 +41,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, diff --git a/tests/integration/resources_permissions/test_storage_resources.py b/tests/integration/resources_permissions/test_storage_resources.py index 8f7f1f05..65c095ed 100644 --- a/tests/integration/resources_permissions/test_storage_resources.py +++ b/tests/integration/resources_permissions/test_storage_resources.py @@ -1,8 +1,6 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json -from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS -from taiga.projects.votes.services import add_vote from taiga.userstorage.serializers import StorageEntrySerializer from taiga.userstorage.models import StorageEntry @@ -103,7 +101,7 @@ def test_storage_create(client, data): create_data = json.dumps({ "key": "test", - "value": "test", + "value": {"test": "test-value"}, }) results = helper_test_http_method(client, 'post', url, create_data, users, lambda: StorageEntry.objects.all().delete()) assert results == [401, 201, 201] @@ -118,6 +116,6 @@ def test_storage_patch(client, data): data.user2, ] - patch_data = json.dumps({"value": "test"}) + patch_data = json.dumps({"value": {"test": "test-value"}}) results = helper_test_http_method(client, 'patch', url, patch_data, users) assert results == [401, 200, 201] diff --git a/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py new file mode 100644 index 00000000..773c44cb --- /dev/null +++ b/tests/integration/resources_permissions/test_tasks_custom_attributes_resource.py @@ -0,0 +1,392 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + 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, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + 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, + email=m.project_member_without_perms.email, + 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_ca = f.TaskCustomAttributeFactory(project=m.public_project) + m.private_task_ca1 = f.TaskCustomAttributeFactory(project=m.private_project1) + m.private_task_ca2 = f.TaskCustomAttributeFactory(project=m.private_project2) + + m.public_task = f.TaskFactory(project=m.public_project, + status__project=m.public_project, + milestone__project=m.public_project, + user_story__project=m.public_project) + m.private_task1 = f.TaskFactory(project=m.private_project1, + status__project=m.private_project1, + milestone__project=m.private_project1, + user_story__project=m.private_project1) + m.private_task2 = f.TaskFactory(project=m.private_project2, + status__project=m.private_project2, + milestone__project=m.private_project2, + user_story__project=m.private_project2) + + m.public_task_cav = m.public_task.custom_attributes_values + m.private_task_cav1 = m.private_task1.custom_attributes_values + m.private_task_cav2 = m.private_task2.custom_attributes_values + + return m + + +######################################################### +# Task Custom Attribute +######################################################### + +def test_task_custom_attribute_retrieve(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attribute_create(client, data): + public_url = reverse('task-custom-attributes-list') + private1_url = reverse('task-custom-attributes-list') + private2_url = reverse('task-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = {"name": "test-new", "project": data.public_project.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project1.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + task_ca_data = {"name": "test-new", "project": data.private_project2.id} + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'post', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_task_custom_attribute_update(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.public_task_ca).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', public_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca1).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private1_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + task_ca_data = serializers.TaskCustomAttributeSerializer(data.private_task_ca2).data + task_ca_data["name"] = "test" + task_ca_data = json.dumps(task_ca_data) + results = helper_test_http_method(client, 'put', private2_url, task_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_task_custom_attribute_delete(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + + +def test_task_custom_attribute_list(client, data): + url = reverse('task-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_task_custom_attribute_patch(client, data): + public_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.public_task_ca.pk}) + private1_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca1.pk}) + private2_url = reverse('task-custom-attributes-detail', kwargs={"pk": data.private_task_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + + +def test_task_custom_attribute_action_bulk_update_order(client, data): + url = reverse('task-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_task_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + +######################################################### +# Task Custom Attribute +######################################################### + + +def test_task_custom_attributes_values_retrieve(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_task_custom_attributes_values_update(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + task_data = serializers.TaskCustomAttributesValuesSerializer(data.public_task_cav).data + task_data["attributes_values"] = {str(data.public_task_ca.pk): "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 = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav1).data + task_data["attributes_values"] = {str(data.private_task_ca1.pk): "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 = serializers.TaskCustomAttributesValuesSerializer(data.private_task_cav2).data + task_data["attributes_values"] = {str(data.private_task_ca2.pk): "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_custom_attributes_values_patch(client, data): + public_url = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.public_task.pk}) + private_url1 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task1.pk}) + private_url2 = reverse('task-custom-attributes-values-detail', kwargs={"task_id": data.private_task2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_task_ca.pk): "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({"attributes_values": {str(data.private_task_ca1.pk): "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({"attributes_values": {str(data.private_task_ca2.pk): "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] diff --git a/tests/integration/resources_permissions/test_tasks_resources.py b/tests/integration/resources_permissions/test_tasks_resources.py index 7d63db6a..22bb719f 100644 --- a/tests/integration/resources_permissions/test_tasks_resources.py +++ b/tests/integration/resources_permissions/test_tasks_resources.py @@ -1,3 +1,5 @@ +import uuid + from django.core.urlresolvers import reverse from taiga.base.utils import json @@ -35,20 +37,23 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], - owner=m.project_owner) + owner=m.project_owner, + tasks_csv_uuid=uuid.uuid4().hex) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -135,7 +140,7 @@ def test_task_update(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): task_data = TaskSerializer(data.public_task).data task_data["subject"] = "test" task_data = json.dumps(task_data) @@ -256,7 +261,7 @@ def test_task_patch(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): 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] @@ -307,3 +312,27 @@ def test_task_action_bulk_create(client, data): }) results = helper_test_http_method(client, 'post', url, bulk_data, users) assert results == [401, 403, 403, 200, 200] + + +def test_tasks_csv(client, data): + url = reverse('tasks-csv') + csv_public_uuid = data.public_project.tasks_csv_uuid + csv_private1_uuid = data.private_project1.tasks_csv_uuid + csv_private2_uuid = data.private_project1.tasks_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_timelines_resources.py b/tests/integration/resources_permissions/test_timelines_resources.py index 63948ff8..e33dc043 100644 --- a/tests/integration/resources_permissions/test_timelines_resources.py +++ b/tests/integration/resources_permissions/test_timelines_resources.py @@ -41,9 +41,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, diff --git a/tests/integration/resources_permissions/test_users_resources.py b/tests/integration/resources_permissions/test_users_resources.py index df172a1a..7e3f9659 100644 --- a/tests/integration/resources_permissions/test_users_resources.py +++ b/tests/integration/resources_permissions/test_users_resources.py @@ -47,6 +47,18 @@ def test_user_retrieve(client, data): assert results == [401, 200, 403, 200] +def test_user_me(client, data): + url = reverse('users-me') + + users = [ + None, + data.registered_user + ] + + results = helper_test_http_method(client, 'get', url, None, users) + assert results == [401, 200] + + def test_user_update(client, data): url = reverse('users-detail', kwargs={"pk": data.registered_user.pk}) @@ -74,7 +86,7 @@ def test_user_delete(client, data): ] results = helper_test_http_method(client, 'delete', url, None, users) - assert results == [401, 403, 204] + assert results == [404, 404, 204] def test_user_list(client, data): @@ -89,21 +101,21 @@ def test_user_list(client, data): response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 0 + assert len(users_data) == 1 assert response.status_code == 200 client.login(data.other_user) response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 0 + assert len(users_data) == 1 assert response.status_code == 200 client.login(data.superuser) response = client.get(url) users_data = json.loads(response.content.decode('utf-8')) - assert len(users_data) == 6 + assert len(users_data) == 3 assert response.status_code == 200 @@ -134,7 +146,8 @@ def test_user_patch(client, data): patch_data = json.dumps({"full_name": "test"}) results = helper_test_http_method(client, 'patch', url, patch_data, users) - assert results == [401, 200, 403, 200] + assert results == [404, 200, 404, 200] + def test_user_action_change_password(client, data): url = reverse('users-change-password') @@ -153,7 +166,6 @@ def test_user_action_change_password(client, data): data.superuser, ] - post_data = json.dumps({"current_password": "test-current-password", "password": "test-password"}) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 204, 204, 204] @@ -224,6 +236,7 @@ def test_user_action_change_password_from_recovery(client, data): results = helper_test_http_method(client, 'post', url, patch_data, users, reset_token) assert results == [204, 204, 204, 204] + def test_user_action_password_recovery(client, data): url = reverse('users-password-recovery') @@ -240,6 +253,7 @@ def test_user_action_password_recovery(client, data): results = helper_test_http_method(client, 'post', url, patch_data, users) assert results == [200, 200, 200, 200] + def test_user_action_change_email(client, data): url = reverse('users-change-email') diff --git a/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py new file mode 100644 index 00000000..17a30bc8 --- /dev/null +++ b/tests/integration/resources_permissions/test_userstories_custom_attributes_resource.py @@ -0,0 +1,398 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.core.urlresolvers import reverse + +from taiga.base.utils import json +from taiga.projects.custom_attributes import serializers +from taiga.permissions.permissions import (MEMBERS_PERMISSIONS, + ANON_PERMISSIONS, USER_PERMISSIONS) + + +from tests import factories as f +from tests.utils import helper_test_http_method + +import pytest +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def data(): + m = type("Models", (object,), {}) + m.registered_user = f.UserFactory.create() + m.project_member_with_perms = f.UserFactory.create() + m.project_member_without_perms = f.UserFactory.create() + m.project_owner = f.UserFactory.create() + m.other_user = f.UserFactory.create() + m.superuser = f.UserFactory.create(is_superuser=True) + + m.public_project = f.ProjectFactory(is_private=False, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project1 = f.ProjectFactory(is_private=True, + anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), + public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), + owner=m.project_owner) + m.private_project2 = f.ProjectFactory(is_private=True, + anon_permissions=[], + public_permissions=[], + owner=m.project_owner) + + m.public_membership = f.MembershipFactory(project=m.public_project, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + m.private_membership1 = f.MembershipFactory(project=m.private_project1, + user=m.project_member_with_perms, + 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, + role__project=m.private_project1, + role__permissions=[]) + + m.private_membership2 = f.MembershipFactory(project=m.private_project2, + user=m.project_member_with_perms, + email=m.project_member_with_perms.email, + 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, + email=m.project_member_without_perms.email, + 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_userstory_ca = f.UserStoryCustomAttributeFactory(project=m.public_project) + m.private_userstory_ca1 = f.UserStoryCustomAttributeFactory(project=m.private_project1) + m.private_userstory_ca2 = f.UserStoryCustomAttributeFactory(project=m.private_project2) + + + m.public_user_story = f.UserStoryFactory(project=m.public_project, + status__project=m.public_project) + m.private_user_story1 = f.UserStoryFactory(project=m.private_project1, + status__project=m.private_project1) + m.private_user_story2 = f.UserStoryFactory(project=m.private_project2, + status__project=m.private_project2) + + m.public_user_story_cav = m.public_user_story.custom_attributes_values + m.private_user_story_cav1 = m.private_user_story1.custom_attributes_values + m.private_user_story_cav2 = m.private_user_story2.custom_attributes_values + + return m + + +######################################################### +# User Story Custom Attribute +######################################################### + +def test_userstory_custom_attribute_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private1_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private2_url, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attribute_create(client, data): + public_url = reverse('userstory-custom-attributes-list') + private1_url = reverse('userstory-custom-attributes-list') + private2_url = reverse('userstory-custom-attributes-list') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = {"name": "test-new", "project": data.public_project.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project1.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + userstory_ca_data = {"name": "test-new", "project": data.private_project2.id} + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'post', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 201] + + +def test_userstory_custom_attribute_update(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.public_userstory_ca).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', public_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca1).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private1_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + userstory_ca_data = serializers.UserStoryCustomAttributeSerializer(data.private_userstory_ca2).data + userstory_ca_data["name"] = "test" + userstory_ca_data = json.dumps(userstory_ca_data) + results = helper_test_http_method(client, 'put', private2_url, userstory_ca_data, users) + assert results == [401, 403, 403, 403, 200] + + +def test_userstory_custom_attribute_delete(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'delete', public_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private1_url, None, users) + assert results == [401, 403, 403, 403, 204] + results = helper_test_http_method(client, 'delete', private2_url, None, users) + assert results == [401, 403, 403, 403, 204] + + +def test_userstory_custom_attribute_list(client, data): + url = reverse('userstory-custom-attributes-list') + + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.registered_user) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_without_perms) + response = client.json.get(url) + assert len(response.data) == 2 + assert response.status_code == 200 + + client.login(data.project_member_with_perms) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + client.login(data.project_owner) + response = client.json.get(url) + assert len(response.data) == 3 + assert response.status_code == 200 + + +def test_userstory_custom_attribute_patch(client, data): + public_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.public_userstory_ca.pk}) + private1_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca1.pk}) + private2_url = reverse('userstory-custom-attributes-detail', kwargs={"pk": data.private_userstory_ca2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'patch', public_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private1_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + results = helper_test_http_method(client, 'patch', private2_url, '{"name": "Test"}', users) + assert results == [401, 403, 403, 403, 200] + + +def test_userstory_custom_attribute_action_bulk_update_order(client, data): + url = reverse('userstory-custom-attributes-bulk-update-order') + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.public_project.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project1.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + post_data = json.dumps({ + "bulk_userstory_custom_attributes": [(1,2)], + "project": data.private_project2.pk + }) + results = helper_test_http_method(client, 'post', url, post_data, users) + assert results == [401, 403, 403, 403, 204] + + + +######################################################### +# UserStory Custom Attribute +######################################################### + + +def test_userstory_custom_attributes_values_retrieve(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', public_url, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url1, None, users) + assert results == [200, 200, 200, 200, 200] + results = helper_test_http_method(client, 'get', private_url2, None, users) + assert results == [401, 403, 403, 200, 200] + + +def test_userstory_custom_attributes_values_update(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + user_story_data = serializers.UserStoryCustomAttributesValuesSerializer(data.public_user_story_cav).data + user_story_data["attributes_values"] = {str(data.public_userstory_ca.pk): "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 = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav1).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca1.pk): "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 = serializers.UserStoryCustomAttributesValuesSerializer(data.private_user_story_cav2).data + user_story_data["attributes_values"] = {str(data.private_userstory_ca2.pk): "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_userstory_custom_attributes_values_patch(client, data): + public_url = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.public_user_story.pk}) + private_url1 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story1.pk}) + private_url2 = reverse('userstory-custom-attributes-values-detail', kwargs={ + "user_story_id": data.private_user_story2.pk}) + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + patch_data = json.dumps({"attributes_values": {str(data.public_userstory_ca.pk): "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({"attributes_values": {str(data.private_userstory_ca1.pk): "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({"attributes_values": {str(data.private_userstory_ca2.pk): "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] diff --git a/tests/integration/resources_permissions/test_userstories_resources.py b/tests/integration/resources_permissions/test_userstories_resources.py index 1b006935..3a718cc7 100644 --- a/tests/integration/resources_permissions/test_userstories_resources.py +++ b/tests/integration/resources_permissions/test_userstories_resources.py @@ -1,3 +1,5 @@ +import uuid + from django.core.urlresolvers import reverse from taiga.base.utils import json @@ -35,20 +37,23 @@ def data(): m.public_project = f.ProjectFactory(is_private=False, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex) m.private_project1 = f.ProjectFactory(is_private=True, anon_permissions=list(map(lambda x: x[0], ANON_PERMISSIONS)), public_permissions=list(map(lambda x: x[0], USER_PERMISSIONS)), - owner=m.project_owner) + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex) m.private_project2 = f.ProjectFactory(is_private=True, anon_permissions=[], public_permissions=[], - owner=m.project_owner) + owner=m.project_owner, + userstories_csv_uuid=uuid.uuid4().hex) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -133,7 +138,7 @@ def test_user_story_update(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): user_story_data = UserStorySerializer(data.public_user_story).data user_story_data["subject"] = "test" user_story_data = json.dumps(user_story_data) @@ -239,7 +244,7 @@ def test_user_story_patch(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): 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] @@ -308,3 +313,27 @@ def test_user_story_action_bulk_update_order(client, data): }) results = helper_test_http_method(client, 'post', url, post_data, users) assert results == [401, 403, 403, 204, 204] + + +def test_user_stories_csv(client, data): + url = reverse('userstories-csv') + csv_public_uuid = data.public_project.userstories_csv_uuid + csv_private1_uuid = data.private_project1.userstories_csv_uuid + csv_private2_uuid = data.private_project1.userstories_csv_uuid + + users = [ + None, + data.registered_user, + data.project_member_without_perms, + data.project_member_with_perms, + data.project_owner + ] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users) + assert results == [200, 200, 200, 200, 200] + + results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users) + assert results == [200, 200, 200, 200, 200] diff --git a/tests/integration/resources_permissions/test_webhooks_resources.py b/tests/integration/resources_permissions/test_webhooks_resources.py index 9514fc88..63ef52e6 100644 --- a/tests/integration/resources_permissions/test_webhooks_resources.py +++ b/tests/integration/resources_permissions/test_webhooks_resources.py @@ -3,7 +3,6 @@ from django.core.urlresolvers import reverse from taiga.base.utils import json from taiga.webhooks.serializers import WebhookSerializer from taiga.webhooks.models import Webhook -from taiga.webhooks import tasks from tests import factories as f from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals @@ -187,12 +186,12 @@ def test_webhook_action_test(client, data): with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: results = helper_test_http_method(client, 'post', url1, None, users) assert results == [404, 404, 200] - assert _send_request_mock.called == True + assert _send_request_mock.called is True with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock: results = helper_test_http_method(client, 'post', url2, None, users) assert results == [404, 404, 404] - assert _send_request_mock.called == False + assert _send_request_mock.called is False def test_webhooklogs_list(client, data): diff --git a/tests/integration/resources_permissions/test_wiki_resources.py b/tests/integration/resources_permissions/test_wiki_resources.py index 8d0ff278..cf6089b7 100644 --- a/tests/integration/resources_permissions/test_wiki_resources.py +++ b/tests/integration/resources_permissions/test_wiki_resources.py @@ -47,9 +47,9 @@ def data(): owner=m.project_owner) m.public_membership = f.MembershipFactory(project=m.public_project, - user=m.project_member_with_perms, - role__project=m.public_project, - role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) + user=m.project_member_with_perms, + role__project=m.public_project, + role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) m.private_membership1 = f.MembershipFactory(project=m.private_project1, user=m.project_member_with_perms, role__project=m.private_project1, @@ -124,7 +124,7 @@ def test_wiki_page_update(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): wiki_page_data = WikiPageSerializer(data.public_wiki_page).data wiki_page_data["content"] = "test" wiki_page_data = json.dumps(wiki_page_data) @@ -242,7 +242,7 @@ def test_wiki_page_patch(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): 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] @@ -255,6 +255,7 @@ def test_wiki_page_patch(client, data): 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') @@ -305,7 +306,7 @@ def test_wiki_link_update(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data wiki_link_data["title"] = "test" wiki_link_data = json.dumps(wiki_link_data) @@ -423,7 +424,7 @@ def test_wiki_link_patch(client, data): data.project_owner ] - with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock: + with mock.patch.object(OCCResourceMixin, "_validate_and_update_version"): 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] diff --git a/tests/integration/test_attachments.py b/tests/integration/test_attachments.py index 1aa1ef70..54844385 100644 --- a/tests/integration/test_attachments.py +++ b/tests/integration/test_attachments.py @@ -1,10 +1,8 @@ import pytest from django.core.urlresolvers import reverse -from django.core.files.base import File from django.core.files.uploadedfile import SimpleUploadedFile -from taiga.projects.attachments.serializers import AttachmentSerializer from .. import factories as f pytestmark = pytest.mark.django_db @@ -15,7 +13,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) + f.MembershipFactory(project=us.project, user=us.owner, is_owner=True) attachment_data = { "description": "test", "attached_file": None, @@ -32,7 +30,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) + 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_auth_api.py b/tests/integration/test_auth_api.py index 793d73d1..e776b569 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -16,19 +16,12 @@ # along with this program. If not, see . import pytest -from unittest.mock import patch, Mock -from django.apps import apps from django.core.urlresolvers import reverse from django.core import mail from .. import factories -from taiga.base.connectors import github -from taiga.front import resolve as resolve_front_url -from taiga.users import models -from taiga.auth.tokens import get_token_for_user - pytestmark = pytest.mark.django_db @@ -90,136 +83,6 @@ def test_response_200_in_public_registration(client, settings): assert len(mail.outbox) == 1 assert mail.outbox[0].subject == "You've been Taigatized!" -def test_response_200_in_registration_with_github_account(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - form = {"type": "github", - "code": "xxxxxx"} - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 200 - assert response.data["username"] == "mmcfly" - assert response.data["auth_token"] != "" and response.data["auth_token"] != None - assert response.data["email"] == "mmcfly@bttf.com" - assert response.data["full_name"] == "martin seamus mcfly" - assert response.data["bio"] == "time traveler" - assert response.data["github_id"] == 1955 - -def test_response_200_in_registration_with_github_account_and_existed_user_by_email(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - form = {"type": "github", - "code": "xxxxxx"} - user = factories.UserFactory() - user.email = "mmcfly@bttf.com" - user.github_id = None - user.save() - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 200 - assert response.data["username"] == user.username - assert response.data["auth_token"] != "" and response.data["auth_token"] != None - assert response.data["email"] == user.email - assert response.data["full_name"] == user.full_name - assert response.data["bio"] == user.bio - assert response.data["github_id"] == 1955 - -def test_response_200_in_registration_with_github_account_and_existed_user_by_github_id(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - form = {"type": "github", - "code": "xxxxxx"} - user = factories.UserFactory() - user.github_id = 1955 - user.save() - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 200 - assert response.data["username"] != "mmcfly" - assert response.data["auth_token"] != "" and response.data["auth_token"] != None - assert response.data["email"] != "mmcfly@bttf.com" - assert response.data["full_name"] != "martin seamus mcfly" - assert response.data["bio"] != "time traveler" - assert response.data["github_id"] == user.github_id - -def test_response_200_in_registration_with_github_account_and_change_github_username(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - form = {"type": "github", - "code": "xxxxxx"} - user = factories.UserFactory() - user.username = "mmcfly" - user.save() - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 200 - assert response.data["username"] == "mmcfly-1" - assert response.data["auth_token"] != "" and response.data["auth_token"] != None - assert response.data["email"] == "mmcfly@bttf.com" - assert response.data["full_name"] == "martin seamus mcfly" - assert response.data["bio"] == "time traveler" - assert response.data["github_id"] == 1955 - -def test_response_200_in_registration_with_github_account_in_a_project(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - membership_model = apps.get_model("projects", "Membership") - membership = factories.MembershipFactory(user=None) - form = {"type": "github", - "code": "xxxxxx", - "token": membership.token} - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 200 - assert membership_model.objects.get(token=form["token"]).user.username == "mmcfly" - - -def test_response_404_in_registration_with_github_in_a_project_with_invalid_token(client, settings): - settings.PUBLIC_REGISTER_ENABLED = False - form = {"type": "github", - "code": "xxxxxx", - "token": "123456"} - - with patch("taiga.base.connectors.github.me") as m_me: - m_me.return_value = ("mmcfly@bttf.com", - github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler")) - - response = client.post(reverse("auth-list"), form) - assert response.status_code == 404 - def test_respond_400_if_username_is_invalid(client, settings, register_form): settings.PUBLIC_REGISTER_ENABLED = True diff --git a/tests/integration/test_custom_attributes_issues.py b/tests/integration/test_custom_attributes_issues.py new file mode 100644 index 00000000..65152aa5 --- /dev/null +++ b/tests/integration/test_custom_attributes_issues.py @@ -0,0 +1,200 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.db.transaction import atomic +from django.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Issue Custom Attributes +######################################################### + +def test_issue_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("issue-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_issue_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_issue_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.IssueCustomAttributeFactory() + custom_attr_2 = f.IssueCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Issue Custom Attributes Values +######################################################### + +def test_issue_custom_attributes_values_when_create_us(client): + issue = f.IssueFactory() + assert issue.custom_attributes_values.attributes_values == {} + + +def test_issue_custom_attributes_values_update(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + + assert issue.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + issue = issue.__class__.objects.get(id=issue.id) + assert issue.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_issue_custom_attributes_values_update_with_error_invalid_key(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issue-custom-attributes-values-detail", args=[issue.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + +def test_issue_custom_attributes_values_delete_issue(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + + url = reverse("issues-detail", args=[issue.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not issue.__class__.objects.filter(id=issue.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_issuecustomvalues_afeter_remove_issuecustomattribute(client): + issue = f.IssueFactory() + member = f.MembershipFactory(user=issue.project.owner, + project=issue.project, + is_owner=True) + custom_attr_1 = f.IssueCustomAttributeFactory(project=issue.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.IssueCustomAttributeFactory(project=issue.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = issue.custom_attributes_values + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("issue-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_custom_attributes_tasks.py b/tests/integration/test_custom_attributes_tasks.py new file mode 100644 index 00000000..fee38830 --- /dev/null +++ b/tests/integration/test_custom_attributes_tasks.py @@ -0,0 +1,202 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# Task Custom Attributes +######################################################### + +def test_task_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("task-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.TaskCustomAttributeFactory() + custom_attr_2 = f.TaskCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Task Custom Attributes Values +######################################################### + +def test_task_custom_attributes_values_when_create_us(client): + task = f.TaskFactory() + assert task.custom_attributes_values.attributes_values == {} + + +def test_task_custom_attributes_values_update(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + task = task.__class__.objects.get(id=task.id) + assert task.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_task_custom_attributes_values_update_with_error_invalid_key(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("task-custom-attributes-values-detail", args=[task.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert task.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_task_custom_attributes_values_delete_task(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + url = reverse("tasks-detail", args=[task.id]) + + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + assert not task.__class__.objects.filter(id=task.id).exists() + assert not custom_attrs_val.__class__.objects.filter(id=custom_attrs_val.id).exists() + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_taskcustomvalues_afeter_remove_taskcustomattribute(client): + task = f.TaskFactory() + member = f.MembershipFactory(user=task.project.owner, + project=task.project, + is_owner=True) + + custom_attr_1 = f.TaskCustomAttributeFactory(project=task.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.TaskCustomAttributeFactory(project=task.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = task.custom_attributes_values + + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("task-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert not custom_attr_2.__class__.objects.filter(pk=custom_attr_2.pk).exists() + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_custom_attributes_user_stories.py b/tests/integration/test_custom_attributes_user_stories.py new file mode 100644 index 00000000..6e602269 --- /dev/null +++ b/tests/integration/test_custom_attributes_user_stories.py @@ -0,0 +1,179 @@ +# Copyright (C) 2015 Andrey Antukh +# Copyright (C) 2015 Jesús Espino +# Copyright (C) 2015 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.core.urlresolvers import reverse +from taiga.base.utils import json + +from .. import factories as f + +import pytest +pytestmark = pytest.mark.django_db + + +######################################################### +# User Story Custom Attributes +######################################################### + +def test_userstory_custom_attribute_duplicate_name_error_on_create(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("userstory-custom-attributes-list") + data = {"name": custom_attr_1.name, + "project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_userstory_custom_attribute_duplicate_name_error_on_update(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=custom_attr_1.project) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"name": custom_attr_1.name} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +def test_userstory_custom_attribute_duplicate_name_error_on_move_between_projects(client): + custom_attr_1 = f.UserStoryCustomAttributeFactory() + custom_attr_2 = f.UserStoryCustomAttributeFactory(name=custom_attr_1.name) + member = f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_1.project, + is_owner=True) + f.MembershipFactory(user=custom_attr_1.project.owner, + project=custom_attr_2.project, + is_owner=True) + + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + data = {"project": custom_attr_1.project.pk} + + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# User Story Custom Attributes Values +######################################################### + +def test_userstory_custom_attributes_values_when_create_us(client): + user_story = f.UserStoryFactory() + assert user_story.custom_attributes_values.attributes_values == {} + + +def test_userstory_custom_attributes_values_update(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + ct2_id: "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + assert response.data["attributes_values"] == data["attributes_values"] + user_story = user_story.__class__.objects.get(id=user_story.id) + assert user_story.custom_attributes_values.attributes_values == data["attributes_values"] + + +def test_userstory_custom_attributes_values_update_with_error_invalid_key(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + + custom_attrs_val = user_story.custom_attributes_values + + url = reverse("userstory-custom-attributes-values-detail", args=[user_story.id]) + data = { + "attributes_values": { + ct1_id: "test_1_updated", + "123456": "test_2_updated" + }, + "version": custom_attrs_val.version + } + + assert user_story.custom_attributes_values.attributes_values == {} + client.login(member.user) + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 400 + + +######################################################### +# Test tristres triggers :-P +######################################################### + +def test_trigger_update_userstorycustomvalues_afeter_remove_userstorycustomattribute(client): + user_story = f.UserStoryFactory() + member = f.MembershipFactory(user=user_story.project.owner, + project=user_story.project, + is_owner=True) + + custom_attr_1 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct1_id = "{}".format(custom_attr_1.id) + custom_attr_2 = f.UserStoryCustomAttributeFactory(project=user_story.project) + ct2_id = "{}".format(custom_attr_2.id) + + custom_attrs_val = user_story.custom_attributes_values + + custom_attrs_val.attributes_values = {ct1_id: "test_1", ct2_id: "test_2"} + custom_attrs_val.save() + + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id in custom_attrs_val.attributes_values.keys() + + url = reverse("userstory-custom-attributes-detail", kwargs={"pk": custom_attr_2.pk}) + client.login(member.user) + response = client.json.delete(url) + assert response.status_code == 204 + + custom_attrs_val = custom_attrs_val.__class__.objects.get(id=custom_attrs_val.id) + assert ct1_id in custom_attrs_val.attributes_values.keys() + assert ct2_id not in custom_attrs_val.attributes_values.keys() diff --git a/tests/integration/test_feedback.py b/tests/integration/test_feedback.py index 478afc8f..4120e59e 100644 --- a/tests/integration/test_feedback.py +++ b/tests/integration/test_feedback.py @@ -1,7 +1,6 @@ from django.core.urlresolvers import reverse from tests import factories as f -from tests.utils import helper_test_http_method from taiga.base.utils import json diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py index f8f30998..6398fc58 100644 --- a/tests/integration/test_history.py +++ b/tests/integration/test_history.py @@ -16,7 +16,6 @@ # along with this program. If not, see . import pytest -from unittest.mock import MagicMock from unittest.mock import patch from django.core.urlresolvers import reverse @@ -83,6 +82,7 @@ def test_take_two_snapshots_without_changes(): assert qs_created.count() == 1 assert qs_hidden.count() == 0 + def test_take_snapshot_from_deleted_object(): issue = f.IssueFactory.create() @@ -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) - membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + 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" @@ -158,7 +158,7 @@ def test_issue_resource_history_test(client): assert qs_all.count() == 0 - with patch(mock_path) as m: + with patch(mock_path): data = {"subject": "Fooooo", "version": issue.version} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -168,7 +168,7 @@ def test_issue_resource_history_test(client): assert qs_changed.count() == 0 assert qs_deleted.count() == 0 - with patch(mock_path) as m: + with patch(mock_path): response = client.delete(url) assert response.status_code == 204 @@ -200,7 +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) + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) qs_all = HistoryEntry.objects.all() qs_hidden = qs_all.filter(is_hidden=True) @@ -221,14 +221,14 @@ def test_history_with_only_comment_shouldnot_be_hidden(client): 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) + 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) + 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) + 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 index f071400e..4cf3bab8 100644 --- a/tests/integration/test_hooks_bitbucket.py +++ b/tests/integration/test_hooks_bitbucket.py @@ -14,17 +14,14 @@ 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() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "bitbucket": { "secret": "tpnIwJDz4e" @@ -41,7 +38,7 @@ def test_bad_signature(client): def test_ok_signature(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "bitbucket": { "secret": "tpnIwJDz4e" @@ -55,10 +52,11 @@ def test_ok_signature(client): 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 + assert response.status_code == 204 + def test_invalid_ip(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "bitbucket": { "secret": "tpnIwJDz4e" @@ -76,7 +74,7 @@ def test_invalid_ip(client): def test_not_ip_filter(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "bitbucket": { "secret": "tpnIwJDz4e", @@ -91,34 +89,34 @@ def test_not_ip_filter(client): urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded", REMOTE_ADDR="111.111.111.112") - assert response.status_code == 200 + assert response.status_code == 204 def test_push_event_detected(client): - project=f.ProjectFactory() + project = f.ProjectFactory() url = reverse("bitbucket-hook-list") - url = "%s?project=%s"%(url, project.id) + 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") + content_type="application/x-www-form-urlencoded") assert process_event_mock.call_count == 1 - assert response.status_code == 200 + assert response.status_code == 204 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) + 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) + '{"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) @@ -131,11 +129,11 @@ 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) + 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) + '{"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) @@ -148,11 +146,11 @@ 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) + 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) + '{"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) @@ -165,11 +163,11 @@ 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) + 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()) + '{"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) @@ -182,7 +180,7 @@ def test_push_event_processing_case_insensitive(client): 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) + '{"commits": [{"message": "test message test TG-6666666 #%s ok bye!"}]}' % (issue_status.slug) ] mail.outbox = [] @@ -197,7 +195,7 @@ def test_push_event_task_bad_processing_non_existing_ref(client): 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) + '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}' % (user_story.ref) ] mail.outbox = [] @@ -213,7 +211,7 @@ def test_push_event_us_bad_processing_non_existing_status(client): 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) + '{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}' % (issue.ref) ] mail.outbox = [] @@ -227,7 +225,7 @@ def test_push_event_bad_processing_non_existing_status(client): def test_api_get_project_modules(client): project = f.create_project() - membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -242,7 +240,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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -261,6 +259,7 @@ def test_api_patch_project_modules(client): 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) " diff --git a/tests/integration/test_hooks_github.py b/tests/integration/test_hooks_github.py index 08a33bbd..6d85cca4 100644 --- a/tests/integration/test_hooks_github.py +++ b/tests/integration/test_hooks_github.py @@ -23,20 +23,20 @@ pytestmark = pytest.mark.django_db def test_bad_signature(client): - project=f.ProjectFactory() + project = f.ProjectFactory() url = reverse("github-hook-list") - url = "%s?project=%s"%(url, project.id) + url = "%s?project=%s" % (url, project.id) data = {} response = client.post(url, json.dumps(data), - HTTP_X_HUB_SIGNATURE="sha1=badbadbad", - content_type="application/json") + HTTP_X_HUB_SIGNATURE="sha1=badbadbad", + 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() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "github": { "secret": "tpnIwJDz4e" @@ -44,46 +44,46 @@ def test_ok_signature(client): }) url = reverse("github-hook-list") - url = "%s?project=%s"%(url, project.id) + url = "%s?project=%s" % (url, project.id) data = {"test:": "data"} response = client.post(url, json.dumps(data), - HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", - content_type="application/json") + HTTP_X_HUB_SIGNATURE="sha1=3c8e83fdaa266f81c036ea0b71e98eb5e054581a", + content_type="application/json") - assert response.status_code == 200 + assert response.status_code == 204 def test_push_event_detected(client): - project=f.ProjectFactory() + project = f.ProjectFactory() url = reverse("github-hook-list") - url = "%s?project=%s"%(url, project.id) + url = "%s?project=%s" % (url, project.id) data = {"commits": [ - {"message": "test message"}, + {"message": "test message"}, ]} GitHubViewSet._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") + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") assert process_event_mock.call_count == 1 - assert response.status_code == 200 + assert response.status_code == 204 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) + 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)}, + """ % (issue.ref, new_status.slug)}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(issue.project, payload) @@ -96,14 +96,14 @@ 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) + 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)}, + """ % (task.ref, new_status.slug)}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(task.project, payload) @@ -116,14 +116,14 @@ 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) + 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)}, + """ % (user_story.ref, new_status.slug)}, ]} mail.outbox = [] @@ -137,14 +137,14 @@ 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) + 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())}, + """ % (task.ref, new_status.slug.upper())}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(task.project, payload) @@ -160,7 +160,7 @@ def test_push_event_task_bad_processing_non_existing_ref(client): {"message": """test message test TG-6666666 #%s ok bye! - """%(issue_status.slug)}, + """ % (issue_status.slug)}, ]} mail.outbox = [] @@ -178,7 +178,7 @@ def test_push_event_us_bad_processing_non_existing_status(client): {"message": """test message test TG-%s #non-existing-slug ok bye! - """%(user_story.ref)}, + """ % (user_story.ref)}, ]} mail.outbox = [] @@ -197,7 +197,7 @@ def test_push_event_bad_processing_non_existing_status(client): {"message": """test message test TG-%s #non-existing-slug ok bye! - """%(issue.ref)}, + """ % (issue.ref)}, ]} mail.outbox = [] @@ -244,6 +244,7 @@ def test_issues_event_opened_issue(client): 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 @@ -271,6 +272,7 @@ def test_issues_event_other_than_opened_issue(client): 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 @@ -301,7 +303,7 @@ def test_issues_event_bad_issue(client): def test_issue_comment_event_on_existing_issue_task_and_us(client): 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) + 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) @@ -412,7 +414,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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -427,7 +429,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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -446,6 +448,7 @@ def test_api_patch_project_modules(client): assert config["github"]["secret"] == "test_secret" assert config["github"]["webhooks_url"] != "test_url" + def test_replace_github_references(): assert event_hooks.replace_github_references("project-url", "#2") == "[GitHub#2](project-url/issues/2)" assert event_hooks.replace_github_references("project-url", "#2 ") == "[GitHub#2](project-url/issues/2) " diff --git a/tests/integration/test_hooks_gitlab.py b/tests/integration/test_hooks_gitlab.py index 2bb4305a..54a44d6d 100644 --- a/tests/integration/test_hooks_gitlab.py +++ b/tests/integration/test_hooks_gitlab.py @@ -13,7 +13,6 @@ 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 @@ -21,8 +20,9 @@ from .. import factories as f pytestmark = pytest.mark.django_db + def test_bad_signature(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "gitlab": { "secret": "tpnIwJDz4e" @@ -39,7 +39,7 @@ def test_bad_signature(client): def test_ok_signature(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "gitlab": { "secret": "tpnIwJDz4e", @@ -55,11 +55,11 @@ def test_ok_signature(client): content_type="application/json", REMOTE_ADDR="111.111.111.111") - assert response.status_code == 200 + assert response.status_code == 204 def test_invalid_ip(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "gitlab": { "secret": "tpnIwJDz4e", @@ -79,7 +79,7 @@ def test_invalid_ip(client): def test_not_ip_filter(client): - project=f.ProjectFactory() + project = f.ProjectFactory() f.ProjectModulesConfigFactory(project=project, config={ "gitlab": { "secret": "tpnIwJDz4e", @@ -95,40 +95,40 @@ def test_not_ip_filter(client): content_type="application/json", REMOTE_ADDR="111.111.111.111") - assert response.status_code == 200 + assert response.status_code == 204 def test_push_event_detected(client): - project=f.ProjectFactory() + project = f.ProjectFactory() url = reverse("gitlab-hook-list") - url = "%s?project=%s"%(url, project.id) + url = "%s?project=%s" % (url, project.id) data = {"commits": [ - {"message": "test message"}, + {"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") + HTTP_X_GITHUB_EVENT="push", + content_type="application/json") assert process_event_mock.call_count == 1 - assert response.status_code == 200 + assert response.status_code == 204 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) + 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)}, + """ % (issue.ref, new_status.slug)}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(issue.project, payload) @@ -141,14 +141,14 @@ 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) + 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)}, + """ % (task.ref, new_status.slug)}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(task.project, payload) @@ -161,14 +161,14 @@ 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) + 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)}, + """ % (user_story.ref, new_status.slug)}, ]} mail.outbox = [] @@ -182,14 +182,14 @@ 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) + 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())}, + """ % (task.ref, new_status.slug.upper())}, ]} mail.outbox = [] ev_hook = event_hooks.PushEventHook(task.project, payload) @@ -205,7 +205,7 @@ def test_push_event_task_bad_processing_non_existing_ref(client): {"message": """test message test TG-6666666 #%s ok bye! - """%(issue_status.slug)}, + """ % (issue_status.slug)}, ]} mail.outbox = [] @@ -223,7 +223,7 @@ def test_push_event_us_bad_processing_non_existing_status(client): {"message": """test message test TG-%s #non-existing-slug ok bye! - """%(user_story.ref)}, + """ % (user_story.ref)}, ]} mail.outbox = [] @@ -242,7 +242,7 @@ def test_push_event_bad_processing_non_existing_status(client): {"message": """test message test TG-%s #non-existing-slug ok bye! - """%(issue.ref)}, + """ % (issue.ref)}, ]} mail.outbox = [] @@ -340,10 +340,9 @@ def test_issues_event_bad_issue(client): 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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -358,7 +357,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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("projects-modules", args=(project.id,)) @@ -377,6 +376,7 @@ def test_api_patch_project_modules(client): 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) " diff --git a/tests/integration/test_importer_api.py b/tests/integration/test_importer_api.py index 14fd299e..9cbb64c0 100644 --- a/tests/integration/test_importer_api.py +++ b/tests/integration/test_importer_api.py @@ -16,13 +16,14 @@ import pytest import base64 -import datetime from django.core.urlresolvers import reverse from django.core.files.base import ContentFile from .. import factories as f +from django.apps import apps + from taiga.base.utils import json from taiga.projects.models import Project from taiga.projects.issues.models import Issue @@ -30,9 +31,6 @@ from taiga.projects.userstories.models import UserStory from taiga.projects.tasks.models import Task from taiga.projects.wiki.models import WikiPage -from taiga.export_import.service import project_to_dict -from taiga.export_import.dump_service import dict_to_project - pytestmark = pytest.mark.django_db @@ -46,6 +44,7 @@ def test_invalid_project_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + def test_valid_project_import_without_extra_data(client): user = f.UserFactory.create() client.login(user) @@ -68,6 +67,7 @@ def test_valid_project_import_without_extra_data(client): assert all(map(lambda x: len(response_data[x]) == 0, must_empty_children)) assert response_data["owner"] == user.email + def test_valid_project_import_with_not_existing_memberships(client): user = f.UserFactory.create() client.login(user) @@ -89,6 +89,7 @@ def test_valid_project_import_with_not_existing_memberships(client): # The new membership and the owner membership assert len(response_data["memberships"]) == 2 + def test_valid_project_import_with_extra_data(client): user = f.UserFactory.create() client.login(user) @@ -142,6 +143,7 @@ def test_valid_project_import_with_extra_data(client): assert all(map(lambda x: len(response_data[x]) == 1, must_one_instance_children)) assert response_data["owner"] == user.email + def test_invalid_project_import_with_extra_data(client): user = f.UserFactory.create() client.login(user) @@ -150,14 +152,14 @@ def test_invalid_project_import_with_extra_data(client): data = { "name": "Imported project", "description": "Imported project", - "roles": [{ }], - "us_statuses": [{ }], - "severities": [{ }], - "priorities": [{ }], - "points": [{ }], - "issue_types": [{ }], - "task_statuses": [{ }], - "issue_statuses": [{ }], + "roles": [{}], + "us_statuses": [{}], + "severities": [{}], + "priorities": [{}], + "points": [{}], + "issue_types": [{}], + "task_statuses": [{}], + "issue_statuses": [{}], } response = client.post(url, json.dumps(data), content_type="application/json") @@ -166,10 +168,66 @@ def test_invalid_project_import_with_extra_data(client): assert len(response_data) == 8 assert Project.objects.filter(slug="imported-project").count() == 0 + +def test_valid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "userstorycustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "taskcustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }], + "issuecustomattributes": [{ + "name": "custom attribute example 1", + "description": "short description 1", + "order": 1 + }] + } + + must_empty_children = ["issues", "user_stories", "wiki_pages", "milestones", "wiki_links"] + must_one_instance_children = ["userstorycustomattributes", "taskcustomattributes", "issuecustomattributes"] + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + assert all(map(lambda x: len(response.data[x]) == 0, must_empty_children)) + # Allwais is created at least the owner membership + assert all(map(lambda x: len(response.data[x]) == 1, must_one_instance_children)) + assert response.data["owner"] == user.email + + +def test_invalid_project_import_with_custom_attributes(client): + user = f.UserFactory.create() + + url = reverse("importer-list") + data = { + "name": "Imported project", + "description": "Imported project", + "userstorycustomattributes": [{ }], + "taskcustomattributes": [{ }], + "issuecustomattributes": [{ }] + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 400 + assert len(response.data) == 3 + assert Project.objects.filter(slug="imported-project").count() == 0 + + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-issue", args=[project.pk]) @@ -178,10 +236,11 @@ def test_invalid_issue_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -199,10 +258,34 @@ def test_valid_user_story_import(client): assert response_data["finish_date"] == "2014-10-24T00:00:00+0000" -def test_valid_issue_import_without_extra_data(client): +def test_valid_user_story_import_with_custom_attributes_values(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() + custom_attr = f.UserStoryCustomAttributeFactory(project=project) + + url = reverse("importer-us", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values User Story", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.UserStoryCustomAttributesValues").objects.get( + user_story__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_issue_import_without_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + 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) @@ -221,7 +304,8 @@ def test_valid_issue_import_without_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None -def test_valid_issue_import_with_extra_data(client): + +def test_valid_issue_import_with_custom_attributes_values(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) membership = f.MembershipFactory(project=project, user=user, is_owner=True) @@ -230,6 +314,33 @@ def test_valid_issue_import_with_extra_data(client): project.default_severity = f.SeverityFactory.create(project=project) project.default_priority = f.PriorityFactory.create(project=project) project.save() + custom_attr = f.IssueCustomAttributeFactory(project=project) + + url = reverse("importer-issue", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Issues", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.IssueCustomAttributesValues").objects.get( + issue__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_issue_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + 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) + project.default_priority = f.PriorityFactory.create(project=project) + project.save() client.login(user) url = reverse("importer-issue", args=[project.pk]) @@ -254,10 +365,11 @@ def test_valid_issue_import_with_extra_data(client): assert response_data["ref"] is not None assert response_data["finished_date"] == "2014-10-24T00:00:00+0000" + 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) + 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) @@ -269,7 +381,7 @@ def test_invalid_issue_import_with_extra_data(client): data = { "subject": "Imported issue", "description": "Imported issue", - "attachments": [{ }], + "attachments": [{}], } response = client.post(url, json.dumps(data), content_type="application/json") @@ -278,10 +390,11 @@ def test_invalid_issue_import_with_extra_data(client): assert len(response_data) == 1 assert Issue.objects.filter(subject="Imported issue").count() == 0 + 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) + 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) @@ -337,10 +450,11 @@ def test_invalid_issue_import_with_bad_choices(client): response_data = json.loads(response.content.decode("utf-8")) assert len(response_data) == 1 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-us", args=[project.pk]) @@ -349,10 +463,11 @@ def test_invalid_us_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -368,10 +483,11 @@ def test_valid_us_import_without_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -396,10 +512,11 @@ def test_valid_us_import_with_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -408,7 +525,7 @@ def test_invalid_us_import_with_extra_data(client): data = { "subject": "Imported us", "description": "Imported us", - "attachments": [{ }], + "attachments": [{}], } response = client.post(url, json.dumps(data), content_type="application/json") @@ -417,10 +534,11 @@ def test_invalid_us_import_with_extra_data(client): assert len(response_data) == 1 assert UserStory.objects.filter(subject="Imported us").count() == 0 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.save() client.login(user) @@ -437,10 +555,11 @@ def test_invalid_us_import_with_bad_choices(client): response_data = json.loads(response.content.decode("utf-8")) assert len(response_data) == 1 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-task", args=[project.pk]) @@ -449,10 +568,11 @@ def test_invalid_task_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -468,12 +588,37 @@ def test_valid_task_import_without_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None -def test_valid_task_import_with_extra_data(client): + +def test_valid_task_import_with_custom_attributes_values(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() + custom_attr = f.TaskCustomAttributeFactory(project=project) + + url = reverse("importer-task", args=[project.pk]) + data = { + "subject": "Test Custom Attrs Values Tasks", + "custom_attributes_values": { + custom_attr.name: "test_value" + } + } + + client.login(user) + response = client.json.post(url, json.dumps(data)) + assert response.status_code == 201 + custom_attributes_values = apps.get_model("custom_attributes.TaskCustomAttributesValues").objects.get( + task__subject=response.data["subject"]) + assert custom_attributes_values.attributes_values == {str(custom_attr.id): "test_value"} + + +def test_valid_task_import_with_extra_data(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory(project=project, user=user, is_owner=True) + project.default_task_status = f.TaskStatusFactory.create(project=project) + project.save() client.login(user) url = reverse("importer-task", args=[project.pk]) @@ -496,10 +641,11 @@ def test_valid_task_import_with_extra_data(client): assert response_data["owner"] == user.email assert response_data["ref"] is not None + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -508,7 +654,7 @@ def test_invalid_task_import_with_extra_data(client): data = { "subject": "Imported task", "description": "Imported task", - "attachments": [{ }], + "attachments": [{}], } response = client.post(url, json.dumps(data), content_type="application/json") @@ -517,10 +663,11 @@ def test_invalid_task_import_with_extra_data(client): assert len(response_data) == 1 assert Task.objects.filter(subject="Imported task").count() == 0 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) project.default_task_status = f.TaskStatusFactory.create(project=project) project.save() client.login(user) @@ -537,10 +684,11 @@ def test_invalid_task_import_with_bad_choices(client): response_data = json.loads(response.content.decode("utf-8")) assert len(response_data) == 1 + 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) + 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() @@ -557,10 +705,11 @@ def test_valid_task_with_user_story(client): assert response.status_code == 201 assert us.tasks.all().count() == 1 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -569,10 +718,11 @@ def test_invalid_wiki_page_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -585,10 +735,11 @@ def test_valid_wiki_page_import_without_extra_data(client): response_data = json.loads(response.content.decode("utf-8")) assert response_data["owner"] == user.email + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) @@ -610,17 +761,18 @@ def test_valid_wiki_page_import_with_extra_data(client): assert len(response_data["attachments"]) == 1 assert response_data["owner"] == user.email + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-page", args=[project.pk]) data = { "slug": "imported-wiki-page", "content": "Imported wiki_page", - "attachments": [{ }], + "attachments": [{}], } response = client.post(url, json.dumps(data), content_type="application/json") @@ -629,10 +781,11 @@ def test_invalid_wiki_page_import_with_extra_data(client): assert len(response_data) == 1 assert WikiPage.objects.filter(slug="imported-wiki-page").count() == 0 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-link", args=[project.pk]) @@ -641,10 +794,11 @@ def test_invalid_wiki_link_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-wiki-link", args=[project.pk]) @@ -655,12 +809,14 @@ def test_valid_wiki_link_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 201 - response_data = json.loads(response.content.decode("utf-8")) + json.loads(response.content.decode("utf-8")) + + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) @@ -669,10 +825,11 @@ def test_invalid_milestone_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) @@ -684,12 +841,14 @@ def test_valid_milestone_import(client): response = client.post(url, json.dumps(data), content_type="application/json") assert response.status_code == 201 - response_data = json.loads(response.content.decode("utf-8")) + json.loads(response.content.decode("utf-8")) + + 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) + f.MembershipFactory(project=project, user=user, is_owner=True) client.login(user) url = reverse("importer-milestone", args=[project.pk]) @@ -705,6 +864,7 @@ def test_milestone_import_duplicated_milestone(client): response_data = json.loads(response.content.decode("utf-8")) assert response_data["milestones"][0]["name"][0] == "Name duplicated for the project" + def test_invalid_dump_import(client): user = f.UserFactory.create() client.login(user) @@ -719,6 +879,7 @@ def test_invalid_dump_import(client): response_data = json.loads(response.content.decode("utf-8")) assert response_data["_error_message"] == "Invalid dump format" + def test_valid_dump_import_with_celery_disabled(client, settings): settings.CELERY_ENABLED = False @@ -740,6 +901,7 @@ def test_valid_dump_import_with_celery_disabled(client, settings): assert "id" in response_data assert response_data["name"] == "Valid project" + def test_valid_dump_import_with_celery_enabled(client, settings): settings.CELERY_ENABLED = True @@ -760,6 +922,7 @@ def test_valid_dump_import_with_celery_enabled(client, settings): response_data = json.loads(response.content.decode("utf-8")) assert "import_id" in response_data + def test_dump_import_duplicated_project(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) @@ -780,6 +943,7 @@ def test_dump_import_duplicated_project(client): assert response_data["name"] == "Test import" assert response_data["slug"] == "{}-test-import".format(user.username) + def test_dump_import_throttling(client, settings): settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["import-dump-mode"] = "1/minute" diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index c415b492..9cd3d2af 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -1,3 +1,6 @@ +import uuid +import csv + from unittest import mock from django.core.urlresolvers import reverse @@ -45,7 +48,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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("issues-bulk-create") @@ -86,6 +89,7 @@ def test_api_filter_by_text_1(client): assert response.status_code == 200 assert number_of_issues == 1 + def test_api_filter_by_text_2(client): user = f.UserFactory(is_superuser=True) f.create_issue(owner=user) @@ -100,6 +104,7 @@ def test_api_filter_by_text_2(client): assert response.status_code == 200 assert number_of_issues == 1 + def test_api_filter_by_text_3(client): user = f.UserFactory(is_superuser=True) f.create_issue(owner=user) @@ -114,6 +119,7 @@ def test_api_filter_by_text_3(client): assert response.status_code == 200 assert number_of_issues == 2 + def test_api_filter_by_text_4(client): user = f.UserFactory(is_superuser=True) f.create_issue(owner=user) @@ -128,6 +134,7 @@ def test_api_filter_by_text_4(client): assert response.status_code == 200 assert number_of_issues == 0 + def test_api_filter_by_text_5(client): user = f.UserFactory(is_superuser=True) f.create_issue(owner=user) @@ -148,7 +155,6 @@ def test_api_filter_by_text_6(client): 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) @@ -157,3 +163,38 @@ def test_api_filter_by_text_6(client): assert response.status_code == 200 assert number_of_issues == 1 + + +def test_get_invalid_csv(client): + url = reverse("issues-csv") + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("issues-csv") + project = f.ProjectFactory.create(issues_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.issues_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(issues_csv_uuid=uuid.uuid4().hex) + attr = f.IssueCustomAttributeFactory.create(project=project, name="attr1", description="desc") + issue = f.IssueFactory.create(project=project) + attr_values = issue.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.issues.all() + data = services.issues_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + assert row[15] == attr.name + row = next(reader) + assert row[15] == "val1" diff --git a/tests/integration/test_memberships.py b/tests/integration/test_memberships.py index 90fd1e97..9805006d 100644 --- a/tests/integration/test_memberships.py +++ b/tests/integration/test_memberships.py @@ -34,7 +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) + f.MembershipFactory(project=project, user=project.owner, is_owner=True) url = reverse("memberships-bulk-create") @@ -52,10 +52,11 @@ def test_api_create_bulk_members(client): assert response.data[0]["email"] == john.email assert response.data[1]["email"] == joseph.email + 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) + 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,9 +78,10 @@ def test_api_create_bulk_members_with_extra_text(client, outbox): assert message.to == ["john@email.com"] assert "this is a not so random invitation text" in message.body + def test_api_resend_invitation(client, outbox): invitation = f.create_invitation() - membership = f.MembershipFactory(project=invitation.project, user=invitation.project.owner, is_owner=True) + 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) @@ -94,7 +96,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) + f.MembershipFactory(project=role.project, user=role.project.owner, is_owner=True) client.login(role.project.owner) @@ -189,7 +191,7 @@ def test_api_delete_membership(client): 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) + 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) diff --git a/tests/integration/test_milestones.py b/tests/integration/test_milestones.py index ab980e35..1b3410a1 100644 --- a/tests/integration/test_milestones.py +++ b/tests/integration/test_milestones.py @@ -16,9 +16,7 @@ # along with this program. If not, see . import pytest -from unittest.mock import patch, Mock -from django.apps import apps from django.core.urlresolvers import reverse from taiga.base.utils import json @@ -29,13 +27,14 @@ from .. import factories as f pytestmark = pytest.mark.django_db + 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, is_owner=True) + 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) + f.PointsFactory.create(project=project, value=None) us = f.UserStoryFactory.create(project=project, owner=user) url = reverse("milestones-detail", args=[sprint.pk]) diff --git a/tests/integration/test_neighbors.py b/tests/integration/test_neighbors.py index 6d3b7b02..33ee8d06 100644 --- a/tests/integration/test_neighbors.py +++ b/tests/integration/test_neighbors.py @@ -15,8 +15,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from functools import partial - import pytest from taiga.projects.userstories.models import UserStory diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py index f100af22..b3874495 100644 --- a/tests/integration/test_notifications.py +++ b/tests/integration/test_notifications.py @@ -45,7 +45,7 @@ def mail(): def test_attach_notify_policy_to_project_queryset(): project1 = f.ProjectFactory.create() - project2 = f.ProjectFactory.create() + f.ProjectFactory.create() qs = project1.__class__.objects.order_by("id") qs = services.attach_notify_policy_to_project_queryset(project1.owner, qs) @@ -109,14 +109,25 @@ def test_users_to_notify(): member3 = f.MembershipFactory.create(project=project, role=role1) member4 = f.MembershipFactory.create(project=project, role=role1) member5 = f.MembershipFactory.create(project=project, role=role2) + inactive_member1 = f.MembershipFactory.create(project=project, role=role1) + inactive_member1.user.is_active = False + inactive_member1.user.save() + system_member1 = f.MembershipFactory.create(project=project, role=role1) + system_member1.user.is_system = True + system_member1.user.save() issue = f.IssueFactory.create(project=project, owner=member4.user) policy_model_cls = apps.get_model("notifications", "NotifyPolicy") policy1 = policy_model_cls.objects.get(user=member1.user) - policy2 = policy_model_cls.objects.get(user=member2.user) - policy3 = policy_model_cls.objects.get(user=member3.user) + policy2 = policy_model_cls.objects.get(user=member3.user) + policy3 = policy_model_cls.objects.get(user=inactive_member1.user) + policy3.notify_level = NotifyLevel.watch + policy3.save() + policy4 = policy_model_cls.objects.get(user=system_member1.user) + policy4.notify_level = NotifyLevel.watch + policy4.save() history = MagicMock() history.owner = member2.user @@ -144,8 +155,8 @@ def test_users_to_notify(): assert users == {member1.user, member3.user, issue.get_owner()} # Test with watchers with ignore policy - policy3.notify_level = NotifyLevel.ignore - policy3.save() + policy2.notify_level = NotifyLevel.ignore + policy2.save() issue.watchers.add(member3.user) users = services.get_users_to_notify(issue) @@ -158,6 +169,16 @@ def test_users_to_notify(): assert len(users) == 2 assert users == {member1.user, issue.get_owner()} + # Test with inactive user + issue.watchers.add(inactive_member1.user) + assert len(users) == 2 + assert users == {member1.user, issue.get_owner()} + + # Test with system user + issue.watchers.add(system_member1.user) + 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 @@ -197,7 +218,6 @@ def test_send_notifications_using_services_method(settings, mail): services.send_notifications(issue, history=history_delete) - # Userstories us = f.UserStoryFactory.create(project=project, owner=member2.user) take_snapshot(us) @@ -240,6 +260,7 @@ def test_send_notifications_using_services_method(settings, mail): services.process_sync_notifications() assert len(mail.outbox) == 12 + def test_resource_notification_test(client, settings, mail): settings.CHANGE_NOTIFICATIONS_MIN_INTERVAL = 1 @@ -247,8 +268,8 @@ def test_resource_notification_test(client, settings, mail): user2 = f.UserFactory.create() project = f.ProjectFactory.create(owner=user1) 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) + f.MembershipFactory.create(project=project, user=user1, role=role, is_owner=True) + f.MembershipFactory.create(project=project, user=user2, role=role) issue = f.IssueFactory.create(owner=user2, project=project) mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" @@ -256,7 +277,7 @@ def test_resource_notification_test(client, settings, mail): client.login(user1) - with patch(mock_path) as m: + with patch(mock_path): data = {"subject": "Fooooo", "version": issue.version} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -267,7 +288,7 @@ def test_resource_notification_test(client, settings, mail): assert len(mail.outbox) == 1 assert models.HistoryChangeNotification.objects.count() == 0 - with patch(mock_path) as m: + with patch(mock_path): response = client.delete(url) assert response.status_code == 204 assert len(mail.outbox) == 1 @@ -285,8 +306,8 @@ 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, is_owner=True) - member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) @@ -298,7 +319,6 @@ def test_watchers_assignation_for_issue(client): response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200, response.content - issue = f.create_issue(project=project1, owner=user1) data = {"version": issue.version, "watchers": [user1.pk, user2.pk]} @@ -338,8 +358,8 @@ 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, is_owner=True) - member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) @@ -351,7 +371,6 @@ def test_watchers_assignation_for_task(client): response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200, response.content - task = f.create_task(project=project1, owner=user1) data = {"version": task.version, "watchers": [user1.pk, user2.pk]} @@ -391,8 +410,8 @@ 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, is_owner=True) - member2 = f.MembershipFactory.create(project=project2, user=user2, role=role2) + f.MembershipFactory.create(project=project1, user=user1, role=role1, is_owner=True) + f.MembershipFactory.create(project=project2, user=user2, role=role2) client.login(user1) @@ -404,7 +423,6 @@ def test_watchers_assignation_for_us(client): response = client.json.patch(url, json.dumps(data)) assert response.status_code == 200 - us = f.create_userstory(project=project1, owner=user1) data = {"version": us.version, "watchers": [user1.pk, user2.pk]} @@ -440,7 +458,6 @@ def test_watchers_assignation_for_us(client): def test_retrieve_notify_policies_by_anonymous_user(client): project = f.ProjectFactory.create() - policy_model_cls = apps.get_model("notifications", "NotifyPolicy") policy = services.get_notify_policy(project, project.owner) url = reverse("notifications-detail", args=[policy.pk]) diff --git a/tests/integration/test_occ.py b/tests/integration/test_occ.py index 4cd408b6..0e6eb974 100644 --- a/tests/integration/test_occ.py +++ b/tests/integration/test_occ.py @@ -20,10 +20,6 @@ from unittest.mock import patch from django.core.urlresolvers import reverse from taiga.base.utils import json -from taiga.projects.issues.models import Issue -from taiga.projects.wiki.models import WikiPage -from taiga.projects.userstories.models import UserStory -from taiga.projects.tasks.models import Task from .. import factories as f @@ -33,7 +29,7 @@ pytestmark = pytest.mark.django_db def test_valid_us_creation(client): user = f.UserFactory.create() project = f.ProjectFactory.create(owner=user) - membership = f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.MembershipFactory.create(project=project, user=user, is_owner=True) client.login(user) @@ -50,28 +46,28 @@ def test_valid_us_creation(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, is_owner=True) + 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: + with patch(mock_path): 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} + "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"} + 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"} + data = {"version": 1, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 @@ -79,28 +75,28 @@ def test_invalid_concurrent_save_for_issue(client): 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) + 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: + with patch(mock_path): 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} + "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"} + 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"} + data = {"version": 2, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -108,28 +104,28 @@ def test_valid_concurrent_save_for_issue_different_versions(client): 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) + 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: + with patch(mock_path): 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} + "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"} + 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"} + data = {"version": 1, "description": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -137,11 +133,11 @@ def test_valid_concurrent_save_for_issue_different_fields(client): 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) + 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: + with patch(mock_path): url = reverse("wiki-list") data = {"project": project.id, "slug": "test"} response = client.json.post(url, json.dumps(data)) @@ -149,11 +145,11 @@ def test_invalid_concurrent_save_for_wiki_page(client): wiki_id = json.loads(response.content)["id"] url = reverse("wiki-detail", args=(wiki_id,)) - data = {"version":1, "content": "test 1"} + 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"} + data = {"version": 1, "content": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 @@ -161,11 +157,11 @@ def test_invalid_concurrent_save_for_wiki_page(client): 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) + 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: + with patch(mock_path): url = reverse("wiki-list") data = {"project": project.id, "slug": "test"} response = client.json.post(url, json.dumps(data)) @@ -173,11 +169,11 @@ def test_valid_concurrent_save_for_wiki_page_different_versions(client): wiki_id = json.loads(response.content)["id"] url = reverse("wiki-detail", args=(wiki_id,)) - data = {"version":1, "content": "test 1"} + 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"} + data = {"version": 2, "content": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -185,26 +181,26 @@ def test_valid_concurrent_save_for_wiki_page_different_versions(client): 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) + f.MembershipFactory.create(project=project, user=user, is_owner=True) + f.UserStoryFactory.create(version=10, project=project) client.login(user) mock_path = "taiga.projects.userstories.api.UserStoryViewSet.pre_conditions_on_save" - with patch(mock_path) as m: + with patch(mock_path): url = reverse("userstories-list") data = {"subject": "test", - "project": project.id, - "status": f.UserStoryStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 1, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 @@ -212,25 +208,25 @@ def test_invalid_concurrent_save_for_us(client): 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) + 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: + with patch(mock_path): url = reverse("userstories-list") data = {"subject": "test", - "project": project.id, - "status": f.UserStoryStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 2, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -238,25 +234,25 @@ def test_valid_concurrent_save_for_us_different_versions(client): 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) + 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: + with patch(mock_path): url = reverse("userstories-list") data = {"subject": "test", - "project": project.id, - "status": f.UserStoryStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 1, "description": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -264,25 +260,25 @@ def test_valid_concurrent_save_for_us_different_fields(client): 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, is_owner=True) + 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: + with patch(mock_path): url = reverse("tasks-list") data = {"subject": "test", - "project": project.id, - "status": f.TaskStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 1, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 400 @@ -290,25 +286,25 @@ def test_invalid_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, is_owner=True) + 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: + with patch(mock_path): url = reverse("tasks-list") data = {"subject": "test", - "project": project.id, - "status": f.TaskStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 2, "subject": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 @@ -316,24 +312,24 @@ def test_valid_concurrent_save_for_task_different_versions(client): 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) + 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: + with patch(mock_path): url = reverse("tasks-list") data = {"subject": "test", - "project": project.id, - "status": f.TaskStatusFactory.create(project=project).id} + "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"} + 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"} + data = {"version": 1, "description": "test 2"} response = client.patch(url, json.dumps(data), content_type="application/json") assert response.status_code == 200 diff --git a/tests/integration/test_permissions.py b/tests/integration/test_permissions.py index aabfc34a..b16bcea3 100644 --- a/tests/integration/test_permissions.py +++ b/tests/integration/test_permissions.py @@ -88,16 +88,16 @@ def test_member_get_user_project_permissions(): def test_anon_user_has_perm(): project = factories.ProjectFactory() project.anon_permissions = ["test"] - assert service.user_has_perm(AnonymousUser(), "test", project) == True - assert service.user_has_perm(AnonymousUser(), "fail", project) == False + assert service.user_has_perm(AnonymousUser(), "test", project) is True + assert service.user_has_perm(AnonymousUser(), "fail", project) is False def test_authenticated_user_has_perm_on_project(): user1 = factories.UserFactory() project = factories.ProjectFactory() project.public_permissions = ["test"] - assert service.user_has_perm(user1, "test", project) == True - assert service.user_has_perm(user1, "fail", project) == False + assert service.user_has_perm(user1, "test", project) is True + assert service.user_has_perm(user1, "fail", project) is False def test_authenticated_user_has_perm_on_project_related_object(): @@ -106,10 +106,10 @@ def test_authenticated_user_has_perm_on_project_related_object(): project.public_permissions = ["test"] us = factories.UserStoryFactory(project=project) - assert service.user_has_perm(user1, "test", us) == True - assert service.user_has_perm(user1, "fail", us) == False + assert service.user_has_perm(user1, "test", us) is True + assert service.user_has_perm(user1, "fail", us) is False def test_authenticated_user_has_perm_on_invalid_object(): user1 = factories.UserFactory() - assert service.user_has_perm(user1, "test", user1) == False + assert service.user_has_perm(user1, "test", user1) is False diff --git a/tests/integration/test_projects.py b/tests/integration/test_projects.py index 941b3a47..6efa9a65 100644 --- a/tests/integration/test_projects.py +++ b/tests/integration/test_projects.py @@ -2,6 +2,8 @@ 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 taiga.permissions.permissions import ANON_PERMISSIONS +from taiga.projects.models import Project from .. import factories as f @@ -118,7 +120,7 @@ def test_points_name_duplicated(client): 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) + 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 @@ -130,33 +132,33 @@ def test_get_closed_bugs_per_member_stats(): 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) + f.IssueFactory(project=project, + status=issue_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + 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) + f.TaskFactory(project=project, + status=task_closed_status, + owner=membership_1.user, + assigned_to=membership_1.user) + f.TaskFactory(project=project, + status=task_open_status, + owner=membership_2.user, + assigned_to=membership_2.user) + 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.content = "Frontend, future" wiki_page.save() take_snapshot(wiki_page, user=membership_1.user) @@ -235,3 +237,43 @@ def test_edit_membership_only_owner(client): 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" + + +def test_anon_permissions_generation_when_making_project_public(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(is_private=True) + role = f.RoleFactory.create(project=project, permissions=["view_project", "modify_project"]) + membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True) + assert project.anon_permissions == [] + client.login(user) + url = reverse("projects-detail", kwargs={"pk": project.pk}) + data = {"is_private": False} + response = client.json.patch(url, json.dumps(data)) + assert response.status_code == 200 + anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) + assert set(anon_permissions).issubset(set(response.data["anon_permissions"])) + assert set(anon_permissions).issubset(set(response.data["public_permissions"])) + + +def test_destroy_point_and_reassign(client): + project = f.ProjectFactory.create() + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + p1 = f.PointsFactory(project=project) + project.default_points = p1 + project.save() + p2 = f.PointsFactory(project=project) + user_story = f.UserStoryFactory.create(project=project) + rp1 = f.RolePointsFactory.create(user_story=user_story, points=p1) + + url = reverse("points-detail", args=[p1.pk]) + "?moveTo={}".format(p2.pk) + + client.login(project.owner) + + assert user_story.role_points.all()[0].points.id == p1.id + assert project.default_points.id == p1.id + + response = client.delete(url) + + assert user_story.role_points.all()[0].points.id == p2.id + project = Project.objects.get(id=project.id) + assert project.default_points.id == p2.id diff --git a/tests/integration/test_references_sequences.py b/tests/integration/test_references_sequences.py index 93616176..10653cea 100644 --- a/tests/integration/test_references_sequences.py +++ b/tests/integration/test_references_sequences.py @@ -17,9 +17,6 @@ import pytest -from django.core import management -from django.conf import settings - from .. import factories diff --git a/tests/integration/test_roles.py b/tests/integration/test_roles.py index 923da195..70665aff 100644 --- a/tests/integration/test_roles.py +++ b/tests/integration/test_roles.py @@ -16,31 +16,27 @@ # along with this program. If not, see . import pytest -from unittest.mock import patch, Mock -from django.apps import apps from django.core.urlresolvers import reverse -from taiga.base.utils import json - from taiga.users.models import Role from taiga.projects.models import Membership from taiga.projects.models import Project -from taiga.projects.userstories.serializers import UserStorySerializer from .. import factories as f pytestmark = pytest.mark.django_db + def test_destroy_role_and_reassign_members(client): user1 = f.UserFactory.create() user2 = f.UserFactory.create() 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, is_owner=True) - member = f.MembershipFactory.create(project=project, user=user2, role=role2) + f.MembershipFactory.create(project=project, user=user1, role=role1, is_owner=True) + f.MembershipFactory.create(project=project, user=user2, role=role2) url = reverse("roles-detail", args=[role2.pk]) + "?moveTo={}".format(role1.pk) @@ -58,6 +54,7 @@ def test_destroy_role_and_reassign_members(client): qs = Membership.objects.filter(project=project, role_id=role1.pk) assert qs.count() == 2 + def test_destroy_role_and_reassign_members_with_deleted_project(client): """ Regression test, that fixes some 500 errors on production @@ -68,8 +65,8 @@ def test_destroy_role_and_reassign_members_with_deleted_project(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=user2, role=role2) + f.MembershipFactory.create(project=project, user=user1, role=role1) + f.MembershipFactory.create(project=project, user=user2, role=role2) Project.objects.filter(pk=project.id).delete() diff --git a/tests/integration/test_searches.py b/tests/integration/test_searches.py index 2eeecf68..606f3304 100644 --- a/tests/integration/test_searches.py +++ b/tests/integration/test_searches.py @@ -56,21 +56,20 @@ def searches_initial_data(): m.points2 = f.PointsFactory(project=m.project2, value=None) m.role_points1 = f.RolePointsFactory.create(role=m.project1.roles.all()[0], - points=m.points1, - user_story__project=m.project1) + points=m.points1, + user_story__project=m.project1) m.role_points2 = f.RolePointsFactory.create(role=m.project1.roles.all()[0], - points=m.points1, - user_story__project=m.project1, - user_story__description="Back to the future") + points=m.points1, + user_story__project=m.project1, + user_story__description="Back to the future") m.role_points3 = f.RolePointsFactory.create(role=m.project2.roles.all()[0], - points=m.points2, - user_story__project=m.project2) + points=m.points2, + user_story__project=m.project2) m.us1 = m.role_points1.user_story m.us2 = m.role_points2.user_story m.us3 = m.role_points3.user_story - m.tsk1 = f.TaskFactory.create(project=m.project2) m.tsk2 = f.TaskFactory.create(project=m.project1) m.tsk3 = f.TaskFactory.create(project=m.project1, subject="Back to the future") @@ -79,9 +78,9 @@ def searches_initial_data(): m.iss2 = f.IssueFactory.create(project=m.project2) m.iss3 = f.IssueFactory.create(project=m.project1) - m.wiki1 = f.WikiPageFactory.create(project=m.project1) - m.wiki2 = f.WikiPageFactory.create(project=m.project1, content="Frontend, future") - m.wiki3 = f.WikiPageFactory.create(project=m.project2) + m.wiki1 = f.WikiPageFactory.create(project=m.project1) + m.wiki2 = f.WikiPageFactory.create(project=m.project1, content="Frontend, future") + m.wiki3 = f.WikiPageFactory.create(project=m.project2) return m @@ -130,3 +129,12 @@ def test_search_text_query_in_my_project(client, searches_initial_data): assert len(response.data["tasks"]) == 1 assert len(response.data["issues"]) == 0 assert len(response.data["wikipages"]) == 0 + + +def test_search_text_query_with_an_invalid_project_id(client, searches_initial_data): + data = searches_initial_data + + client.login(data.member1.user) + + response = client.get(reverse("search-list"), {"project": "new", "text": "future"}) + assert response.status_code == 404 diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py index e102ba80..97bce3ff 100644 --- a/tests/integration/test_stats.py +++ b/tests/integration/test_stats.py @@ -1,8 +1,5 @@ import pytest -from django.conf import settings -from django.core.urlresolvers import reverse - from .. import factories as f from tests.utils import disconnect_signals, reconnect_signals diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index 06639294..63fe177e 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -1,3 +1,6 @@ +import uuid +import csv + from unittest import mock from django.core.urlresolvers import reverse @@ -100,7 +103,7 @@ def test_api_update_order_in_bulk(client): data = { "project_id": project.id, "bulk_tasks": [{"task_id": task1.id, "order": 1}, - {"task_id": task2.id, "order": 2}] + {"task_id": task2.id, "order": 2}] } client.login(project.owner) @@ -110,3 +113,38 @@ def test_api_update_order_in_bulk(client): assert response1.status_code == 204, response1.data assert response2.status_code == 204, response2.data + + +def test_get_invalid_csv(client): + url = reverse("tasks-csv") + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("tasks-csv") + project = f.ProjectFactory.create(tasks_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.tasks_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(tasks_csv_uuid=uuid.uuid4().hex) + attr = f.TaskCustomAttributeFactory.create(project=project, name="attr1", description="desc") + task = f.TaskFactory.create(project=project) + attr_values = task.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.tasks.all() + data = services.tasks_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + assert row[16] == attr.name + row = next(reader) + assert row[16] == "val1" diff --git a/tests/integration/test_throwttling.py b/tests/integration/test_throwttling.py index 4772bb28..ce36d0c4 100644 --- a/tests/integration/test_throwttling.py +++ b/tests/integration/test_throwttling.py @@ -33,7 +33,7 @@ import_rate_path = "taiga.export_import.throttling.ImportModeRateThrottle.get_ra def test_anonimous_throttling_policy(client, settings): - project = f.create_project() + f.create_project() url = reverse("projects-list") with mock.patch(anon_rate_path) as anon_rate, \ @@ -54,7 +54,7 @@ def test_anonimous_throttling_policy(client, settings): def test_user_throttling_policy(client, settings): project = f.create_project() - membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) url = reverse("projects-detail", kwargs={"pk": project.pk}) client.login(project.owner) @@ -83,7 +83,7 @@ def test_user_throttling_policy(client, settings): def test_import_mode_throttling_policy(client, settings): project = f.create_project() - membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True) + 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) diff --git a/tests/integration/test_timeline.py b/tests/integration/test_timeline.py index d3f84a3b..84484457 100644 --- a/tests/integration/test_timeline.py +++ b/tests/integration/test_timeline.py @@ -19,7 +19,6 @@ import pytest from .. import factories -from taiga.base.utils import json from taiga.timeline import service from taiga.timeline.models import Timeline @@ -39,6 +38,7 @@ def test_add_to_object_timeline(): assert Timeline.objects.filter(object_id=user1.id).count() == 1 assert Timeline.objects.order_by("-id")[0].data == id(user2) + def test_get_timeline(): Timeline.objects.all().delete() diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py index 38b03ba1..60f2fcc7 100644 --- a/tests/integration/test_users.py +++ b/tests/integration/test_users.py @@ -74,8 +74,8 @@ def test_update_user_with_valid_email(client): assert response.status_code == 200 user = models.User.objects.get(pk=user.id) - assert user.email_token != None - assert user.new_email == "new@email.com" + assert user.email_token is not None + assert user.new_email == "new@email.com" def test_validate_requested_email_change(client): @@ -88,9 +88,9 @@ def test_validate_requested_email_change(client): assert response.status_code == 204 user = models.User.objects.get(pk=user.id) - assert user.email_token == None - assert user.new_email == None - assert user.email == "new@email.com" + assert user.email_token is None + assert user.new_email is None + assert user.email == "new@email.com" def test_validate_requested_email_change_without_token(client): diff --git a/tests/integration/test_userstorage_api.py b/tests/integration/test_userstorage_api.py index fb2eef3f..1b642de2 100644 --- a/tests/integration/test_userstorage_api.py +++ b/tests/integration/test_userstorage_api.py @@ -34,18 +34,18 @@ def test_list_userstorage(client): factories.StorageEntryFactory(owner=user2) # List by anonumous user - response = client.get(reverse("user-storage-list")) + response = client.json.get(reverse("user-storage-list")) assert response.status_code == 200 assert len(response.data) == 0 # List own entries client.login(username=user1.username, password=user1.username) - response = client.get(reverse("user-storage-list")) + response = client.json.get(reverse("user-storage-list")) assert response.status_code == 200 assert len(response.data) == 3 client.login(username=user2.username, password=user2.username) - response = client.get(reverse("user-storage-list")) + response = client.json.get(reverse("user-storage-list")) assert response.status_code == 200 assert len(response.data) == 1 @@ -54,7 +54,7 @@ def test_list_userstorage(client): keys = ",".join([storage11.key, storage13.key]) url = "{}?keys={}".format(reverse("user-storage-list"), keys) - response = client.get(url) + response = client.json.get(url) assert response.status_code == 200 assert len(response.data) == 2 @@ -65,22 +65,22 @@ def test_view_storage_entries(client): storage11 = factories.StorageEntryFactory(owner=user1) # Get by anonymous user - response = client.get(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 404 # Get single entry client.login(username=user1.username, password=user1.username) - response = client.get(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 200 assert response.data["key"] == storage11.key assert response.data["value"] == storage11.value # Get not existent key client.login(username=user2.username, password=user2.username) - response = client.get(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 404 - response = client.get(reverse("user-storage-detail", args=["foobar"])) + response = client.json.get(reverse("user-storage-detail", args=["foobar"])) assert response.status_code == 404 @@ -89,30 +89,30 @@ def test_create_entries(client): storage11 = factories.StorageEntryFactory(owner=user1) form = {"key": "foo", - "value": "bar"} - form_without_key = {"value": "bar"} + "value": {"bar": "bar"}} + form_without_key = {"value": {"bar": "bar"}} form_without_value = {"key": "foo"} error_form = {"key": storage11.key, - "value": "bar"} + "value": {"bar": "bar"}} # Create entry by anonymous user - response = client.post(reverse("user-storage-list"), form) + response = client.json.post(reverse("user-storage-list"), json.dumps(form)) assert response.status_code == 401 # Create by logged user client.login(username=user1.username, password=user1.username) - response = client.post(reverse("user-storage-list"), form) + response = client.json.post(reverse("user-storage-list"), json.dumps(form)) assert response.status_code == 201 - response = client.get(reverse("user-storage-detail", args=[form["key"]])) + response = client.json.get(reverse("user-storage-detail", args=[form["key"]])) assert response.status_code == 200 # Wrong data client.login(username=user1.username, password=user1.username) - response = client.post(reverse("user-storage-list"), form_without_key) + response = client.json.post(reverse("user-storage-list"), json.dumps(form_without_key)) assert response.status_code == 400 - response = client.post(reverse("user-storage-list"), form_without_value) + response = client.json.post(reverse("user-storage-list"), json.dumps(form_without_value)) assert response.status_code == 400 - response = client.post(reverse("user-storage-list"), error_form) + response = client.json.post(reverse("user-storage-list"), json.dumps(error_form)) assert response.status_code == 400 @@ -122,32 +122,29 @@ def test_update_entries(client): # Update by anonymous user form = {"value": "bar", "key": storage11.key} - response = client.put(reverse("user-storage-detail", args=[storage11.key]), - json.dumps(form), - content_type='application/json') + response = client.json.put(reverse("user-storage-detail", args=[storage11.key]), + json.dumps(form)) assert response.status_code == 401 # Update by logged user client.login(username=user1.username, password=user1.username) - form = {"value": "bar", "key": storage11.key} + form = {"value": {"bar": "bar"}, "key": storage11.key} - response = client.put(reverse("user-storage-detail", args=[storage11.key]), - json.dumps(form), - content_type='application/json') + response = client.json.put(reverse("user-storage-detail", args=[storage11.key]), + json.dumps(form)) assert response.status_code == 200 - response = client.get(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 200 assert response.data["value"] == form["value"] # Update not existing entry - form = {"value": "bar", "key": "foo"} - response = client.get(reverse("user-storage-detail", args=[form["key"]])) + form = {"value": {"bar": "bar"}, "key": "foo"} + response = client.json.get(reverse("user-storage-detail", args=[form["key"]])) assert response.status_code == 404 - response = client.put(reverse("user-storage-detail", args=[form["key"]]), - json.dumps(form), - content_type='application/json') + response = client.json.put(reverse("user-storage-detail", args=[form["key"]]), + json.dumps(form)) assert response.status_code == 201 - response = client.get(reverse("user-storage-detail", args=[form["key"]])) + response = client.json.get(reverse("user-storage-detail", args=[form["key"]])) assert response.status_code == 200 assert response.data["value"] == form["value"] @@ -158,21 +155,21 @@ def test_delete_storage_entry(client): storage11 = factories.StorageEntryFactory(owner=user1) # Delete by anonumous user - response = client.delete(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 401 # Delete by logged user client.login(username=user1.username, password=user1.username) - response = client.delete(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 204 - response = client.get(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.get(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 404 # Delete not existent entry - response = client.delete(reverse("user-storage-detail", args=["foo"])) + response = client.json.delete(reverse("user-storage-detail", args=["foo"])) assert response.status_code == 404 client.login(username=user2.username, password=user2.username) - response = client.delete(reverse("user-storage-detail", args=[storage11.key])) + response = client.json.delete(reverse("user-storage-detail", args=[storage11.key])) assert response.status_code == 404 diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index a9070e50..ceb0cc12 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -1,4 +1,7 @@ import copy +import uuid +import csv + from unittest import mock from django.core.urlresolvers import reverse @@ -125,41 +128,42 @@ 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, is_owner=True) - member = f.MembershipFactory.create(project=project, user=user2, role=role2) + f.MembershipFactory.create(project=project, user=user1, role=role1, is_owner=True) + f.MembershipFactory.create(project=project, user=user2, role=role2) - points1 = f.PointsFactory.create(project=project, value=None) - points2 = f.PointsFactory.create(project=project, value=1) + f.PointsFactory.create(project=project, value=None) + f.PointsFactory.create(project=project, value=1) points3 = f.PointsFactory.create(project=project, value=2) us = f.UserStoryFactory.create(project=project, owner=user1) - url = reverse("userstories-detail", args=[us.pk]) usdata = UserStorySerializer(us).data + url = reverse("userstories-detail", args=[us.pk]) + client.login(user1) # Api should ignore invalid values data = {} data["version"] = usdata["version"] data["points"] = copy.copy(usdata["points"]) - data["points"].update({'2000':points3.pk}) + data["points"].update({'2000': points3.pk}) response = client.json.patch(url, json.dumps(data)) - assert response.status_code == 200, response.data + assert response.status_code == 200 + assert response.data["points"] == usdata['points'] # Api should save successful data = {} data["version"] = usdata["version"] + 1 data["points"] = copy.copy(usdata["points"]) - data["points"].update({str(role1.pk):points3.pk}) + data["points"].update({str(role1.pk): points3.pk}) response = client.json.patch(url, json.dumps(data)) - assert response.status_code == 200, response.data - us = models.UserStory.objects.get(pk=us.pk) - rp = list(us.role_points.values_list("role_id", "points_id")) - - assert rp == [(role1.pk, points3.pk), (role2.pk, points1.pk)] + usdatanew = UserStorySerializer(us).data + assert response.status_code == 200 + assert response.data["points"] == usdatanew['points'] + assert response.data["points"] != usdata['points'] def test_update_userstory_rolepoints_on_add_new_role(client): @@ -172,16 +176,16 @@ def test_update_userstory_rolepoints_on_add_new_role(client): role1 = f.RoleFactory.create(project=project) - member1 = f.MembershipFactory.create(project=project, user=user1, role=role1) + f.MembershipFactory.create(project=project, user=user1, role=role1) - points1 = f.PointsFactory.create(project=project, value=2) + f.PointsFactory.create(project=project, value=2) us = f.UserStoryFactory.create(project=project, owner=user1) # url = reverse("userstories-detail", args=[us.pk]) # client.login(user1) role2 = f.RoleFactory.create(project=project, computable=True) - member2 = f.MembershipFactory.create(project=project, user=user2, role=role2) + f.MembershipFactory.create(project=project, user=user2, role=role2) us.save() @@ -209,6 +213,7 @@ def test_archived_filter(client): response = client.get(url, data) assert len(json.loads(response.content)) == 1 + def test_get_total_points(client): project = f.ProjectFactory.create() @@ -239,3 +244,38 @@ def test_get_total_points(client): f.RolePointsFactory.create(user_story=us_mixed, role=role2, points=points2) assert us_mixed.get_total_points() == 1.0 + + +def test_get_invalid_csv(client): + url = reverse("userstories-csv") + + response = client.get(url) + assert response.status_code == 404 + + response = client.get("{}?uuid={}".format(url, "not-valid-uuid")) + assert response.status_code == 404 + + +def test_get_valid_csv(client): + url = reverse("userstories-csv") + project = f.ProjectFactory.create(userstories_csv_uuid=uuid.uuid4().hex) + + response = client.get("{}?uuid={}".format(url, project.userstories_csv_uuid)) + assert response.status_code == 200 + + +def test_custom_fields_csv_generation(): + project = f.ProjectFactory.create(userstories_csv_uuid=uuid.uuid4().hex) + attr = f.UserStoryCustomAttributeFactory.create(project=project, name="attr1", description="desc") + us = f.UserStoryFactory.create(project=project) + attr_values = us.custom_attributes_values + attr_values.attributes_values = {str(attr.id):"val1"} + attr_values.save() + queryset = project.user_stories.all() + data = services.userstories_to_csv(project, queryset) + data.seek(0) + reader = csv.reader(data) + row = next(reader) + assert row[23] == attr.name + row = next(reader) + assert row[23] == "val1" diff --git a/tests/integration/test_vote_issues.py b/tests/integration/test_vote_issues.py index 390dfcaa..691ce432 100644 --- a/tests/integration/test_vote_issues.py +++ b/tests/integration/test_vote_issues.py @@ -17,7 +17,6 @@ import pytest from django.core.urlresolvers import reverse -from django.contrib.contenttypes.models import ContentType from .. import factories as f diff --git a/tests/models.py b/tests/models.py index 1351b41c..fe21e87c 100644 --- a/tests/models.py +++ b/tests/models.py @@ -18,7 +18,7 @@ from django.db import models from taiga.base import tags + class TaggedModel(tags.TaggedMixin, models.Model): class Meta: app_label = "tests" - diff --git a/tests/unit/test_base_api_permissions.py b/tests/unit/test_base_api_permissions.py index fb792c4a..dd736808 100644 --- a/tests/unit/test_base_api_permissions.py +++ b/tests/unit/test_base_api_permissions.py @@ -14,12 +14,9 @@ # 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, +from taiga.base.api.permissions import (AllowAny as TruePermissionComponent, DenyAll as FalsePermissionComponent) -import pytest - def test_permission_component_composition(): assert (TruePermissionComponent() | TruePermissionComponent()).check_permissions(None, None, None) diff --git a/tests/unit/test_connectors_github.py b/tests/unit/test_connectors_github.py deleted file mode 100644 index f27a2f8a..00000000 --- a/tests/unit/test_connectors_github.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (C) 2014 Andrey Antukh -# Copyright (C) 2014 Jesús Espino -# Copyright (C) 2014 David Barragán -# Copyright (C) 2014 Anler Hernández -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pytest - -from unittest.mock import patch, Mock -from taiga.base.connectors import github -from taiga.base.connectors import exceptions as exc - - -def test_url_builder(): - assert (github._build_url("login", "authorize") == - "https://api.github.com/login/oauth/authorize") - assert (github._build_url("login","access-token") == - "https://api.github.com/login/oauth/access_token") - assert (github._build_url("user", "profile") == - "https://api.github.com/user") - assert (github._build_url("user", "emails") == - "https://api.github.com/user/emails") - - -def test_login_without_settings_params(): - with pytest.raises(exc.GitHubApiError) as e, \ - patch("taiga.base.connectors.github.requests") as m_requests: - m_requests.post.return_value = m_response = Mock() - m_response.status_code = 200 - m_response.json.return_value = {"access_token": "xxxxxxxx"} - - auth_info = github.login("*access-code*", "**client-id**", "*ient-secret*", github.HEADERS) - assert e.value.status_code == 400 - assert "error_message" in e.value.detail - - -def test_login_success(): - with patch("taiga.base.connectors.github.requests") as m_requests, \ - patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \ - patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET: - CLIENT_ID = "*CLIENT_ID*" - CLIENT_SECRET = "*CLIENT_SECRET*" - m_requests.post.return_value = m_response = Mock() - m_response.status_code = 200 - m_response.json.return_value = {"access_token": "xxxxxxxx"} - - auth_info = github.login("*access-code*", "**client-id**", "*client-secret*", github.HEADERS) - - assert auth_info.access_token == "xxxxxxxx" - m_requests.post.assert_called_once_with("https://github.com/login/oauth/access_token", - headers=github.HEADERS, - params={'code': '*access-code*', - 'scope': 'user:emails', - 'client_id': '**client-id**', - 'client_secret': '*client-secret*'}) - - -def test_login_whit_errors(): - with pytest.raises(exc.GitHubApiError) as e, \ - patch("taiga.base.connectors.github.requests") as m_requests, \ - patch("taiga.base.connectors.github.CLIENT_ID") as CLIENT_ID, \ - patch("taiga.base.connectors.github.CLIENT_SECRET") as CLIENT_SECRET: - CLIENT_ID = "*CLIENT_ID*" - CLIENT_SECRET = "*CLIENT_SECRET*" - m_requests.post.return_value = m_response = Mock() - m_response.status_code = 200 - m_response.json.return_value = {"error": "Invalid credentials"} - - auth_info = github.login("*access-code*", "**client-id**", "*ient-secret*", github.HEADERS) - assert e.value.status_code == 400 - assert e.value.detail["status_code"] == 200 - assert e.value.detail["error"] == "Invalid credentials" - - -def test_get_user_profile_success(): - with patch("taiga.base.connectors.github.requests") as m_requests: - m_requests.get.return_value = m_response = Mock() - m_response.status_code = 200 - m_response.json.return_value = {"id": 1955, - "login": "mmcfly", - "name": "martin seamus mcfly", - "bio": "time traveler"} - - user_profile = github.get_user_profile(github.HEADERS) - - assert user_profile.id == 1955 - assert user_profile.username == "mmcfly" - assert user_profile.full_name == "martin seamus mcfly" - assert user_profile.bio == "time traveler" - m_requests.get.assert_called_once_with("https://api.github.com/user", - headers=github.HEADERS) - - -def test_get_user_profile_whit_errors(): - with pytest.raises(exc.GitHubApiError) as e, \ - patch("taiga.base.connectors.github.requests") as m_requests: - m_requests.get.return_value = m_response = Mock() - m_response.status_code = 401 - m_response.json.return_value = {"error": "Invalid credentials"} - - auth_info = github.get_user_profile(github.HEADERS) - assert e.value.status_code == 400 - assert e.value.detail["status_code"] == 401 - assert e.value.detail["error"] == "Invalid credentials" - - -def test_get_user_emails_success(): - with patch("taiga.base.connectors.github.requests") as m_requests: - m_requests.get.return_value = m_response = Mock() - m_response.status_code = 200 - m_response.json.return_value = [{"email": "darth-vader@bttf.com", "primary": False}, - {"email": "mmcfly@bttf.com", "primary": True}] - - emails = github.get_user_emails(github.HEADERS) - - assert len(emails) == 2 - assert emails[0].email == "darth-vader@bttf.com" - assert not emails[0].is_primary - assert emails[1].email == "mmcfly@bttf.com" - assert emails[1].is_primary - m_requests.get.assert_called_once_with("https://api.github.com/user/emails", - headers=github.HEADERS) - - -def test_get_user_emails_whit_errors(): - with pytest.raises(exc.GitHubApiError) as e, \ - patch("taiga.base.connectors.github.requests") as m_requests: - m_requests.get.return_value = m_response = Mock() - m_response.status_code = 401 - m_response.json.return_value = {"error": "Invalid credentials"} - - emails = github.get_user_emails(github.HEADERS) - assert e.value.status_code == 400 - assert e.value.detail["status_code"] == 401 - assert e.value.detail["error"] == "Invalid credentials" - - -def test_me(): - with patch("taiga.base.connectors.github.login") as m_login, \ - patch("taiga.base.connectors.github.get_user_profile") as m_get_user_profile, \ - patch("taiga.base.connectors.github.get_user_emails") as m_get_user_emails: - m_login.return_value = github.AuthInfo(access_token="xxxxxxxx") - m_get_user_profile.return_value = github.User(id=1955, - username="mmcfly", - full_name="martin seamus mcfly", - bio="time traveler") - m_get_user_emails.return_value = [github.Email(email="darth-vader@bttf.com", is_primary=False), - github.Email(email="mmcfly@bttf.com", is_primary=True)] - - email, user = github.me("**access-code**") - - assert email == "mmcfly@bttf.com" - assert user.id == 1955 - assert user.username == "mmcfly" - assert user.full_name == "martin seamus mcfly" - assert user.bio == "time traveler" - - headers = github.HEADERS.copy() - headers["Authorization"] = "token xxxxxxxx" - m_get_user_profile.assert_called_once_with(headers=headers) - m_get_user_emails.assert_called_once_with(headers=headers) diff --git a/tests/unit/test_deferred.py b/tests/unit/test_deferred.py index 7417fc2c..c4e076c4 100644 --- a/tests/unit/test_deferred.py +++ b/tests/unit/test_deferred.py @@ -19,6 +19,7 @@ from unittest import mock from taiga import celery from taiga.deferred import defer, call_async, apply_async + def test_defer(): # settings.CELERY_ALWAYS_EAGER = True name = "task name" diff --git a/tests/unit/test_mdrender.py b/tests/unit/test_mdrender.py index 6e7002e0..1de084d8 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 @@ -101,18 +101,22 @@ def test_render_wikilink(): expected_result = "

test

" assert render(dummy_project, "[[test]]") == expected_result + def test_render_wikilink_1(): expected_result = "

test

" assert render(dummy_project, "[[test]]") == expected_result + def test_render_wikilink_2(): expected_result = "

test page

" assert render(dummy_project, "[[test page]]") == expected_result + def test_render_wikilink_3(): expected_result = "

TestPage

" assert render(dummy_project, "[[TestPage]]") == expected_result + def test_render_wikilink_with_custom_title(): expected_result = "

custom

" assert render(dummy_project, "[[test|custom]]") == expected_result @@ -129,25 +133,25 @@ def test_render_wikilink_relative_to_absolute(): def test_render_reference_links(): - expected_result = "

An example of reference link

" + expected_result = "

An example of reference link

" source = "An [example][id] of reference link\n [id]: http://example.com/ \"Title\"" assert render(dummy_project, source) == expected_result def test_render_url_autolinks(): - expected_result = "

Test the http://example.com/ autolink

" + expected_result = "

Test the http://example.com/ autolink

" source = "Test the http://example.com/ autolink" assert render(dummy_project, source) == expected_result def test_render_url_autolinks_without_http(): - expected_result = "

Test the www.example.com autolink

" + expected_result = "

Test the www.example.com autolink

" source = "Test the www.example.com autolink" assert render(dummy_project, source) == expected_result def test_render_url_automail(): - expected_result = "

Test the example@example.com automail

" + expected_result = "

Test the example@example.com automail

" source = "Test the example@example.com automail" assert render(dummy_project, source) == expected_result diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py index 203320f5..c1a0e765 100644 --- a/tests/unit/test_permissions.py +++ b/tests/unit/test_permissions.py @@ -17,11 +17,9 @@ from taiga.permissions import service from taiga.users.models import Role -import pytest - def test_role_has_perm(): role = Role() role.permissions = ["test"] assert service.role_has_perm(role, "test") - assert service.role_has_perm(role, "false") == False + assert service.role_has_perm(role, "false") is False diff --git a/tests/unit/test_slug.py b/tests/unit/test_slug.py index deb1961b..e687bcbd 100644 --- a/tests/unit/test_slug.py +++ b/tests/unit/test_slug.py @@ -43,6 +43,7 @@ def test_project_slug_with_special_chars(): assert project.slug == "test-han-zi" + def test_project_with_existing_name_slug_with_special_chars(): user = User.objects.create(username="test") Project.objects.create(name="漢字", description="漢字", owner=user) diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py index 5380cbdf..175c34e3 100644 --- a/tests/unit/test_timeline.py +++ b/tests/unit/test_timeline.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, call from django.core.exceptions import ValidationError @@ -41,6 +41,7 @@ def test_push_to_timeline_many_objects(): with pytest.raises(Exception): service.push_to_timeline(None, project, "test") + def test_add_to_objects_timeline(): with patch("taiga.timeline.service._add_to_object_timeline") as mock: users = [User(), User(), User()] @@ -78,7 +79,7 @@ def test_get_impl_key_from_typename(): def test_get_class_implementation(): service._timeline_impl_map["timeline.timeline.test"] = "test" assert service._get_class_implementation(Timeline, "test") == "test" - assert service._get_class_implementation(Timeline, "other") == None + assert service._get_class_implementation(Timeline, "other") is None def test_register_timeline_implementation(): diff --git a/tests/unit/test_tokens.py b/tests/unit/test_tokens.py index 626555ab..1da085e1 100644 --- a/tests/unit/test_tokens.py +++ b/tests/unit/test_tokens.py @@ -25,6 +25,7 @@ from taiga.auth.tokens import get_token_for_user, get_user_for_token pytestmark = pytest.mark.django_db + def test_valid_token(): user = f.UserFactory.create(email="old@email.com") token = get_token_for_user(user, "testing_scope") @@ -34,19 +35,19 @@ def test_valid_token(): @pytest.mark.xfail(raises=exc.NotAuthenticated) def test_invalid_token(): - user = f.UserFactory.create(email="old@email.com") - user_from_token = get_user_for_token("testing_invalid_token", "testing_scope") + f.UserFactory.create(email="old@email.com") + get_user_for_token("testing_invalid_token", "testing_scope") @pytest.mark.xfail(raises=exc.NotAuthenticated) def test_invalid_token_expiration(): user = f.UserFactory.create(email="old@email.com") token = get_token_for_user(user, "testing_scope") - user_from_token = get_user_for_token(token, "testing_scope", max_age=1) + get_user_for_token(token, "testing_scope", max_age=1) @pytest.mark.xfail(raises=exc.NotAuthenticated) def test_invalid_token_scope(): user = f.UserFactory.create(email="old@email.com") token = get_token_for_user(user, "testing_scope") - user_from_token = get_user_for_token(token, "testing_invalid_scope") + get_user_for_token(token, "testing_invalid_scope") diff --git a/tests/utils.py b/tests/utils.py index e30c90d0..10f1fd9f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,7 +14,6 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import functools from django.db.models import signals from taiga.base.utils import json @@ -60,6 +59,7 @@ def _helper_test_http_method_responses(client, method, url, data, users, after_e after_each_request() return results + def helper_test_http_method(client, method, url, data, users, after_each_request=None, content_type="application/json"): responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request, @@ -71,6 +71,7 @@ def helper_test_http_method_and_count(client, method, url, data, users, after_ea responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) return list(map(lambda r: (r.status_code, len(json.loads(r.content.decode('utf-8')))), responses)) + def helper_test_http_method_and_keys(client, method, url, data, users, after_each_request=None): responses = _helper_test_http_method_responses(client, method, url, data, users, after_each_request) return list(map(lambda r: (r.status_code, set(json.loads(r.content.decode('utf-8')).keys())), responses))