commit
d9a8c934b0
|
@ -12,3 +12,4 @@ media
|
|||
.cache
|
||||
.\#*
|
||||
.project
|
||||
.env
|
||||
|
|
12
AUTHORS.rst
12
AUTHORS.rst
|
@ -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
|
||||
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,5 +1,20 @@
|
|||
# Changelog #
|
||||
|
||||
|
||||
## 1.6.0 Abies Bifolia (2015-03-17)
|
||||
|
||||
|
||||
### Features
|
||||
- Added custom fields per project for user stories, tasks and issues.
|
||||
- Support of export to CSV user stories, tasks and issues.
|
||||
- Allow public projects.
|
||||
|
||||
### Misc
|
||||
- New contrib plugin for HipChat (by Δndrea Stagi).
|
||||
- Lots of small and not so small bugfixes.
|
||||
- Updated some requirements.
|
||||
|
||||
|
||||
## 1.5.0 Betula Pendula - FOSDEM 2015 (2015-01-29)
|
||||
|
||||
### Features
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
# Taiga Backend #
|
||||
|
||||

|
||||
|
||||
[](https://travis-ci.org/taigaio/taiga-back "Travis Badge")
|
||||
|
||||
[](https://coveralls.io/r/taigaio/taiga-back?branch=master "Coveralls")
|
||||
[](https://taiga.io "Managed with Taiga.io")
|
||||
[](https://travis-ci.org/taigaio/taiga-back "Build Status")
|
||||
[](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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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': '',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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,7 +82,7 @@ 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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 _
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import collections
|
||||
|
||||
|
||||
def dict_sum(*args):
|
||||
result = collections.Counter()
|
||||
for arg in args:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -20,6 +20,7 @@ def first(iterable):
|
|||
return None
|
||||
return iterable[0]
|
||||
|
||||
|
||||
def next(data:list):
|
||||
return data[1:]
|
||||
|
||||
|
|
|
@ -36,4 +36,3 @@ def without_signals(*disablers):
|
|||
for disabler in disablers:
|
||||
signal, *ids = disabler
|
||||
signal.receivers = signal.backup_receivers
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -16,5 +16,6 @@
|
|||
|
||||
from rest_framework.renderers import UnicodeJSONRenderer
|
||||
|
||||
|
||||
class ExportRenderer(UnicodeJSONRenderer):
|
||||
pass
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -20,5 +20,6 @@ from taiga.base import throttling
|
|||
class ImportModeRateThrottle(throttling.UserRateThrottle):
|
||||
scope = "import-mode"
|
||||
|
||||
|
||||
class ImportDumpModeRateThrottle(throttling.UserRateThrottle):
|
||||
scope = "import-dump-mode"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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."))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
|
@ -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
|
|
@ -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')]),
|
||||
),
|
||||
]
|
|
@ -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,),
|
||||
),
|
||||
]
|
|
@ -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;"""
|
||||
)
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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
|
|
@ -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')
|
|
@ -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
|
|
@ -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()
|
|
@ -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":{}})
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;',
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
@ -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,
|
||||
|
|
|
@ -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;',
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -14,16 +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 django.utils.translation import ugettext_lazy as _
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from taiga.base import filters
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base import response
|
||||
from taiga.base.decorators import detail_route
|
||||
from taiga.base.api import ModelCrudViewSet
|
||||
from taiga.base.api.utils import get_object_or_404
|
||||
|
||||
from taiga.projects.notifications.mixins import WatchedResourceMixin
|
||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||
|
@ -97,4 +92,4 @@ class MilestoneViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudView
|
|||
current_date = current_date + datetime.timedelta(days=1)
|
||||
optimal_points -= optimal_points_per_day
|
||||
|
||||
return Response(milestone_stats)
|
||||
return response.Ok(milestone_stats)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue