Merge branch 'master' into stable

remotes/origin/enhancement/email-actions 1.6.0
Alejandro Alonso 2015-03-17 10:57:11 +01:00
commit d9a8c934b0
205 changed files with 6193 additions and 1970 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ media
.cache
.\#*
.project
.env

View File

@ -4,6 +4,7 @@ The PRIMARY AUTHORS are:
- Jesus Espino Garcia <jespinog@gmail.com>
- David Barragán Merino <dbarragan@dbarragan.com>
- Alejandro Alonso <alejandro.alonso@kaleidos.net>
- Xavi Julian <xavier.julian@kaleidos.net>
- Anler Hernández <hello@anler.me>
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 <andres.moya@kaleidos.net>
- Yamila Moreno <yamila.moreno@kaleidos.net>
- Ricky Posner <e@eposner.com>
- Alonso Torres <alonso.torres@kaleidos.net>
- Alejandro Gómez <alejandro.gomez@kaleidos.net>
- Andrea Stagi <stagi.andrea@gmail.com>
- Hector Colina <hcolina@gmail.com>
- Julien Palard
- Joe Letts

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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': '',

View File

@ -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,

View File

@ -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"))

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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"]

View File

@ -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):

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -1,166 +0,0 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -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

View File

@ -14,17 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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

View File

@ -15,15 +15,19 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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:

View File

@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function
import sys
def patch_serializer():
from rest_framework import serializers

View File

@ -15,10 +15,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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]

View File

@ -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()

View File

@ -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)

View File

@ -15,9 +15,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
from functools import partial
from django.db import models
from django.utils.translation import ugettext_lazy as _

View File

@ -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.

View File

@ -16,6 +16,7 @@
import collections
def dict_sum(*args):
result = collections.Counter()
for arg in args:

View File

@ -15,6 +15,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
def noop(*args, **kwargs):
"""The noop function."""
return None

View File

@ -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)

View File

@ -20,6 +20,7 @@ def first(iterable):
return None
return iterable[0]
def next(data:list):
return data[1:]

View File

@ -36,4 +36,3 @@ def without_signals(*disablers):
for disabler in disablers:
signal, *ids = disabler
signal.receivers = signal.backup_receivers

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils import baseconv
from django.template.defaultfilters import slugify as django_slugify
import time

View File

@ -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

View File

@ -15,6 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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)

View File

@ -14,8 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.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):

View File

@ -14,19 +14,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.core.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 = '<dump_file> <owner-email>'
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):

View File

@ -16,5 +16,6 @@
from rest_framework.renderers import UnicodeJSONRenderer
class ExportRenderer(UnicodeJSONRenderer):
pass

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -20,5 +20,6 @@ from taiga.base import throttling
class ImportModeRateThrottle(throttling.UserRateThrottle):
scope = "import-mode"
class ImportDumpModeRateThrottle(throttling.UserRateThrottle):
scope = "import-dump-mode"

View File

@ -14,11 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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()

View File

@ -14,18 +14,14 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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

View File

@ -15,12 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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:

View File

@ -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

View File

@ -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

View File

@ -14,13 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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):

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -14,20 +14,17 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,46 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2015 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# 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 <http://www.gnu.org/licenses/>.
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),
"<prettify")
class TargetBlankLinksTreeprocessor(Treeprocessor):
def run(self, root):
home_url = resolve("home")
links = root.getiterator("a")
for a in links:
href = a.get("href", "")
url = a.get("href", "")
if url.endswith("/"):
url = url[:-1]
if not url.startswith(home_url):
a.set("target", "_blank")

View File

@ -26,6 +26,7 @@ from taiga.base.utils.slug import slugify
import re
class WikiLinkExtension(Extension):
def __init__(self, project, *args, **kwargs):
self.project = project
@ -66,6 +67,7 @@ class WikiLinksPattern(Pattern):
SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
class RelativeLinksTreeprocessor(Treeprocessor):
def __init__(self, md, project):
self.project = project

View File

@ -22,6 +22,7 @@ import bleach
import html5lib
from html5lib.serializer.htmlserializer import HTMLSerializer
def _serialize(domtree):
walker = html5lib.treewalkers.getTreeWalker('etree')
stream = walker(domtree)
@ -32,7 +33,7 @@ def _serialize(domtree):
return serializer.render(stream)
bleach._serialize = _serialize
### END PATCH
# END PATCH
from django.core.cache import cache
from django.utils.encoding import force_bytes
@ -48,7 +49,7 @@ from .extensions.wikilinks import WikiLinkExtension
from .extensions.emojify import EmojifyExtension
from .extensions.mentions import MentionsExtension
from .extensions.references import TaigaReferencesExtension
from .extensions.target_link import TargetBlankLinkExtension
# Bleach configuration
bleach.ALLOWED_TAGS += ["p", "table", "thead", "tbody", "th", "tr", "td", "h1",
@ -58,7 +59,7 @@ bleach.ALLOWED_TAGS += ["p", "table", "thead", "tbody", "th", "tr", "td", "h1",
bleach.ALLOWED_STYLES.append("background")
bleach.ALLOWED_ATTRIBUTES["a"] = ["href", "title", "alt"]
bleach.ALLOWED_ATTRIBUTES["a"] = ["href", "title", "alt", "target"]
bleach.ALLOWED_ATTRIBUTES["img"] = ["alt", "src"]
bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style"]
@ -73,9 +74,11 @@ def _make_extensions_list(project=None):
EmojifyExtension(),
MentionsExtension(),
TaigaReferencesExtension(project),
TargetBlankLinkExtension(),
"extra",
"codehilite",
"sane_lists",
"toc",
"nl2br"]

View File

@ -103,3 +103,17 @@ def get_user_project_permissions(user, project):
anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
return set(owner_permissions + members_permissions + public_permissions + anon_permissions)
def set_base_permissions_for_project(project):
if project.is_private:
project.anon_permissions = []
project.public_permissions = []
else:
"""
If a project is public anonymous and registered users should have at least visualization permissions
"""
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
project.anon_permissions = list(set(project.anon_permissions + anon_permissions))
project.public_permissions = list(set(project.public_permissions + anon_permissions))

View File

@ -16,50 +16,70 @@
import uuid
from django.db.models import Q, signals
from django.utils.translation import ugettext_lazy as _
from django.shortcuts import get_object_or_404
from django.db import transaction as tx
from django.db.models import signals
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from rest_framework.response import Response
from rest_framework.exceptions import ParseError
from rest_framework import viewsets
from rest_framework import status
from taiga.base import filters, response
from taiga.base import filters
from taiga.base import response
from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.decorators import detail_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import RetrieveModelMixin
from taiga.base.api.permissions import IsAuthenticatedPermission, AllowAnyPermission
from taiga.base.api.permissions import AllowAnyPermission
from taiga.base.api.utils import get_object_or_404
from taiga.base.utils.slug import slugify_uniquely
from taiga.users.models import Role
from taiga.projects.issues.models import Issue
from taiga.projects.userstories.models import UserStory
from taiga.projects.mixins.ordering import BulkUpdateOrderMixin
from taiga.projects.mixins.on_destroy import MoveOnDestroyMixin
from taiga.projects.userstories.models import UserStory, RolePoints
from taiga.projects.tasks.models import Task
from taiga.projects.issues.models import Issue
from taiga.permissions import service as permissions_service
from . import serializers
from . import models
from . import permissions
from . import services
from .votes.utils import attach_votescount_to_queryset
from .votes import services as votes_service
from .votes import serializers as votes_serializers
from .votes import services as votes_service
from .votes.utils import attach_votescount_to_queryset
######################################################
## Project
######################################################
class ProjectViewSet(ModelCrudViewSet):
serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer
list_serializer_class = serializers.ProjectSerializer
permission_classes = (permissions.ProjectPermission, )
filter_backends = (filters.CanViewProjectObjFilterBackend,)
filter_fields = (('member', 'members'),)
def get_queryset(self):
qs = models.Project.objects.all()
return attach_votescount_to_queryset(qs, as_field="stars_count")
def get_serializer_class(self):
if self.action == "list":
return self.list_serializer_class
elif self.action == "create":
return self.serializer_class
if self.action == "by_slug":
slug = self.request.QUERY_PARAMS.get("slug", None)
project = get_object_or_404(models.Project, slug=slug)
else:
project = self.get_object()
if permissions_service.is_project_owner(self.request.user, project):
return self.admin_serializer_class
return self.serializer_class
@list_route(methods=["GET"])
def by_slug(self, request):
slug = request.QUERY_PARAMS.get("slug", None)
@ -73,65 +93,92 @@ class ProjectViewSet(ModelCrudViewSet):
modules_config = services.get_modules_config(project)
if request.method == "GET":
return Response(modules_config.config)
return response.Ok(modules_config.config)
else:
modules_config.config.update(request.DATA)
modules_config.save()
return Response(status=status.HTTP_204_NO_CONTENT)
return response.NoContent()
@detail_route(methods=['get'])
@detail_route(methods=["GET"])
def stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'stats', project)
return Response(services.get_stats_for_project(project))
self.check_permissions(request, "stats", project)
return response.Ok(services.get_stats_for_project(project))
@detail_route(methods=['get'])
def _regenerate_csv_uuid(self, project, field):
uuid_value = uuid.uuid4().hex
setattr(project, field, uuid_value)
project.save()
return uuid_value
@detail_route(methods=["POST"])
def regenerate_userstories_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_userstories_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "userstories_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["POST"])
def regenerate_issues_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_issues_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["POST"])
def regenerate_tasks_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_tasks_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["GET"])
def member_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'member_stats', project)
return Response(services.get_member_stats_for_project(project))
self.check_permissions(request, "member_stats", project)
return response.Ok(services.get_member_stats_for_project(project))
@detail_route(methods=['get'])
@detail_route(methods=["GET"])
def issues_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'issues_stats', project)
return Response(services.get_stats_for_project_issues(project))
self.check_permissions(request, "issues_stats", project)
return response.Ok(services.get_stats_for_project_issues(project))
@detail_route(methods=['get'])
@detail_route(methods=["GET"])
def issue_filters_data(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'issues_filters_data', project)
return Response(services.get_issues_filters_data(project))
self.check_permissions(request, "issues_filters_data", project)
return response.Ok(services.get_issues_filters_data(project))
@detail_route(methods=['get'])
@detail_route(methods=["GET"])
def tags_colors(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'tags_colors', project)
return Response(dict(project.tags_colors))
self.check_permissions(request, "tags_colors", project)
return response.Ok(dict(project.tags_colors))
@detail_route(methods=['post'])
@detail_route(methods=["POST"])
def star(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'star', project)
self.check_permissions(request, "star", project)
votes_service.add_vote(project, user=request.user)
return Response(status=status.HTTP_200_OK)
return response.Ok()
@detail_route(methods=['post'])
@detail_route(methods=["POST"])
def unstar(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'unstar', project)
self.check_permissions(request, "unstar", project)
votes_service.remove_vote(project, user=request.user)
return Response(status=status.HTTP_200_OK)
return response.Ok()
@detail_route(methods=['get'])
@detail_route(methods=["GET"])
def fans(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'fans', project)
self.check_permissions(request, "fans", project)
voters = votes_service.get_voters(project)
voters_data = votes_serializers.VoterSerializer(voters, many=True)
return Response(voters_data.data)
return response.Ok(voters_data.data)
@detail_route(methods=["POST"])
def create_template(self, request, **kwargs):
@ -139,10 +186,10 @@ class ProjectViewSet(ModelCrudViewSet):
template_description = request.DATA.get('template_description', None)
if not template_name:
raise ParseError("Not valid template name")
raise response.BadRequest("Not valid template name")
if not template_description:
raise ParseError("Not valid template description")
raise response.BadRequest("Not valid template description")
template_slug = slugify_uniquely(template_name, models.ProjectTemplate)
@ -158,14 +205,28 @@ class ProjectViewSet(ModelCrudViewSet):
template.load_data_from_project(project)
template.save()
return Response(serializers.ProjectTemplateSerializer(template).data, status=201)
return response.Created(serializers.ProjectTemplateSerializer(template).data)
@detail_route(methods=['post'])
def leave(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'leave', project)
services.remove_user_from_project(request.user, project)
return Response(status=status.HTTP_200_OK)
return response.Ok()
def _set_base_permissions(self, obj):
update_permissions = False
if not obj.id:
if not obj.is_private:
# Creating a public project
update_permissions = True
else:
if self.get_object().is_private != obj.is_private:
# Changing project public state
update_permissions = True
if update_permissions:
permissions_service.set_base_permissions_for_project(obj)
def pre_save(self, obj):
if not obj.id:
@ -175,19 +236,25 @@ class ProjectViewSet(ModelCrudViewSet):
if not obj.id:
obj.template = self.request.QUERY_PARAMS.get('template', None)
self._set_base_permissions(obj)
super().pre_save(obj)
def destroy(self, request, *args, **kwargs):
obj = self.get_object_or_none()
self.check_permissions(request, 'destroy', obj)
signals.post_delete.disconnect(sender=UserStory, dispatch_uid="user_story_update_project_colors_on_delete")
signals.post_delete.disconnect(sender=Issue, dispatch_uid="issue_update_project_colors_on_delete")
signals.post_delete.disconnect(sender=UserStory,
dispatch_uid="user_story_update_project_colors_on_delete")
signals.post_delete.disconnect(sender=Issue,
dispatch_uid="issue_update_project_colors_on_delete")
signals.post_delete.disconnect(sender=Task,
dispatch_uid="tasks_milestone_close_handler_on_delete")
signals.post_delete.disconnect(sender=Task,
dispatch_uid="tasks_us_close_handler_on_delete")
signals.post_delete.disconnect(sender=Task,
dispatch_uid="task_update_project_colors_on_delete")
signals.post_delete.disconnect(dispatch_uid="refprojdel")
signals.post_delete.disconnect(dispatch_uid='update_watchers_on_membership_post_delete')
signals.post_delete.disconnect(sender=Task, dispatch_uid="tasks_milestone_close_handler_on_delete")
signals.post_delete.disconnect(sender=Task, dispatch_uid="tasks_us_close_handler_on_delete")
signals.post_delete.disconnect(sender=Task, dispatch_uid="task_update_project_colors_on_delete")
obj.tasks.all().delete()
obj.user_stories.all().delete()
@ -202,135 +269,15 @@ class ProjectViewSet(ModelCrudViewSet):
self.pre_conditions_on_delete(obj)
obj.delete()
self.post_delete(obj)
return Response(status=status.HTTP_204_NO_CONTENT)
return response.NoContent()
class MembershipViewSet(ModelCrudViewSet):
model = models.Membership
serializer_class = serializers.MembershipSerializer
permission_classes = (permissions.MembershipPermission,)
filter_backends = (filters.CanViewProjectFilterBackend,)
filter_fields = ("project", "role")
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.MembersBulkSerializer(data=request.DATA)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
######################################################
## Custom values for selectors
######################################################
data = serializer.data
project = models.Project.objects.get(id=data["project_id"])
invitation_extra_text = data.get("invitation_extra_text", None)
self.check_permissions(request, 'bulk_create', project)
# TODO: this should be moved to main exception handler instead
# of handling explicit exception catchin here.
try:
members = services.create_members_in_bulk(data["bulk_memberships"],
project=project,
invitation_extra_text=invitation_extra_text,
callback=self.post_save,
precall=self.pre_save)
except ValidationError as err:
return response.BadRequest(err.message_dict)
members_serialized = self.serializer_class(members, many=True)
return response.Ok(data=members_serialized.data)
@detail_route(methods=["POST"])
def resend_invitation(self, request, **kwargs):
invitation = self.get_object()
self.check_permissions(request, 'resend_invitation', invitation.project)
services.send_invitation(invitation=invitation)
return Response(status=status.HTTP_204_NO_CONTENT)
def pre_delete(self, obj):
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project):
raise exc.BadRequest(_("At least one of the user must be an active admin"))
def pre_save(self, obj):
if not obj.token:
obj.token = str(uuid.uuid1())
obj.invited_by = self.request.user
obj.user = services.find_invited_user(obj.email, default=obj.user)
super().pre_save(obj)
def post_save(self, object, created=False):
super().post_save(object, created=created)
if not created:
return
# Send email only if a new membership is created
services.send_invitation(invitation=object)
class InvitationViewSet(ModelListViewSet):
"""
Only used by front for get invitation by it token.
"""
queryset = models.Membership.objects.filter(user__isnull=True)
serializer_class = serializers.MembershipSerializer
lookup_field = "token"
permission_classes = (AllowAnyPermission,)
def list(self, *args, **kwargs):
raise exc.PermissionDenied(_("You don't have permisions to see that."))
class RolesViewSet(ModelCrudViewSet):
model = 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:
role_dest = get_object_or_404(self.model, project=obj.project, id=move_to)
qs = models.Membership.objects.filter(project_id=obj.project.pk, role=obj)
qs.update(role=role_dest)
super().pre_delete(obj)
# User Stories commin ViewSets
class BulkUpdateOrderMixin(object):
"""
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(_("%s parameter is mandatory") % 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(models.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(data=None, status=status.HTTP_204_NO_CONTENT)
class PointsViewSet(ModelCrudViewSet, BulkUpdateOrderMixin):
class PointsViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Points
serializer_class = serializers.PointsSerializer
permission_classes = (permissions.PointsPermission,)
@ -339,27 +286,9 @@ class PointsViewSet(ModelCrudViewSet, BulkUpdateOrderMixin):
bulk_update_param = "bulk_points"
bulk_update_perm = "change_points"
bulk_update_order_action = services.bulk_update_points_order
class MoveOnDestroyMixin(object):
@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, project=obj.project, id=move_to)
self.check_permissions(request, 'destroy', obj)
kwargs = {self.move_on_destroy_related_field: move_item}
self.move_on_destroy_related_class.objects.filter(project=obj.project, **{self.move_on_destroy_related_field: obj}).update(**kwargs)
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)
move_on_destroy_related_class = RolePoints
move_on_destroy_related_field = "points"
move_on_destroy_project_default_field = "default_points"
class UserStoryStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.UserStoryStatus
@ -445,6 +374,10 @@ class IssueStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMi
move_on_destroy_project_default_field = "default_issue_status"
######################################################
## Project Template
######################################################
class ProjectTemplateViewSet(ModelCrudViewSet):
model = models.ProjectTemplate
serializer_class = serializers.ProjectTemplateSerializer
@ -452,3 +385,100 @@ class ProjectTemplateViewSet(ModelCrudViewSet):
def get_queryset(self):
return models.ProjectTemplate.objects.all()
######################################################
## Members & Invitations
######################################################
class MembershipViewSet(ModelCrudViewSet):
model = models.Membership
admin_serializer_class = serializers.MembershipAdminSerializer
serializer_class = serializers.MembershipSerializer
permission_classes = (permissions.MembershipPermission,)
filter_backends = (filters.CanViewProjectFilterBackend,)
filter_fields = ("project", "role")
def get_serializer_class(self):
project_id = self.request.QUERY_PARAMS.get("project", None)
if project_id is None:
# Creation
if self.request.method == 'POST':
return self.admin_serializer_class
return self.serializer_class
project = get_object_or_404(models.Project, pk=project_id)
if permissions_service.is_project_owner(self.request.user, project):
return self.admin_serializer_class
return self.serializer_class
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.MembersBulkSerializer(data=request.DATA)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
data = serializer.data
project = models.Project.objects.get(id=data["project_id"])
invitation_extra_text = data.get("invitation_extra_text", None)
self.check_permissions(request, 'bulk_create', project)
# TODO: this should be moved to main exception handler instead
# of handling explicit exception catchin here.
try:
members = services.create_members_in_bulk(data["bulk_memberships"],
project=project,
invitation_extra_text=invitation_extra_text,
callback=self.post_save,
precall=self.pre_save)
except ValidationError as err:
return response.BadRequest(err.message_dict)
members_serialized = self.admin_serializer_class(members, many=True)
return response.Ok(members_serialized.data)
@detail_route(methods=["POST"])
def resend_invitation(self, request, **kwargs):
invitation = self.get_object()
self.check_permissions(request, 'resend_invitation', invitation.project)
services.send_invitation(invitation=invitation)
return response.NoContent()
def pre_delete(self, obj):
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project):
raise exc.BadRequest(_("At least one of the user must be an active admin"))
def pre_save(self, obj):
if not obj.token:
obj.token = str(uuid.uuid1())
obj.invited_by = self.request.user
obj.user = services.find_invited_user(obj.email, default=obj.user)
super().pre_save(obj)
def post_save(self, object, created=False):
super().post_save(object, created=created)
if not created:
return
# Send email only if a new membership is created
services.send_invitation(invitation=object)
class InvitationViewSet(ModelListViewSet):
"""
Only used by front for get invitation by it token.
"""
queryset = models.Membership.objects.filter(user__isnull=True)
serializer_class = serializers.MembershipSerializer
lookup_field = "token"
permission_classes = (AllowAnyPermission,)
def list(self, *args, **kwargs):
raise exc.PermissionDenied(_("You don't have permisions to see that."))

View File

@ -21,14 +21,15 @@ import mimetypes
mimetypes.init()
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404
from django.conf import settings
from django import http
from taiga.base.api import ModelCrudViewSet
from taiga.base.api import generics
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.api import generics
from taiga.base.api import ModelCrudViewSet
from taiga.base.api.utils import get_object_or_404
from taiga.users.models import User
from taiga.projects.notifications.mixins import WatchedResourceMixin

View File

@ -0,0 +1,71 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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"]

View File

@ -0,0 +1,119 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -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')]),
),
]

View File

@ -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,),
),
]

View File

@ -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;"""
)
]

View File

@ -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),
]

View File

@ -0,0 +1,130 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -0,0 +1,83 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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')

View File

@ -0,0 +1,146 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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

View File

@ -0,0 +1,69 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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()

View File

@ -0,0 +1,35 @@
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
# 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 <http://www.gnu.org/licenses/>.
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":{}})

View File

@ -15,14 +15,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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()

View File

@ -14,9 +14,13 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from 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

View File

@ -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;',
),
]

View File

@ -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:

View File

@ -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

View File

@ -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 @@
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ points.1 }}</strong>
<strong>{{ points.0 }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ points.0 }}</strong>
<strong>{{ points.1 }}</strong>
</td>
</tr>
{% endfor %}
@ -80,9 +81,7 @@
<tr>
<td colspan="2">
<h3>{{ _("Deleted attachment") }}</h3>
{% if att.changes.description %}
<p>{{ att.filename|linebreaksbr }}</p>
{% endif %}
</td>
</tr>
{% 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) %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
<h3>{{ verbose_name(obj_class, field_name) }}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ ', '.join(values_from) }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ ', '.join(values_to) }}</strong>
{% if values_added %}
<span>{{ _("added") }}</span><br>
<strong>{{ ', '.join(values_added) }}</strong>
{% endif %}
{% if values_removed %}
<span>{{ _("removed") }}</span><br>
<strong>{{ ', '.join(values_removed) }}</strong>
{% endif %}
</td>
</tr>
{# DESCRIPTIONS #}
@ -126,7 +129,7 @@
{% elif field_name == "assigned_to" %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
<h3>{{ verbose_name(obj_class, field_name) }}</h3>
</td>
<td valign="top" class="update-row-from">
{% if values.0 != None and values.0 != "" %}
@ -151,10 +154,9 @@
</tr>
{# * #}
{% else %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ field_name }}</h3>
<h3>{{ verbose_name(obj_class, field_name) }}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
@ -168,5 +170,52 @@
</td>
</tr>
{% endif %}
{% elif field_name == "custom_attributes" %}
{# CUSTOM ATTRIBUTES #}
{% if values.new %}
{% for attr in values['new']%}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ attr.name }}</h3>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ attr.value|linebreaksbr }}</strong>
</td>
</tr>
{% endfor %}
{% endif %}
{% if values.changed %}
{% for attr in values['changed'] %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ attr.name }}</h3>
</td>
<td valign="top" class="update-row-from">
<span>{{ _("from") }}</span><br>
<strong>{{ attr.changes.value.0|linebreaksbr }}</strong>
</td>
</tr>
<tr>
<td valign="top">
<span>{{ _("to") }}</span><br>
<strong>{{ attr.changes.value.1|linebreaksbr }}</strong>
</td>
</tr>
{% endfor %}
{% endif %}
{% if values.deleted %}
{% for attr in values['deleted']%}
<tr>
<td colspan="2">
<h3>{{ attr.name }}</h3>
<p>{{ _("-deleted-") }}</p>
</td>
</tr>
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}

View File

@ -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 %}

View File

@ -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)))

View File

@ -15,18 +15,15 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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")

View File

@ -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')

View File

@ -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

View File

@ -14,6 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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

View File

@ -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,

View File

@ -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;',
),
]

View File

@ -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),
]

View File

@ -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,
),
]

View File

@ -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,
),
]

Some files were not shown because too many files have changed in this diff Show More