Merge branch 'master' into stable

remotes/origin/issue/4795/notification_even_they_are_disabled 2.0.0
Alejandro Alonso 2016-04-01 11:25:44 +02:00
commit 3aed6371ff
278 changed files with 13623 additions and 4556 deletions

View File

@ -20,12 +20,18 @@ answer newbie questions, and generally made taiga that much better:
- Andrea Stagi <stagi.andrea@gmail.com> - Andrea Stagi <stagi.andrea@gmail.com>
- Andrés Moya <andres.moya@kaleidos.net> - Andrés Moya <andres.moya@kaleidos.net>
- Andrey Alekseenko <al42and@gmail.com> - Andrey Alekseenko <al42and@gmail.com>
<<<<<<< HEAD
=======
- Brett Profitt <brett.profitt@gmail.com>
>>>>>>> master
- Bruno Clermont <bruno@robotinfra.com> - Bruno Clermont <bruno@robotinfra.com>
- Chris Wilson <chris.wilson@aridhia.com> - Chris Wilson <chris.wilson@aridhia.com>
- David Burke <david@burkesoftware.com> - David Burke <david@burkesoftware.com>
- Hector Colina <hcolina@gmail.com> - Hector Colina <hcolina@gmail.com>
- Joe Letts - Joe Letts
- Julien Palard - Julien Palard
- luyikei <luyikei.qmltu@gmail.com>
- Motius GmbH <mail@motius.de>
- Ricky Posner <e@eposner.com> - Ricky Posner <e@eposner.com>
- Yamila Moreno <yamila.moreno@kaleidos.net> - Yamila Moreno <yamila.moreno@kaleidos.net>
- Brett Profitt <brett.profitt@gmail.com> - Yaser Alraddadi <yaser@yr.sa>

View File

@ -1,6 +1,19 @@
# Changelog # # Changelog #
## 2.0.0 Pulsatilla Patens (2016-04-04)
### Features
- Ability to create url custom fields. (thanks to [@astagi](https://github.com/astagi)).
- Blocked projects support
- Transfer projects ownership support
- Customizable max private and public projects per user
- Customizable max of memberships per owned private and public projects
### Misc
- Lots of small and not so small bugfixes.
## 1.10.0 Dryas Octopetala (2016-01-30) ## 1.10.0 Dryas Octopetala (2016-01-30)
### Features ### Features
@ -10,7 +23,7 @@
- Filter projects list by - Filter projects list by
- is_looking_for_people - is_looking_for_people
- is_featured - is_featured
- is_backlog_activated - is_backlog_activated
- is_kanban_activated - is_kanban_activated
- Search projects by text query (order by ranking name > tags > description) - Search projects by text query (order by ranking name > tags > description)
- Order projects list: - Order projects list:

View File

@ -1,8 +1,8 @@
-r requirements.txt -r requirements.txt
factory_boy==2.6.0 factory_boy==2.6.1
py==1.4.31 py==1.4.31
pytest==2.8.5 pytest==2.8.7
pytest-django==2.9.1 pytest-django==2.9.1
pytest-pythonpath==0.7 pytest-pythonpath==0.7

View File

@ -1,37 +1,36 @@
Django==1.8.6 Django==1.9.2
#djangorestframework==2.3.13 # It's not necessary since Taiga 1.7 #djangorestframework==2.3.13 # It's not necessary since Taiga 1.7
django-picklefield==0.3.2 django-picklefield==0.3.2
django-sampledatahelper==0.3.0 django-sampledatahelper==0.4.0
gunicorn==19.3.0 gunicorn==19.4.5
psycopg2==2.6.1 psycopg2==2.6.1
Pillow==2.9.0 Pillow==3.1.1
pytz==2015.7 pytz==2015.7
six==1.10.0 six==1.10.0
amqp==1.4.7 amqp==1.4.9
djmail==0.11 djmail==0.12.0.post1
django-pgjson==0.3.1 django-pgjson==0.3.1
djorm-pgarray==1.2 djorm-pgarray==1.2
django-jinja==2.1.1 django-jinja==2.1.2
jinja2==2.8 jinja2==2.8
pygments==2.0.2 pygments==2.0.2
django-sites==0.8 django-sites==0.9
Markdown==2.6.5 Markdown==2.6.5
fn==0.4.3 fn==0.4.3
diff-match-patch==20121119 diff-match-patch==20121119
requests==2.8.1 requests==2.9.1
django-sr==0.0.4 django-sr==0.0.4
easy-thumbnails==2.2.1 easy-thumbnails==2.3
celery==3.1.19 celery==3.1.20
redis==2.10.5 redis==2.10.5
Unidecode==0.04.18 Unidecode==0.04.19
raven==5.9.2 raven==5.10.2
bleach==1.4.2 bleach==1.4.2
django-ipware==1.1.2 django-ipware==1.1.3
premailer==2.9.6 premailer==2.9.7
cssutils==1.0.1 # Compatible with python 3.5 cssutils==1.0.1 # Compatible with python 3.5
django-transactional-cleanup==0.1.15
lxml==3.5.0 lxml==3.5.0
git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea
pyjwkest==1.0.9 pyjwkest==1.1.5
python-dateutil==2.4.2 python-dateutil==2.4.2
netaddr==0.7.18 netaddr==0.7.18

View File

@ -30,7 +30,7 @@ DEBUG = False
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2", "ENGINE": "django.db.backends.postgresql",
"NAME": "taiga", "NAME": "taiga",
} }
} }
@ -320,7 +320,6 @@ INSTALLED_APPS = [
"sr", "sr",
"easy_thumbnails", "easy_thumbnails",
"raven.contrib.django.raven_compat", "raven.contrib.django.raven_compat",
"django_transactional_cleanup",
] ]
WSGI_APPLICATION = "taiga.wsgi.application" WSGI_APPLICATION = "taiga.wsgi.application"
@ -347,7 +346,7 @@ LOGGING = {
"handlers": { "handlers": {
"null": { "null": {
"level":"DEBUG", "level":"DEBUG",
"class":"django.utils.log.NullHandler", "class":"logging.NullHandler",
}, },
"console":{ "console":{
"level":"DEBUG", "level":"DEBUG",
@ -434,7 +433,9 @@ REST_FRAMEWORK = {
# Extra expose header related to Taiga APP (see taiga.base.middleware.cors=) # Extra expose header related to Taiga APP (see taiga.base.middleware.cors=)
APP_EXTRA_EXPOSE_HEADERS = [ APP_EXTRA_EXPOSE_HEADERS = [
"taiga-info-total-opened-milestones", "taiga-info-total-opened-milestones",
"taiga-info-total-closed-milestones" "taiga-info-total-closed-milestones",
"taiga-info-project-memberships",
"taiga-info-project-is-private"
] ]
DEFAULT_PROJECT_TEMPLATE = "scrum" DEFAULT_PROJECT_TEMPLATE = "scrum"
@ -522,6 +523,12 @@ WEBHOOKS_ENABLED = False
FRONT_SITEMAP_ENABLED = False FRONT_SITEMAP_ENABLED = False
FRONT_SITEMAP_CACHE_TIMEOUT = 24*60*60 # In second FRONT_SITEMAP_CACHE_TIMEOUT = 24*60*60 # In second
EXTRA_BLOCKING_CODES = []
MAX_PRIVATE_PROJECTS_PER_USER = None # None == no limit
MAX_PUBLIC_PROJECTS_PER_USER = None # None == no limit
MAX_MEMBERSHIPS_PRIVATE_PROJECTS = None # None == no limit
MAX_MEMBERSHIPS_PUBLIC_PROJECTS = None # None == no limit
from .sr import * from .sr import *

View File

@ -1,6 +1,7 @@
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz> # Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com> # Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com> # Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 Alejandro Alonso <alejandro.alonso@kaleidos.net>
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as # it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the # published by the Free Software Foundation, either version 3 of the
@ -24,7 +25,7 @@ from .development import *
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'transaction_hooks.backends.postgresql_psycopg2', 'ENGINE': 'django.db.backends.postgresql',
'NAME': 'taiga', 'NAME': 'taiga',
'USER': 'taiga', 'USER': 'taiga',
'PASSWORD': 'changeme', 'PASSWORD': 'changeme',

View File

@ -25,6 +25,7 @@ not uses clasess and uses simple functions.
""" """
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model
from django.db import transaction as tx from django.db import transaction as tx
from django.db import IntegrityError from django.db import IntegrityError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -69,7 +70,7 @@ def is_user_already_registered(*, username:str, email:str) -> (bool, str):
and in case he does whats the duplicated attribute and in case he does whats the duplicated attribute
""" """
user_model = apps.get_model("users", "User") user_model = get_user_model()
if user_model.objects.filter(username=username): if user_model.objects.filter(username=username):
return (True, _("Username is already in use.")) return (True, _("Username is already in use."))
@ -110,7 +111,7 @@ def public_register(username:str, password:str, email:str, full_name:str):
if is_registered: if is_registered:
raise exc.WrongArguments(reason) raise exc.WrongArguments(reason)
user_model = apps.get_model("users", "User") user_model = get_user_model()
user = user_model(username=username, user = user_model(username=username,
email=email, email=email,
full_name=full_name) full_name=full_name)
@ -159,7 +160,7 @@ def private_register_for_new_user(token:str, username:str, email:str,
if is_registered: if is_registered:
raise exc.WrongArguments(reason) raise exc.WrongArguments(reason)
user_model = apps.get_model("users", "User") user_model = get_user_model()
user = user_model(username=username, user = user_model(username=username,
email=email, email=email,
full_name=full_name) full_name=full_name)

View File

@ -14,7 +14,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.auth import get_user_model
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from django.apps import apps from django.apps import apps
@ -47,7 +47,7 @@ def get_user_for_token(token, scope, max_age=None):
except signing.BadSignature: except signing.BadSignature:
raise exc.NotAuthenticated(_("Invalid token")) raise exc.NotAuthenticated(_("Invalid token"))
model_cls = apps.get_model("users", "User") model_cls = get_user_model()
try: try:
user = model_cls.objects.get(pk=data["user_%s_id" % (scope)]) user = model_cls.objects.get(pk=data["user_%s_id" % (scope)])

View File

@ -64,17 +64,17 @@ from django.utils.encoding import is_protected_type
from django.utils.functional import Promise from django.utils.functional import Promise
from django.utils.translation import ugettext from django.utils.translation import ugettext
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.datastructures import SortedDict
from . import ISO_8601 from . import ISO_8601
from .settings import api_settings from .settings import api_settings
from collections import OrderedDict
from decimal import Decimal, DecimalException
import copy import copy
import datetime import datetime
import inspect import inspect
import re import re
import warnings import warnings
from decimal import Decimal, DecimalException
def is_non_str_iterable(obj): def is_non_str_iterable(obj):
@ -255,7 +255,7 @@ class Field(object):
return [self.to_native(item) for item in value] return [self.to_native(item) for item in value]
elif isinstance(value, dict): elif isinstance(value, dict):
# Make sure we preserve field ordering, if it exists # Make sure we preserve field ordering, if it exists
ret = SortedDict() ret = OrderedDict()
for key, val in value.items(): for key, val in value.items():
ret[key] = self.to_native(val) ret[key] = self.to_native(val)
return ret return ret
@ -270,7 +270,7 @@ class Field(object):
return {} return {}
def metadata(self): def metadata(self):
metadata = SortedDict() metadata = OrderedDict()
metadata["type"] = self.type_label metadata["type"] = self.type_label
metadata["required"] = getattr(self, "required", False) metadata["required"] = getattr(self, "required", False)
optional_attrs = ["read_only", "label", "help_text", optional_attrs = ["read_only", "label", "help_text",

View File

@ -53,9 +53,9 @@ from taiga.base import response
from .settings import api_settings from .settings import api_settings
from .utils import get_object_or_404 from .utils import get_object_or_404
from .. import exceptions as exc
from ..decorators import model_pk_lock from ..decorators import model_pk_lock
def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None):
""" """
Given a model instance, and an optional pk and slug field, Given a model instance, and an optional pk and slug field,
@ -243,3 +243,32 @@ class DestroyModelMixin:
obj.delete() obj.delete()
self.post_delete(obj) self.post_delete(obj)
return response.NoContent() return response.NoContent()
class BlockeableModelMixin:
def is_blocked(self, obj):
raise NotImplementedError("is_blocked must be overridden")
def pre_conditions_blocked(self, obj):
#Raises permission exception
if obj is not None and self.is_blocked(obj):
raise exc.Blocked(_("Blocked element"))
class BlockeableSaveMixin(BlockeableModelMixin):
def pre_conditions_on_save(self, obj):
# Called on create and update calls
self.pre_conditions_blocked(obj)
super().pre_conditions_on_save(obj)
class BlockeableDeleteMixin():
def pre_conditions_on_delete(self, obj):
# Called on destroy call
self.pre_conditions_blocked(obj)
super().pre_conditions_on_delete(obj)
class BlockedByProjectMixin(BlockeableSaveMixin, BlockeableDeleteMixin):
def is_blocked(self, obj):
return obj.project is not None and obj.project.blocked_code is not None

View File

@ -20,7 +20,7 @@ import abc
from functools import reduce from functools import reduce
from taiga.base.utils import sequence as sq from taiga.base.utils import sequence as sq
from taiga.permissions.service import user_has_perm, is_project_owner from taiga.permissions.service import user_has_perm, is_project_admin
from django.apps import apps from django.apps import apps
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -206,9 +206,9 @@ class HasMandatoryParam(PermissionComponent):
return False return False
class IsProjectOwner(PermissionComponent): class IsProjectAdmin(PermissionComponent):
def check_permissions(self, request, view, obj=None): def check_permissions(self, request, view, obj=None):
return is_project_owner(request.user, obj) return is_project_admin(request.user, obj)
class IsObjectOwner(PermissionComponent): class IsObjectOwner(PermissionComponent):

View File

@ -59,11 +59,11 @@ from django.core.paginator import Page
from django.db import models from django.db import models
from django.forms import widgets from django.forms import widgets
from django.utils import six from django.utils import six
from django.utils.datastructures import SortedDict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from .settings import api_settings from .settings import api_settings
from collections import OrderedDict
import copy import copy
import datetime import datetime
import inspect import inspect
@ -148,7 +148,7 @@ class DictWithMetadata(dict):
return dict(self) return dict(self)
class SortedDictWithMetadata(SortedDict): class OrderedDictWithMetadata(OrderedDict):
""" """
A sorted dict-like object, that can have additional properties attached. A sorted dict-like object, that can have additional properties attached.
""" """
@ -158,7 +158,7 @@ class SortedDictWithMetadata(SortedDict):
Overriden to remove the metadata from the dict, since it shouldn't be Overriden to remove the metadata from the dict, since it shouldn't be
pickle and may in some instances be unpickleable. pickle and may in some instances be unpickleable.
""" """
return SortedDict(self).__dict__ return OrderedDict(self).__dict__
def _is_protected_type(obj): def _is_protected_type(obj):
@ -194,7 +194,7 @@ def _get_declared_fields(bases, attrs):
if hasattr(base, "base_fields"): if hasattr(base, "base_fields"):
fields = list(base.base_fields.items()) + fields fields = list(base.base_fields.items()) + fields
return SortedDict(fields) return OrderedDict(fields)
class SerializerMetaclass(type): class SerializerMetaclass(type):
@ -222,7 +222,7 @@ class BaseSerializer(WritableField):
pass pass
_options_class = SerializerOptions _options_class = SerializerOptions
_dict_class = SortedDictWithMetadata _dict_class = OrderedDictWithMetadata
def __init__(self, instance=None, data=None, files=None, def __init__(self, instance=None, data=None, files=None,
context=None, partial=False, many=None, context=None, partial=False, many=None,
@ -268,7 +268,7 @@ class BaseSerializer(WritableField):
This will be the set of any explicitly declared fields, This will be the set of any explicitly declared fields,
plus the set of fields returned by get_default_fields(). plus the set of fields returned by get_default_fields().
""" """
ret = SortedDict() ret = OrderedDict()
# Get the explicitly declared fields # Get the explicitly declared fields
base_fields = copy.deepcopy(self.base_fields) base_fields = copy.deepcopy(self.base_fields)
@ -284,7 +284,7 @@ class BaseSerializer(WritableField):
# If "fields" is specified, use those fields, in that order. # If "fields" is specified, use those fields, in that order.
if self.opts.fields: if self.opts.fields:
assert isinstance(self.opts.fields, (list, tuple)), "`fields` must be a list or tuple" assert isinstance(self.opts.fields, (list, tuple)), "`fields` must be a list or tuple"
new = SortedDict() new = OrderedDict()
for key in self.opts.fields: for key in self.opts.fields:
new[key] = ret[key] new[key] = ret[key]
ret = new ret = new
@ -458,7 +458,10 @@ class BaseSerializer(WritableField):
many = hasattr(value, "__iter__") and not isinstance(value, (Page, dict, six.text_type)) many = hasattr(value, "__iter__") and not isinstance(value, (Page, dict, six.text_type))
if many: if many:
return [self.to_native(item) for item in value] try:
return [self.to_native(item) for item in value]
except TypeError:
pass # LazyObject is iterable so we need to catch this
return self.to_native(value) return self.to_native(value)
def field_from_native(self, data, files, field_name, into): def field_from_native(self, data, files, field_name, into):
@ -610,7 +613,10 @@ class BaseSerializer(WritableField):
DeprecationWarning, stacklevel=2) DeprecationWarning, stacklevel=2)
if many: if many:
self._data = [self.to_native(item) for item in obj] try:
self._data = [self.to_native(item) for item in obj]
except TypeError:
self._data = self.to_native(obj) # LazyObject is iterable so we need to catch this
else: else:
self._data = self.to_native(obj) self._data = self.to_native(obj)
@ -645,7 +651,7 @@ class BaseSerializer(WritableField):
Useful for things like responding to OPTIONS requests, or generating Useful for things like responding to OPTIONS requests, or generating
API schemas for auto-documentation. API schemas for auto-documentation.
""" """
return SortedDict( return OrderedDict(
[(field_name, field.metadata()) [(field_name, field.metadata())
for field_name, field in six.iteritems(self.fields)] for field_name, field in six.iteritems(self.fields)]
) )
@ -740,7 +746,7 @@ class ModelSerializer((six.with_metaclass(SerializerMetaclass, BaseSerializer)))
assert cls is not None, \ assert cls is not None, \
"Serializer class '%s' is missing `model` Meta option" % self.__class__.__name__ "Serializer class '%s' is missing `model` Meta option" % self.__class__.__name__
opts = cls._meta.concrete_model._meta opts = cls._meta.concrete_model._meta
ret = SortedDict() ret = OrderedDict()
nested = bool(self.opts.depth) nested = bool(self.opts.depth)
# Deal with adding the primary key field # Deal with adding the primary key field

View File

@ -62,9 +62,10 @@ back to the defaults.
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.utils import importlib
from django.utils import six from django.utils import six
import importlib
from . import ISO_8601 from . import ISO_8601

View File

@ -1,4 +1,3 @@
{% load url from future %}
{% load api %} {% load api %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>

View File

@ -1,4 +1,3 @@
{% load url from future %}
{% load api %} {% load api %}
<html> <html>

View File

@ -45,13 +45,10 @@
Helper classes for parsers. Helper classes for parsers.
""" """
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils.datastructures import SortedDict
from django.utils.functional import Promise from django.utils.functional import Promise
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import force_text from django.utils.encoding import force_text
from taiga.base.api.serializers import DictWithMetadata, SortedDictWithMetadata
import datetime import datetime
import decimal import decimal
import types import types

View File

@ -43,6 +43,8 @@
import json import json
from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
@ -50,7 +52,6 @@ from django.http.response import HttpResponseBase
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.defaults import server_error from django.views.defaults import server_error
from django.views.generic import View from django.views.generic import View
from django.utils.datastructures import SortedDict
from django.utils.encoding import smart_text from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -462,7 +463,7 @@ class APIView(View):
# By default we can't provide any form-like information, however the # By default we can't provide any form-like information, however the
# generic views override this implementation and add additional # generic views override this implementation and add additional
# information for POST and PUT methods, based on the serializer. # information for POST and PUT methods, based on the serializer.
ret = SortedDict() ret = OrderedDict()
ret['name'] = self.get_view_name() ret['name'] = self.get_view_name()
ret['description'] = self.get_view_description() ret['description'] = self.get_view_description()
ret['renders'] = [renderer.media_type for renderer in self.renderer_classes] ret['renders'] = [renderer.media_type for renderer in self.renderer_classes]

View File

@ -187,11 +187,13 @@ class ModelListViewSet(mixins.RetrieveModelMixin,
GenericViewSet): GenericViewSet):
pass pass
class ModelUpdateRetrieveViewSet(mixins.UpdateModelMixin, class ModelUpdateRetrieveViewSet(mixins.UpdateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
GenericViewSet): GenericViewSet):
pass pass
class ModelRetrieveViewSet(mixins.RetrieveModelMixin, class ModelRetrieveViewSet(mixins.RetrieveModelMixin,
GenericViewSet): GenericViewSet):
pass pass

View File

@ -17,12 +17,14 @@
from django.apps import AppConfig from django.apps import AppConfig
from .signals.thumbnails import connect_thumbnail_signals
class BaseAppConfig(AppConfig): class BaseAppConfig(AppConfig):
name = "taiga.base" name = "taiga.base"
verbose_name = "Base App Config" verbose_name = "Base App Config"
def ready(self): def ready(self):
from .signals.thumbnails import connect_thumbnail_signals
from .signals.cleanup_files import connect_cleanup_files_signals
connect_thumbnail_signals() connect_thumbnail_signals()
connect_cleanup_files_signals()

View File

@ -17,7 +17,6 @@
from django_pglocks import advisory_lock from django_pglocks import advisory_lock
def detail_route(methods=['get'], **kwargs): def detail_route(methods=['get'], **kwargs):
""" """
Used to mark a method on a ViewSet that should be routed for detail requests. Used to mark a method on a ViewSet that should be routed for detail requests.

View File

@ -201,6 +201,28 @@ class NotAuthenticated(NotAuthenticated):
pass pass
class Blocked(APIException):
"""
Exception used on blocked projects
"""
status_code = status.HTTP_451_BLOCKED
default_detail = _("Blocked element")
class NotEnoughSlotsForProject(BaseException):
"""
Exception used on import/edition/creation project errors where the user
hasn't slots enough
"""
default_detail = _("No room left for more projects.")
def __init__(self, is_private, total_memberships, detail=None):
self.detail = detail or self.default_detail
self.project_data = {
"is_private": is_private,
"total_memberships": total_memberships
}
def format_exception(exc): def format_exception(exc):
if isinstance(exc.detail, (dict, list, tuple,)): if isinstance(exc.detail, (dict, list, tuple,)):
detail = exc.detail detail = exc.detail
@ -232,6 +254,9 @@ def exception_handler(exc):
headers["WWW-Authenticate"] = exc.auth_header headers["WWW-Authenticate"] = exc.auth_header
if getattr(exc, "wait", None): if getattr(exc, "wait", None):
headers["X-Throttle-Wait-Seconds"] = "%d" % exc.wait headers["X-Throttle-Wait-Seconds"] = "%d" % exc.wait
if getattr(exc, "project_data", None):
headers["Taiga-Info-Project-Memberships"] = exc.project_data["total_memberships"]
headers["Taiga-Info-Project-Is-Private"] = exc.project_data["is_private"]
detail = format_exception(exc) detail = format_exception(exc)
return response.Response(detail, status=exc.status_code, headers=headers) return response.Response(detail, status=exc.status_code, headers=headers)

View File

@ -14,6 +14,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging
from django.apps import apps from django.apps import apps
@ -141,7 +142,7 @@ class PermissionBasedFilterBackend(FilterBackend):
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) |
Q(is_owner=True)) Q(is_admin=True))
projects_list = [membership.project_id for membership in memberships_qs] projects_list = [membership.project_id for membership in memberships_qs]
@ -242,7 +243,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) |
Q(is_owner=True)) Q(is_admin=True))
projects_list = [membership.project_id for membership in memberships_qs] projects_list = [membership.project_id for membership in memberships_qs]
@ -286,7 +287,7 @@ class BaseIsProjectAdminFilterBackend(object):
return [] return []
membership_model = apps.get_model('projects', 'Membership') membership_model = apps.get_model('projects', 'Membership')
memberships_qs = membership_model.objects.filter(user=request.user, is_owner=True) memberships_qs = membership_model.objects.filter(user=request.user, is_admin=True)
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)

View File

@ -19,7 +19,8 @@ import datetime
from optparse import make_option from optparse import make_option
from django.db.models.loading import get_model from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
@ -28,7 +29,6 @@ from taiga.base.mails import mail_builder
from taiga.projects.models import Project, Membership from taiga.projects.models import Project, Membership
from taiga.projects.history.models import HistoryEntry from taiga.projects.history.models import HistoryEntry
from taiga.projects.history.services import get_history_queryset_by_model_instance from taiga.projects.history.services import get_history_queryset_by_model_instance
from taiga.users.models import User
class Command(BaseCommand): class Command(BaseCommand):
@ -50,7 +50,7 @@ class Command(BaseCommand):
# Register email # Register email
context = {"lang": locale, context = {"lang": locale,
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
"cancel_token": "cancel-token"} "cancel_token": "cancel-token"}
email = mail_builder.registered_user(test_email, context) email = mail_builder.registered_user(test_email, context)
@ -58,7 +58,7 @@ class Command(BaseCommand):
# Membership invitation # Membership invitation
membership = Membership.objects.order_by("?").filter(user__isnull=True).first() membership = Membership.objects.order_by("?").filter(user__isnull=True).first()
membership.invited_by = User.objects.all().order_by("?").first() membership.invited_by = get_user_model().objects.all().order_by("?").first()
membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example" membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example"
context = {"lang": locale, "membership": membership} context = {"lang": locale, "membership": membership}
@ -88,19 +88,19 @@ class Command(BaseCommand):
email.send() email.send()
# Password recovery # Password recovery
context = {"lang": locale, "user": User.objects.all().order_by("?").first()} context = {"lang": locale, "user": get_user_model().objects.all().order_by("?").first()}
email = mail_builder.password_recovery(test_email, context) email = mail_builder.password_recovery(test_email, context)
email.send() email.send()
# Change email # Change email
context = {"lang": locale, "user": User.objects.all().order_by("?").first()} context = {"lang": locale, "user": get_user_model().objects.all().order_by("?").first()}
email = mail_builder.change_email(test_email, context) email = mail_builder.change_email(test_email, context)
email.send() email.send()
# Export/Import emails # Export/Import emails
context = { context = {
"lang": locale, "lang": locale,
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(), "project": Project.objects.all().order_by("?").first(),
"error_subject": "Error generating project dump", "error_subject": "Error generating project dump",
"error_message": "Error generating project dump", "error_message": "Error generating project dump",
@ -109,7 +109,7 @@ class Command(BaseCommand):
email.send() email.send()
context = { context = {
"lang": locale, "lang": locale,
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
"error_subject": "Error importing project dump", "error_subject": "Error importing project dump",
"error_message": "Error importing project dump", "error_message": "Error importing project dump",
} }
@ -120,7 +120,7 @@ class Command(BaseCommand):
context = { context = {
"lang": locale, "lang": locale,
"url": "http://dummyurl.com", "url": "http://dummyurl.com",
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(), "project": Project.objects.all().order_by("?").first(),
"deletion_date": deletion_date, "deletion_date": deletion_date,
} }
@ -129,7 +129,7 @@ class Command(BaseCommand):
context = { context = {
"lang": locale, "lang": locale,
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
"project": Project.objects.all().order_by("?").first(), "project": Project.objects.all().order_by("?").first(),
} }
email = mail_builder.load_dump(test_email, context) email = mail_builder.load_dump(test_email, context)
@ -157,13 +157,13 @@ class Command(BaseCommand):
context = { context = {
"lang": locale, "lang": locale,
"project": Project.objects.all().order_by("?").first(), "project": Project.objects.all().order_by("?").first(),
"changer": User.objects.all().order_by("?").first(), "changer": get_user_model().objects.all().order_by("?").first(),
"history_entries": HistoryEntry.objects.all().order_by("?")[0:5], "history_entries": HistoryEntry.objects.all().order_by("?")[0:5],
"user": User.objects.all().order_by("?").first(), "user": get_user_model().objects.all().order_by("?").first(),
} }
for notification_email in notification_emails: for notification_email in notification_emails:
model = get_model(*notification_email[0].split(".")) model = apps.get_model(*notification_email[0].split("."))
snapshot = { snapshot = {
"subject": "Tests subject", "subject": "Tests subject",
"ref": 123123, "ref": 123123,
@ -187,3 +187,38 @@ class Command(BaseCommand):
cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]}) cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]})
email = cls() email = cls()
email.send(test_email, context) email.send(test_email, context)
# Transfer Emails
context = {
"project": Project.objects.all().order_by("?").first(),
"requester": User.objects.all().order_by("?").first(),
}
email = mail_builder.transfer_request(test_email, context)
email.send()
context = {
"project": Project.objects.all().order_by("?").first(),
"receiver": User.objects.all().order_by("?").first(),
"token": "test-token",
"reason": "Test reason"
}
email = mail_builder.transfer_start(test_email, context)
email.send()
context = {
"project": Project.objects.all().order_by("?").first(),
"old_owner": User.objects.all().order_by("?").first(),
"new_owner": User.objects.all().order_by("?").first(),
"reason": "Test reason"
}
email = mail_builder.transfer_accept(test_email, context)
email.send()
context = {
"project": Project.objects.all().order_by("?").first(),
"rejecter": User.objects.all().order_by("?").first(),
"reason": "Test reason"
}
email = mail_builder.transfer_reject(test_email, context)
email.send()

View File

@ -43,9 +43,10 @@
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""The various HTTP responses for use in returning proper HTTP codes.""" """The various HTTP responses for use in returning proper HTTP codes."""
from http.client import responses
from django import http from django import http
from django.core.handlers.wsgi import STATUS_CODE_TEXT
from django.template.response import SimpleTemplateResponse from django.template.response import SimpleTemplateResponse
from django.utils import six from django.utils import six
@ -114,7 +115,7 @@ class Response(SimpleTemplateResponse):
""" """
# TODO: Deprecate and use a template tag instead # TODO: Deprecate and use a template tag instead
# TODO: Status code text for RFC 6585 status codes # TODO: Status code text for RFC 6585 status codes
return STATUS_CODE_TEXT.get(self.status_code, '') return responses.get(self.status_code, '')
def __getstate__(self): def __getstate__(self):
""" """

View File

@ -0,0 +1,97 @@
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 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/>.
from django.apps import apps
from django.db import models, connection
from django.db.utils import DEFAULT_DB_ALIAS, ConnectionHandler
from django.db.models.signals import pre_save, post_delete
import logging
logger = logging.getLogger(__name__)
from django.dispatch import Signal
cleanup_pre_delete = Signal(providing_args=["file"])
cleanup_post_delete = Signal(providing_args=["file"])
def _find_models_with_filefield():
result = []
for model in apps.get_models():
for field in model._meta.fields:
if isinstance(field, models.FileField):
result.append(model)
break
return result
def _delete_file(file_obj):
def delete_from_storage():
try:
cleanup_pre_delete.send(sender=None, file=file_obj)
storage.delete(file_obj.name)
cleanup_post_delete.send(sender=None, file=file_obj)
except Exception:
logger.exception("Unexpected exception while attempting "
"to delete old file '%s'".format(file_obj.name))
storage = file_obj.storage
if storage and storage.exists(file_obj.name):
connection.on_commit(delete_from_storage)
def _get_file_fields(instance):
return filter(
lambda field: isinstance(field, models.FileField),
instance._meta.fields,
)
def remove_files_on_change(sender, instance, **kwargs):
if not instance.pk:
return
try:
old_instance = sender.objects.get(pk=instance.pk)
except instance.DoesNotExist:
return
for field in _get_file_fields(instance):
old_file = getattr(old_instance, field.name)
new_file = getattr(instance, field.name)
if old_file and old_file != new_file:
_delete_file(old_file)
def remove_files_on_delete(sender, instance, **kwargs):
for field in _get_file_fields(instance):
file_to_delete = getattr(instance, field.name)
if file_to_delete:
_delete_file(file_to_delete)
def connect_cleanup_files_signals():
connections = ConnectionHandler()
backend = connections[DEFAULT_DB_ALIAS]
for model in _find_models_with_filefield():
pre_save.connect(remove_files_on_change, sender=model)
post_delete.connect(remove_files_on_delete, sender=model)

View File

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

View File

@ -104,6 +104,7 @@ HTTP_417_EXPECTATION_FAILED = 417
HTTP_428_PRECONDITION_REQUIRED = 428 HTTP_428_PRECONDITION_REQUIRED = 428
HTTP_429_TOO_MANY_REQUESTS = 429 HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
HTTP_451_BLOCKED = 451
HTTP_500_INTERNAL_SERVER_ERROR = 500 HTTP_500_INTERNAL_SERVER_ERROR = 500
HTTP_501_NOT_IMPLEMENTED = 501 HTTP_501_NOT_IMPLEMENTED = 501
HTTP_502_BAD_GATEWAY = 502 HTTP_502_BAD_GATEWAY = 502

View File

@ -1,20 +0,0 @@
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 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/>.
from taiga.base import routers
router = routers.DefaultRouter(trailing_slash=False)

View File

@ -19,15 +19,16 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models import signals from django.db.models import signals
from . import signal_handlers as handlers
def connect_events_signals(): def connect_events_signals():
from . import signal_handlers as handlers
signals.post_save.connect(handlers.on_save_any_model, dispatch_uid="events_change") signals.post_save.connect(handlers.on_save_any_model, dispatch_uid="events_change")
signals.post_delete.connect(handlers.on_delete_any_model, dispatch_uid="events_delete") signals.post_delete.connect(handlers.on_delete_any_model, dispatch_uid="events_delete")
def disconnect_events_signals(): def disconnect_events_signals():
from . import signal_handlers as handlers
signals.post_save.disconnect(dispatch_uid="events_change") signals.post_save.disconnect(dispatch_uid="events_change")
signals.post_delete.disconnect(dispatch_uid="events_delete") signals.post_delete.disconnect(dispatch_uid="events_delete")

View File

@ -36,6 +36,7 @@ from taiga.projects.models import Project, Membership
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task from taiga.projects.tasks.models import Task
from taiga.projects.serializers import ProjectSerializer from taiga.projects.serializers import ProjectSerializer
from taiga.users import services as users_service
from . import mixins from . import mixins
from . import serializers from . import serializers
@ -90,6 +91,14 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
data = request.DATA.copy() data = request.DATA.copy()
data['owner'] = data.get('owner', request.user.email) data['owner'] = data.get('owner', request.user.email)
is_private = data.get('is_private', False)
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
self.request.user,
Project(is_private=is_private, id=None)
)
if not enough_slots:
raise exc.NotEnoughSlotsForProject(is_private, 1, not_enough_slots_error)
# Create Project # Create Project
project_serialized = service.store_project(data) project_serialized = service.store_project(data)
@ -106,11 +115,19 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
# Create memberships # Create memberships
if "memberships" in data: if "memberships" in data:
members = len([m for m in data.get("memberships", []) if m.get("email", None) != data["owner"]])
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
self.request.user,
Project(is_private=is_private, id=None),
members
)
if not enough_slots:
raise exc.NotEnoughSlotsForProject(is_private, max(members, 1), not_enough_slots_error)
service.store_memberships(project_serialized.object, data) service.store_memberships(project_serialized.object, data)
try: try:
owner_membership = project_serialized.object.memberships.get(user=project_serialized.object.owner) owner_membership = project_serialized.object.memberships.get(user=project_serialized.object.owner)
owner_membership.is_owner = True owner_membership.is_admin = True
owner_membership.save() owner_membership.save()
except Membership.DoesNotExist: except Membership.DoesNotExist:
Membership.objects.create( Membership.objects.create(
@ -118,7 +135,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
email=project_serialized.object.owner.email, email=project_serialized.object.owner.email,
user=project_serialized.object.owner, user=project_serialized.object.owner,
role=project_serialized.object.roles.all().first(), role=project_serialized.object.roles.all().first(),
is_owner=True is_admin=True
) )
# Create project values choicess # Create project values choicess
@ -202,6 +219,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
try: try:
dump = json.load(reader(dump)) dump = json.load(reader(dump))
is_private = dump.get("is_private", False)
except Exception: except Exception:
raise exc.WrongArguments(_("Invalid dump format")) raise exc.WrongArguments(_("Invalid dump format"))
@ -209,11 +227,23 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
if slug is not None and Project.objects.filter(slug=slug).exists(): if slug is not None and Project.objects.filter(slug=slug).exists():
del dump['slug'] del dump['slug']
user = request.user
dump['owner'] = user.email
members = len([m for m in dump.get("memberships", []) if m.get("email", None) != dump["owner"]])
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
user,
Project(is_private=is_private, id=None),
members
)
if not enough_slots:
raise exc.NotEnoughSlotsForProject(is_private, max(members, 1), not_enough_slots_error)
if settings.CELERY_ENABLED: if settings.CELERY_ENABLED:
task = tasks.load_project_dump.delay(request.user, dump) task = tasks.load_project_dump.delay(user, dump)
return response.Accepted({"import_id": task.id}) return response.Accepted({"import_id": task.id})
project = dump_service.dict_to_project(dump, request.user.email) project = dump_service.dict_to_project(dump, request.user)
response_data = ProjectSerializer(project).data response_data = ProjectSerializer(project).data
return response.Created(response_data) return response.Created(response_data)

View File

@ -17,7 +17,8 @@
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from taiga.projects.models import Membership from taiga.projects.models import Membership, Project
from taiga.users import services as users_service
from . import serializers from . import serializers
from . import service from . import service
@ -89,7 +90,15 @@ def store_tags_colors(project, data):
def dict_to_project(data, owner=None): def dict_to_project(data, owner=None):
if owner: if owner:
data["owner"] = owner data["owner"] = owner.email
members = len([m for m in data.get("memberships", []) if m.get("email", None) != data["owner"]])
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
owner,
Project(is_private=data.get("is_private", False), id=None),
members
)
if not enough_slots:
raise TaigaImportError(not_enough_slots_error)
project_serialized = service.store_project(data) project_serialized = service.store_project(data)
@ -138,7 +147,7 @@ def dict_to_project(data, owner=None):
email=proj.owner.email, email=proj.owner.email,
user=proj.owner, user=proj.owner,
role=proj.roles.all().first(), role=proj.roles.all().first(),
is_owner=True is_admin=True
) )
if service.get_errors(clear=False): if service.get_errors(clear=False):

View File

@ -25,6 +25,7 @@ from taiga.projects.models import Project
from taiga.export_import.renderers import ExportRenderer from taiga.export_import.renderers import ExportRenderer
from taiga.export_import.dump_service import dict_to_project, TaigaImportError from taiga.export_import.dump_service import dict_to_project, TaigaImportError
from taiga.export_import.service import get_errors from taiga.export_import.service import get_errors
from taiga.users.models import User
class Command(BaseCommand): class Command(BaseCommand):
@ -58,7 +59,9 @@ class Command(BaseCommand):
except Project.DoesNotExist: except Project.DoesNotExist:
pass pass
signals.post_delete.receivers = receivers_back signals.post_delete.receivers = receivers_back
dict_to_project(data, args[1])
user = User.objects.get(email=args[1])
dict_to_project(data, user)
except TaigaImportError as e: except TaigaImportError as e:
print("ERROR:", end=" ") print("ERROR:", end=" ")
print(e.message) print(e.message)

View File

@ -17,11 +17,11 @@
from taiga.base.api.permissions import (TaigaResourcePermission, from taiga.base.api.permissions import (TaigaResourcePermission,
IsProjectOwner, IsAuthenticated) IsProjectAdmin, IsAuthenticated)
class ImportExportPermission(TaigaResourcePermission): class ImportExportPermission(TaigaResourcePermission):
import_project_perms = IsAuthenticated() import_project_perms = IsAuthenticated()
import_item_perms = IsProjectOwner() import_item_perms = IsProjectAdmin()
export_project_perms = IsProjectOwner() export_project_perms = IsProjectAdmin()
load_dump_perms = IsAuthenticated() load_dump_perms = IsAuthenticated()

View File

@ -21,6 +21,7 @@ import os
from collections import OrderedDict from collections import OrderedDict
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -29,10 +30,10 @@ from django.utils.translation import ugettext as _
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from taiga import mdrender
from taiga.base.api import serializers from taiga.base.api import serializers
from taiga.base.fields import JsonField, PgArrayField from taiga.base.fields import JsonField, PgArrayField
from taiga.mdrender.service import render as mdrender
from taiga.projects import models as projects_models from taiga.projects import models as projects_models
from taiga.projects.custom_attributes import models as custom_attributes_models from taiga.projects.custom_attributes import models as custom_attributes_models
from taiga.projects.userstories import models as userstories_models from taiga.projects.userstories import models as userstories_models
@ -154,7 +155,7 @@ class CommentField(serializers.WritableField):
def field_from_native(self, data, files, field_name, into): def field_from_native(self, data, files, field_name, into):
super().field_from_native(data, files, field_name, into) super().field_from_native(data, files, field_name, into)
into["comment_html"] = mdrender.render(self.context['project'], data.get("comment", "")) into["comment_html"] = mdrender(self.context['project'], data.get("comment", ""))
class ProjectRelatedField(serializers.RelatedField): class ProjectRelatedField(serializers.RelatedField):
@ -263,7 +264,7 @@ class WatcheableObjectModelSerializer(serializers.ModelSerializer):
adding_watcher_emails = list(new_watcher_emails.difference(old_watcher_emails)) adding_watcher_emails = list(new_watcher_emails.difference(old_watcher_emails))
removing_watcher_emails = list(old_watcher_emails.difference(new_watcher_emails)) removing_watcher_emails = list(old_watcher_emails.difference(new_watcher_emails))
User = apps.get_model("users", "User") User = get_user_model()
adding_users = User.objects.filter(email__in=adding_watcher_emails) adding_users = User.objects.filter(email__in=adding_watcher_emails)
removing_users = User.objects.filter(email__in=removing_watcher_emails) removing_users = User.objects.filter(email__in=removing_watcher_emails)

View File

@ -79,7 +79,7 @@ def delete_project_dump(project_id, project_slug, task_id):
@app.task @app.task
def load_project_dump(user, dump): def load_project_dump(user, dump):
try: try:
project = dict_to_project(dump, user.email) project = dict_to_project(dump, user)
except Exception: except Exception:
ctx = { ctx = {
"user": user, "user": user,

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %} {% extends "emails/base-body-html.jinja" %}
{% block body %} {% block body %}
{% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} {% trans user=user.get_full_name(), project=project.name, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
<h1>Project dump generated</h1> <h1>Project dump generated</h1>
<p>Hello {{ user }},</p> <p>Hello {{ user }},</p>
<h3>Your dump from project {{ project }} has been correctly generated.</h3> <h3>Your dump from project {{ project }} has been correctly generated.</h3>

View File

@ -1,4 +1,4 @@
{% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %} {% trans user=user.get_full_name(), project=project.name, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
Hello {{ user }}, Hello {{ user }},
Your dump from project {{ project }} has been correctly generated. You can download it here: Your dump from project {{ project }} has been correctly generated. You can download it here:

View File

@ -1 +1 @@
{% trans project=project.name|safe %}[{{ project }}] Your project dump has been generated{% endtrans %} {% trans project=project.name %}[{{ project }}] Your project dump has been generated{% endtrans %}

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %} {% extends "emails/base-body-html.jinja" %}
{% block body %} {% block body %}
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email"), project=project.name|safe %} {% trans user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), project=project.name %}
<h1>{{ error_message }}</h1> <h1>{{ error_message }}</h1>
<p>Hello {{ user }},</p> <p>Hello {{ user }},</p>
<p>Your project {{ project }} has not been exported correctly.</p> <p>Your project {{ project }} has not been exported correctly.</p>

View File

@ -1,4 +1,4 @@
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email"), project=project.name|safe %} {% trans user=user.get_full_name(), error_message=error_message, support_email=sr("support.email"), project=project.name %}
Hello {{ user }}, Hello {{ user }},
{{ error_message }} {{ error_message }}

View File

@ -1 +1 @@
{% trans error_subject=error_subject, project=project.name|safe %}[{{ project }}] {{ error_subject }}{% endtrans %} {% trans error_subject=error_subject, project=project.name %}[{{ project }}] {{ error_subject }}{% endtrans %}

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %} {% extends "emails/base-body-html.jinja" %}
{% block body %} {% block body %}
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email") %} {% trans user=user.get_full_name(), error_message=error_message, support_email=sr("support.email") %}
<h1>{{ error_message }}</h1> <h1>{{ error_message }}</h1>
<p>Hello {{ user }},</p> <p>Hello {{ user }},</p>
<p>Your project has not been importer correctly.</p> <p>Your project has not been importer correctly.</p>

View File

@ -1,4 +1,4 @@
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email") %} {% trans user=user.get_full_name(), error_message=error_message, support_email=sr("support.email") %}
Hello {{ user }}, Hello {{ user }},
{{ error_message }} {{ error_message }}

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %} {% extends "emails/base-body-html.jinja" %}
{% block body %} {% block body %}
{% trans user=user.get_full_name()|safe, url=resolve_front_url("project", project.slug), project=project.name|safe %} {% trans user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %}
<h1>Project dump imported</h1> <h1>Project dump imported</h1>
<p>Hello {{ user }},</p> <p>Hello {{ user }},</p>
<h3>Your project dump has been correctly imported.</h3> <h3>Your project dump has been correctly imported.</h3>

View File

@ -1,4 +1,4 @@
{% trans user=user.get_full_name()|safe, url=resolve_front_url("project", project.slug), project=project.name|safe %} {% trans user=user.get_full_name(), url=resolve_front_url("project", project.slug), project=project.name %}
Hello {{ user }}, Hello {{ user }},
Your project dump has been correctly imported. Your project dump has been correctly imported.

View File

@ -1 +1 @@
{% trans project=project.name|safe %}[{{ project }}] Your project dump has been imported{% endtrans %} {% trans project=project.name %}[{{ project }}] Your project dump has been imported{% endtrans %}

View File

@ -20,8 +20,6 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from .routers import router
class FeedbackAppConfig(AppConfig): class FeedbackAppConfig(AppConfig):
name = "taiga.feedback" name = "taiga.feedback"
@ -30,4 +28,5 @@ class FeedbackAppConfig(AppConfig):
def ready(self): def ready(self):
if settings.FEEDBACK_ENABLED: if settings.FEEDBACK_ENABLED:
from taiga.urls import urlpatterns from taiga.urls import urlpatterns
from .routers import router
urlpatterns.append(url(r'^api/v1/', include(router.urls))) urlpatterns.append(url(r'^api/v1/', include(router.urls)))

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %} {% extends "emails/base-body-html.jinja" %}
{% block body %} {% block body %}
{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email %} {% trans full_name=feedback_entry.full_name, email=feedback_entry.email %}
<h1>Feedback</h1> <h1>Feedback</h1>
<p>Taiga has received feedback from {{ full_name }} <{{ email }}></p> <p>Taiga has received feedback from {{ full_name }} <{{ email }}></p>
{% endtrans %} {% endtrans %}

View File

@ -1,4 +1,4 @@
{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email, comment=feedback_entry.comment %}--------- {% trans full_name=feedback_entry.full_name, email=feedback_entry.email, comment=feedback_entry.comment %}---------
- From: {{ full_name }} <{{ email }}> - From: {{ full_name }} <{{ email }}>
--------- ---------
- Comment: - Comment:

View File

@ -1,3 +1,3 @@
{% trans full_name=feedback_entry.full_name|safe, email=feedback_entry.email %} {% trans full_name=feedback_entry.full_name, email=feedback_entry.email %}
[Taiga] Feedback from {{ full_name }} <{{ email }}> [Taiga] Feedback from {{ full_name }} <{{ email }}>
{% endtrans %} {% endtrans %}

View File

@ -32,6 +32,9 @@ class IssuesSitemap(Sitemap):
Q(project__is_private=True, Q(project__is_private=True,
project__anon_permissions__contains=["view_issues"])) project__anon_permissions__contains=["view_issues"]))
# Exclude blocked projects
queryset = queryset.filter(project__blocked_code__isnull=True)
# Project data is needed # Project data is needed
queryset = queryset.select_related("project") queryset = queryset.select_related("project")

View File

@ -34,6 +34,9 @@ class MilestonesSitemap(Sitemap):
"view_us", "view_us",
"view_tasks"])) "view_tasks"]))
# Exclude blocked projects
queryset = queryset.filter(project__blocked_code__isnull=True)
# Project data is needed # Project data is needed
queryset = queryset.select_related("project") queryset = queryset.select_related("project")

View File

@ -32,6 +32,9 @@ class ProjectsSitemap(Sitemap):
Q(is_private=True, Q(is_private=True,
anon_permissions__contains=["view_project"])) anon_permissions__contains=["view_project"]))
# Exclude blocked projects
queryset = queryset.filter(blocked_code__isnull=True)
return queryset return queryset
def location(self, obj): def location(self, obj):

View File

@ -32,6 +32,9 @@ class TasksSitemap(Sitemap):
Q(project__is_private=True, Q(project__is_private=True,
project__anon_permissions__contains=["view_tasks"])) project__anon_permissions__contains=["view_tasks"]))
# Exclude blocked projects
queryset = queryset.filter(project__blocked_code__isnull=True)
# Project data is needed # Project data is needed
queryset = queryset.select_related("project") queryset = queryset.select_related("project")

View File

@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model
from taiga.front.templatetags.functions import resolve from taiga.front.templatetags.functions import resolve
@ -24,7 +25,7 @@ from .base import Sitemap
class UsersSitemap(Sitemap): class UsersSitemap(Sitemap):
def items(self): def items(self):
user_model = apps.get_model("users", "User") user_model = get_user_model()
# Only active users and not system users # Only active users and not system users
queryset = user_model.objects.filter(is_active=True, queryset = user_model.objects.filter(is_active=True,

View File

@ -32,6 +32,9 @@ class UserStoriesSitemap(Sitemap):
Q(project__is_private=True, Q(project__is_private=True,
project__anon_permissions__contains=["view_us"])) project__anon_permissions__contains=["view_us"]))
# Exclude blocked projects
queryset = queryset.filter(project__blocked_code__isnull=True)
# Project data is needed # Project data is needed
queryset = queryset.select_related("project") queryset = queryset.select_related("project")

View File

@ -32,6 +32,9 @@ class WikiPagesSitemap(Sitemap):
Q(project__is_private=True, Q(project__is_private=True,
project__anon_permissions__contains=["view_wiki_pages"])) project__anon_permissions__contains=["view_wiki_pages"]))
# Exclude blocked projects
queryset = queryset.filter(project__blocked_code__isnull=True)
# Exclude wiki pages from projects without wiki section enabled # Exclude wiki pages from projects without wiki section enabled
queryset = queryset.exclude(project__is_wiki_activated=False) queryset = queryset.exclude(project__is_wiki_activated=False)

View File

@ -46,6 +46,7 @@ urls = {
"team": "/project/{0}/team/", # project.slug "team": "/project/{0}/team/", # project.slug
"project-admin": "/project/{0}/admin/project-profile/details", # project.slug "project-transfer": "/project/{0}/transfer/{1}", # project.slug, project.transfer_token
}
"project-admin": "/login?next=/project/{0}/admin/project-profile/details", # project.slug
}

View File

@ -64,6 +64,9 @@ class BaseWebhookApiViewSet(GenericViewSet):
if not self._validate_signature(project, request): if not self._validate_signature(project, request):
raise exc.BadRequest(_("Bad signature")) raise exc.BadRequest(_("Bad signature"))
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
event_name = self._get_event_name(request) event_name = self._get_event_name(request)
payload = self._get_payload(request) payload = self._get_payload(request)

View File

@ -40,19 +40,16 @@ class PushEventHook(BaseEventHook):
changes = self.payload.get("push", {}).get('changes', []) changes = self.payload.get("push", {}).get('changes', [])
for change in filter(None, changes): for change in filter(None, changes):
new = change.get("new", None) commits = change.get("commits", [])
if not new: if not commits:
continue continue
target = new.get("target", None) for commit in commits:
if not target: message = commit.get("message", None)
continue if not message:
continue
message = target.get("message", None) self._process_message(message, None)
if not message:
continue
self._process_message(message, None)
def _process_message(self, message, bitbucket_user): def _process_message(self, message, bitbucket_user):
""" """

View File

@ -17,10 +17,10 @@
import uuid import uuid
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from taiga.users.models import User
from taiga.base.utils.urls import get_absolute_url from taiga.base.utils.urls import get_absolute_url
@ -43,4 +43,4 @@ def get_or_generate_config(project):
def get_bitbucket_user(user_id): def get_bitbucket_user(user_id):
return User.objects.get(is_system=True, username__startswith="bitbucket") return get_user_model().objects.get(is_system=True, username__startswith="bitbucket")

View File

@ -17,9 +17,9 @@
import uuid import uuid
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from taiga.users.models import User
from taiga.users.models import AuthData from taiga.users.models import AuthData
from taiga.base.utils.urls import get_absolute_url from taiga.base.utils.urls import get_absolute_url
@ -49,6 +49,6 @@ def get_github_user(github_id):
pass pass
if user is None: if user is None:
user = User.objects.get(is_system=True, username__startswith="github") user = get_user_model().objects.get(is_system=True, username__startswith="github")
return user return user

View File

@ -17,10 +17,10 @@
import uuid import uuid
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from taiga.users.models import User
from taiga.base.utils.urls import get_absolute_url from taiga.base.utils.urls import get_absolute_url
@ -47,11 +47,11 @@ def get_gitlab_user(user_email):
if user_email: if user_email:
try: try:
user = User.objects.get(email=user_email) user = get_user_model().objects.get(email=user_email)
except User.DoesNotExist: except get_user_model().DoesNotExist:
pass pass
if user is None: if user is None:
user = User.objects.get(is_system=True, username__startswith="gitlab") user = get_user_model().objects.get(is_system=True, username__startswith="gitlab")
return user return user

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
# Copyright (C) 2014-2016 Andrey Antukh <niwi@niwi.nz>
# Copyright (C) 2014-2016 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014-2016 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014-2016 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/>.
from .service import *

View File

@ -22,13 +22,12 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
from django.contrib.auth import get_user_model
from markdown.extensions import Extension from markdown.extensions import Extension
from markdown.inlinepatterns import Pattern from markdown.inlinepatterns import Pattern
from markdown.util import etree, AtomicString from markdown.util import etree, AtomicString
from taiga.users.models import User
class MentionsExtension(Extension): class MentionsExtension(Extension):
def extendMarkdown(self, md, md_globals): def extendMarkdown(self, md, md_globals):
@ -43,8 +42,8 @@ class MentionsPattern(Pattern):
username = m.group(3) username = m.group(3)
try: try:
user = User.objects.get(username=username) user = get_user_model().objects.get(username=username)
except User.DoesNotExist: except get_user_model().DoesNotExist:
return "@{}".format(username) return "@{}".format(username)
url = "/profile/{}".format(username) url = "/profile/{}".format(username)

View File

@ -7,85 +7,85 @@
# it under the terms of the GNU Affero General Public License as # it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the # published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version. # License, or (at your option) any later version.
# #
# This program is distributed in the hope that it will be useful, # This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of # but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details. # GNU Affero General Public License for more details.
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from markdown import Extension from markdown import Extension
from markdown.inlinepatterns import Pattern from markdown.inlinepatterns import Pattern
from markdown.treeprocessors import Treeprocessor from markdown.treeprocessors import Treeprocessor
from markdown.util import etree from markdown.util import etree
from taiga.front.templatetags.functions import resolve from taiga.front.templatetags.functions import resolve
from taiga.base.utils.slug import slugify from taiga.base.utils.slug import slugify
import re import re
class WikiLinkExtension(Extension): class WikiLinkExtension(Extension):
def __init__(self, project, *args, **kwargs): def __init__(self, project, *args, **kwargs):
self.project = project self.project = project
return super().__init__(*args, **kwargs) return super().__init__(*args, **kwargs)
def extendMarkdown(self, md, md_globals): def extendMarkdown(self, md, md_globals):
WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]" WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]"
md.inlinePatterns.add("wikilinks", md.inlinePatterns.add("wikilinks",
WikiLinksPattern(md, WIKILINK_RE, self.project), WikiLinksPattern(md, WIKILINK_RE, self.project),
"<not_strong") "<not_strong")
md.treeprocessors.add("relative_to_absolute_links", md.treeprocessors.add("relative_to_absolute_links",
RelativeLinksTreeprocessor(md, self.project), RelativeLinksTreeprocessor(md, self.project),
"<prettify") "<prettify")
class WikiLinksPattern(Pattern): class WikiLinksPattern(Pattern):
def __init__(self, md, pattern, project): def __init__(self, md, pattern, project):
self.project = project self.project = project
self.md = md self.md = md
super().__init__(pattern) super().__init__(pattern)
def handleMatch(self, m): def handleMatch(self, m):
label = m.group(2).strip() label = m.group(2).strip()
url = resolve("wiki", self.project.slug, slugify(label)) url = resolve("wiki", self.project.slug, slugify(label))
if m.group(3): if m.group(3):
title = m.group(3).strip()[1:] title = m.group(3).strip()[1:]
else: else:
title = label title = label
a = etree.Element("a") a = etree.Element("a")
a.text = title a.text = title
a.set("href", url) a.set("href", url)
a.set("title", title) a.set("title", title)
a.set("class", "reference wiki") a.set("class", "reference wiki")
return a return a
SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$") SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
class RelativeLinksTreeprocessor(Treeprocessor): class RelativeLinksTreeprocessor(Treeprocessor):
def __init__(self, md, project): def __init__(self, md, project):
self.project = project self.project = project
super().__init__(md) super().__init__(md)
def run(self, root): def run(self, root):
links = root.getiterator("a") links = root.getiterator("a")
for a in links: for a in links:
href = a.get("href", "") href = a.get("href", "")
if SLUG_RE.search(href): if SLUG_RE.search(href):
# [wiki](wiki_page) -> <a href="FRONT_HOST/.../wiki/wiki_page" ... # [wiki](wiki_page) -> <a href="FRONT_HOST/.../wiki/wiki_page" ...
url = resolve("wiki", self.project.slug, href) url = resolve("wiki", self.project.slug, href)
a.set("href", url) a.set("href", url)
a.set("class", "reference wiki") a.set("class", "reference wiki")
elif href and href[0] == "/": elif href and href[0] == "/":
# [some link](/some/link) -> <a href="FRONT_HOST/some/link" ... # [some link](/some/link) -> <a href="FRONT_HOST/some/link" ...
url = "{}{}".format(resolve("home"), href[1:]) url = "{}{}".format(resolve("home"), href[1:])
a.set("href", url) a.set("href", url)

View File

@ -144,4 +144,5 @@ def get_diff_of_htmls(html1, html2):
diffutil.diff_cleanupSemantic(diffs) diffutil.diff_cleanupSemantic(diffs)
return diffutil.diff_pretty_html(diffs) return diffutil.diff_pretty_html(diffs)
__all__ = ["render", "get_diff_of_htmls", "render_and_extract"] __all__ = ["render", "get_diff_of_htmls", "render_and_extract"]

View File

@ -82,7 +82,7 @@ MEMBERS_PERMISSIONS = [
('delete_wiki_link', _('Delete wiki link')), ('delete_wiki_link', _('Delete wiki link')),
] ]
OWNERS_PERMISSIONS = [ ADMINS_PERMISSIONS = [
('modify_project', _('Modify project')), ('modify_project', _('Modify project')),
('add_member', _('Add member')), ('add_member', _('Add member')),
('remove_member', _('Remove member')), ('remove_member', _('Remove member')),

View File

@ -16,12 +16,11 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .permissions import OWNERS_PERMISSIONS, MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from .permissions import ADMINS_PERMISSIONS, MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
from django.apps import apps from django.apps import apps
def _get_user_project_membership(user, project): def _get_user_project_membership(user, project):
Membership = apps.get_model("projects", "Membership")
if user.is_anonymous(): if user.is_anonymous():
return None return None
@ -39,10 +38,17 @@ def _get_object_project(obj):
def is_project_owner(user, obj): def is_project_owner(user, obj):
""" project = _get_object_project(obj)
The owner attribute of a project is just an historical reference if project is None:
""" return False
if user.id == project.owner.id:
return True
return False
def is_project_admin(user, obj):
if user.is_superuser: if user.is_superuser:
return True return True
@ -51,7 +57,7 @@ def is_project_owner(user, obj):
return False return False
membership = _get_user_project_membership(user, project) membership = _get_user_project_membership(user, project)
if membership and membership.is_owner: if membership and membership.is_admin:
return True return True
return False return False
@ -79,43 +85,41 @@ def _get_membership_permissions(membership):
def get_user_project_permissions(user, project): def get_user_project_permissions(user, project):
membership = _get_user_project_membership(user, project) membership = _get_user_project_membership(user, project)
if user.is_superuser: if user.is_superuser:
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS)) admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS))
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS)) public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS))
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
elif membership: elif membership:
if membership.is_owner: if membership.is_admin:
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS)) admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS))
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
else: else:
owner_permissions = [] admins_permissions = []
members_permissions = [] members_permissions = []
members_permissions = members_permissions + _get_membership_permissions(membership) members_permissions = members_permissions + _get_membership_permissions(membership)
public_permissions = project.public_permissions if project.public_permissions is not None else [] public_permissions = project.public_permissions if project.public_permissions is not None else []
anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
elif user.is_authenticated(): elif user.is_authenticated():
owner_permissions = [] admins_permissions = []
members_permissions = [] members_permissions = []
public_permissions = project.public_permissions if project.public_permissions is not None else [] public_permissions = project.public_permissions if project.public_permissions is not None else []
anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
else: else:
owner_permissions = [] admins_permissions = []
members_permissions = [] members_permissions = []
public_permissions = [] public_permissions = []
anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
return set(owner_permissions + members_permissions + public_permissions + anon_permissions) return set(admins_permissions + members_permissions + public_permissions + anon_permissions)
def set_base_permissions_for_project(project): def set_base_permissions_for_project(project):
if project.is_private: if project.is_private:
project.anon_permissions = [] project.anon_permissions = []
project.public_permissions = [] project.public_permissions = []
else: else:
""" # If a project is public anonymous and registered users should have at
If a project is public anonymous and registered users should have at least visualization permissions # least visualization permissions.
"""
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
project.anon_permissions = list(set((project.anon_permissions or []) + anon_permissions)) project.anon_permissions = list(set((project.anon_permissions or []) + anon_permissions))
project.public_permissions = list(set((project.public_permissions or []) + anon_permissions)) project.public_permissions = list(set((project.public_permissions or []) + anon_permissions))

View File

@ -26,6 +26,7 @@ from django.db.models.functions import Coalesce
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils import timezone from django.utils import timezone
from django.http import Http404
from taiga.base import filters from taiga.base import filters
from taiga.base import response from taiga.base import response
@ -33,6 +34,7 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route from taiga.base.decorators import list_route
from taiga.base.decorators import detail_route from taiga.base.decorators import detail_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin, BlockeableSaveMixin, BlockeableDeleteMixin
from taiga.base.api.permissions import AllowAnyPermission from taiga.base.api.permissions import AllowAnyPermission
from taiga.base.api.utils import get_object_or_404 from taiga.base.api.utils import get_object_or_404
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
@ -50,6 +52,7 @@ from taiga.projects.tasks.models import Task
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin from taiga.projects.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
from taiga.permissions import service as permissions_service from taiga.permissions import service as permissions_service
from taiga.users import services as users_service
from . import filters as project_filters from . import filters as project_filters
from . import models from . import models
@ -61,7 +64,9 @@ from . import services
###################################################### ######################################################
## Project ## Project
###################################################### ######################################################
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet): class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
BlockeableSaveMixin, BlockeableDeleteMixin, ModelCrudViewSet):
queryset = models.Project.objects.all() queryset = models.Project.objects.all()
serializer_class = serializers.ProjectDetailSerializer serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer admin_serializer_class = serializers.ProjectDetailAdminSerializer
@ -88,6 +93,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
"total_activity_last_month", "total_activity_last_month",
"total_activity_last_year") "total_activity_last_year")
def is_blocked(self, obj):
return obj.blocked_code is not None
def _get_order_by_field_name(self): def _get_order_by_field_name(self):
order_by_query_param = project_filters.CanViewProjectObjFilterBackend.order_by_query_param order_by_query_param = project_filters.CanViewProjectObjFilterBackend.order_by_query_param
order_by = self.request.QUERY_PARAMS.get(order_by_query_param, None) order_by = self.request.QUERY_PARAMS.get(order_by_query_param, None)
@ -97,9 +105,11 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
qs = qs.select_related("owner")
# Prefetch doesn"t work correctly if then if the field is filtered later (it generates more queries) # Prefetch doesn"t work correctly if then if the field is filtered later (it generates more queries)
# so we add some custom prefetching # so we add some custom prefetching
qs = qs.prefetch_related("members") qs = qs.prefetch_related("members")
qs = qs.prefetch_related("memberships")
qs = qs.prefetch_related(Prefetch("notify_policies", qs = qs.prefetch_related(Prefetch("notify_policies",
NotifyPolicy.objects.exclude(notify_level=NotifyLevel.none), to_attr="valid_notify_policies")) NotifyPolicy.objects.exclude(notify_level=NotifyLevel.none), to_attr="valid_notify_policies"))
@ -137,7 +147,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
else: else:
project = self.get_object() project = self.get_object()
if permissions_service.is_project_owner(self.request.user, project): if permissions_service.is_project_admin(self.request.user, project):
serializer_class = self.admin_serializer_class serializer_class = self.admin_serializer_class
return serializer_class return serializer_class
@ -158,6 +168,8 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
except Exception: except Exception:
raise exc.WrongArguments(_("Invalid image format")) raise exc.WrongArguments(_("Invalid image format"))
self.pre_conditions_on_save(self.object)
self.object.logo = logo self.object.logo = logo
self.object.save(update_fields=["logo"]) self.object.save(update_fields=["logo"])
@ -171,7 +183,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
""" """
self.object = get_object_or_404(self.get_queryset(), **kwargs) self.object = get_object_or_404(self.get_queryset(), **kwargs)
self.check_permissions(request, "remove_logo", self.object) self.check_permissions(request, "remove_logo", self.object)
self.pre_conditions_on_save(self.object)
self.object.logo = None self.object.logo = None
self.object.save(update_fields=["logo"]) self.object.save(update_fields=["logo"])
@ -182,6 +194,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def watch(self, request, pk=None): def watch(self, request, pk=None):
project = self.get_object() project = self.get_object()
self.check_permissions(request, "watch", project) self.check_permissions(request, "watch", project)
self.pre_conditions_on_save(project)
notify_level = request.DATA.get("notify_level", NotifyLevel.involved) notify_level = request.DATA.get("notify_level", NotifyLevel.involved)
project.add_watcher(self.request.user, notify_level=notify_level) project.add_watcher(self.request.user, notify_level=notify_level)
return response.Ok() return response.Ok()
@ -190,6 +203,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def unwatch(self, request, pk=None): def unwatch(self, request, pk=None):
project = self.get_object() project = self.get_object()
self.check_permissions(request, "unwatch", project) self.check_permissions(request, "unwatch", project)
self.pre_conditions_on_save(project)
user = self.request.user user = self.request.user
project.remove_watcher(user) project.remove_watcher(user)
return response.Ok() return response.Ok()
@ -207,77 +221,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
services.update_projects_order_in_bulk(data, "user_order", request.user) services.update_projects_order_in_bulk(data, "user_order", request.user)
return response.NoContent(data=None) return response.NoContent(data=None)
@list_route(methods=["GET"])
def by_slug(self, request):
slug = request.QUERY_PARAMS.get("slug", None)
project = get_object_or_404(models.Project, slug=slug)
return self.retrieve(request, pk=project.pk)
@detail_route(methods=["GET", "PATCH"])
def modules(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'modules', project)
modules_config = services.get_modules_config(project)
if request.method == "GET":
return response.Ok(modules_config.config)
else:
modules_config.config.update(request.DATA)
modules_config.save()
return response.NoContent()
@detail_route(methods=["GET"])
def stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "stats", project)
return response.Ok(services.get_stats_for_project(project))
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.Ok(services.get_member_stats_for_project(project))
@detail_route(methods=["GET"])
def issues_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "issues_stats", project)
return response.Ok(services.get_stats_for_project_issues(project))
@detail_route(methods=["GET"])
def tags_colors(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "tags_colors", project)
return response.Ok(dict(project.tags_colors))
@detail_route(methods=["POST"]) @detail_route(methods=["POST"])
def create_template(self, request, **kwargs): def create_template(self, request, **kwargs):
template_name = request.DATA.get('template_name', None) template_name = request.DATA.get('template_name', None)
@ -305,13 +248,161 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
template.save() template.save()
return response.Created(serializers.ProjectTemplateSerializer(template).data) return response.Created(serializers.ProjectTemplateSerializer(template).data)
@detail_route(methods=['post']) @detail_route(methods=['POST'])
def leave(self, request, pk=None): def leave(self, request, pk=None):
project = self.get_object() project = self.get_object()
self.check_permissions(request, 'leave', project) self.check_permissions(request, 'leave', project)
self.pre_conditions_on_save(project)
services.remove_user_from_project(request.user, project) services.remove_user_from_project(request.user, project)
return response.Ok() return response.Ok()
@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)
self.pre_conditions_on_save(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)
self.pre_conditions_on_save(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)
self.pre_conditions_on_save(project)
data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")}
return response.Ok(data)
@list_route(methods=["GET"])
def by_slug(self, request):
slug = request.QUERY_PARAMS.get("slug", None)
project = get_object_or_404(models.Project, slug=slug)
return self.retrieve(request, pk=project.pk)
@detail_route(methods=["GET", "PATCH"])
def modules(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'modules', project)
modules_config = services.get_modules_config(project)
if request.method == "GET":
return response.Ok(modules_config.config)
else:
self.pre_conditions_on_save(project)
modules_config.config.update(request.DATA)
modules_config.save()
return response.NoContent()
@detail_route(methods=["GET"])
def stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "stats", project)
return response.Ok(services.get_stats_for_project(project))
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=["GET"])
def member_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "member_stats", project)
return response.Ok(services.get_member_stats_for_project(project))
@detail_route(methods=["GET"])
def issues_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "issues_stats", project)
return response.Ok(services.get_stats_for_project_issues(project))
@detail_route(methods=["GET"])
def tags_colors(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "tags_colors", project)
return response.Ok(dict(project.tags_colors))
@detail_route(methods=["POST"])
def transfer_validate_token(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "transfer_validate_token", project)
token = request.DATA.get('token', None)
services.transfer.validate_project_transfer_token(token, project, request.user)
return response.Ok()
@detail_route(methods=["POST"])
def transfer_request(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "transfer_request", project)
services.request_project_transfer(project, request.user)
return response.Ok()
@detail_route(methods=['post'])
def transfer_start(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "transfer_start", project)
user_id = request.DATA.get('user', None)
if user_id is None:
raise exc.WrongArguments(_("Invalid user id"))
user_model = apps.get_model("users", "User")
try:
user = user_model.objects.get(id=user_id)
except user_model.DoesNotExist:
return response.BadRequest(_("The user doesn't exist"))
# Check the user is a membership from the project
if not project.memberships.filter(user=user).exists():
return response.BadRequest(_("The user must be already a project member"))
reason = request.DATA.get('reason', None)
transfer_token = services.start_project_transfer(project, user, reason)
return response.Ok()
@detail_route(methods=["POST"])
def transfer_accept(self, request, pk=None):
token = request.DATA.get('token', None)
if token is None:
raise exc.WrongArguments(_("Invalid token"))
project = self.get_object()
self.check_permissions(request, "transfer_accept", project)
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
request.user,
project,
)
if not enough_slots:
members = project.memberships.count()
raise exc.NotEnoughSlotsForProject(project.is_private, members, not_enough_slots_error)
reason = request.DATA.get('reason', None)
services.accept_project_transfer(project, request.user, token, reason)
return response.Ok()
@detail_route(methods=["POST"])
def transfer_reject(self, request, pk=None):
token = request.DATA.get('token', None)
if token is None:
raise exc.WrongArguments(_("Invalid token"))
project = self.get_object()
self.check_permissions(request, "transfer_reject", project)
reason = request.DATA.get('reason', None)
services.reject_project_transfer(project, request.user, token, reason)
return response.Ok()
def _set_base_permissions(self, obj): def _set_base_permissions(self, obj):
update_permissions = False update_permissions = False
if not obj.id: if not obj.id:
@ -329,9 +420,15 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def pre_save(self, obj): def pre_save(self, obj):
if not obj.id: if not obj.id:
obj.owner = self.request.user obj.owner = self.request.user
# TODO REFACTOR THIS
obj.template = self.request.QUERY_PARAMS.get('template', None) obj.template = self.request.QUERY_PARAMS.get('template', None)
# Validate if the owner have enought slots to create or update the project
# TODO: Move to the ProjectAdminSerializer
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(obj.owner, obj)
if not enough_slots:
members = max(obj.memberships.count(), 1)
raise exc.NotEnoughSlotsForProject(obj.is_private, members, not_enough_slots_error)
self._set_base_permissions(obj) self._set_base_permissions(obj)
super().pre_save(obj) super().pre_save(obj)
@ -342,10 +439,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
if obj is None: if obj is None:
raise Http404 raise Http404
obj.delete_related_content()
self.pre_delete(obj) self.pre_delete(obj)
self.pre_conditions_on_delete(obj) self.pre_conditions_on_delete(obj)
obj.delete_related_content()
obj.delete() obj.delete()
self.post_delete(obj) self.post_delete(obj)
return response.NoContent() return response.NoContent()
@ -365,7 +461,9 @@ class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet):
## Custom values for selectors ## Custom values for selectors
###################################################### ######################################################
class PointsViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Points model = models.Points
serializer_class = serializers.PointsSerializer serializer_class = serializers.PointsSerializer
permission_classes = (permissions.PointsPermission,) permission_classes = (permissions.PointsPermission,)
@ -379,7 +477,9 @@ class PointsViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
move_on_destroy_project_default_field = "default_points" move_on_destroy_project_default_field = "default_points"
class UserStoryStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.UserStoryStatus model = models.UserStoryStatus
serializer_class = serializers.UserStoryStatusSerializer serializer_class = serializers.UserStoryStatusSerializer
permission_classes = (permissions.UserStoryStatusPermission,) permission_classes = (permissions.UserStoryStatusPermission,)
@ -393,7 +493,9 @@ class UserStoryStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrd
move_on_destroy_project_default_field = "default_us_status" move_on_destroy_project_default_field = "default_us_status"
class TaskStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.TaskStatus model = models.TaskStatus
serializer_class = serializers.TaskStatusSerializer serializer_class = serializers.TaskStatusSerializer
permission_classes = (permissions.TaskStatusPermission,) permission_classes = (permissions.TaskStatusPermission,)
@ -407,7 +509,9 @@ class TaskStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMix
move_on_destroy_project_default_field = "default_task_status" move_on_destroy_project_default_field = "default_task_status"
class SeverityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class SeverityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Severity model = models.Severity
serializer_class = serializers.SeveritySerializer serializer_class = serializers.SeveritySerializer
permission_classes = (permissions.SeverityPermission,) permission_classes = (permissions.SeverityPermission,)
@ -421,7 +525,8 @@ class SeverityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin
move_on_destroy_project_default_field = "default_severity" move_on_destroy_project_default_field = "default_severity"
class PriorityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class PriorityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Priority model = models.Priority
serializer_class = serializers.PrioritySerializer serializer_class = serializers.PrioritySerializer
permission_classes = (permissions.PriorityPermission,) permission_classes = (permissions.PriorityPermission,)
@ -435,7 +540,8 @@ class PriorityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin
move_on_destroy_project_default_field = "default_priority" move_on_destroy_project_default_field = "default_priority"
class IssueTypeViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class IssueTypeViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.IssueType model = models.IssueType
serializer_class = serializers.IssueTypeSerializer serializer_class = serializers.IssueTypeSerializer
permission_classes = (permissions.IssueTypePermission,) permission_classes = (permissions.IssueTypePermission,)
@ -449,7 +555,8 @@ class IssueTypeViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixi
move_on_destroy_project_default_field = "default_issue_type" move_on_destroy_project_default_field = "default_issue_type"
class IssueStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin): class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.IssueStatus model = models.IssueStatus
serializer_class = serializers.IssueStatusSerializer serializer_class = serializers.IssueStatusSerializer
permission_classes = (permissions.IssueStatusPermission,) permission_classes = (permissions.IssueStatusPermission,)
@ -480,7 +587,7 @@ class ProjectTemplateViewSet(ModelCrudViewSet):
## Members & Invitations ## Members & Invitations
###################################################### ######################################################
class MembershipViewSet(ModelCrudViewSet): class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet):
model = models.Membership model = models.Membership
admin_serializer_class = serializers.MembershipAdminSerializer admin_serializer_class = serializers.MembershipAdminSerializer
serializer_class = serializers.MembershipSerializer serializer_class = serializers.MembershipSerializer
@ -490,17 +597,17 @@ class MembershipViewSet(ModelCrudViewSet):
def get_serializer_class(self): def get_serializer_class(self):
use_admin_serializer = False use_admin_serializer = False
if self.action == "create": if self.action == "create":
use_admin_serializer = True use_admin_serializer = True
if self.action == "retrieve": if self.action == "retrieve":
use_admin_serializer = permissions_service.is_project_owner(self.request.user, self.object.project) use_admin_serializer = permissions_service.is_project_admin(self.request.user, self.object.project)
project_id = self.request.QUERY_PARAMS.get("project", None) project_id = self.request.QUERY_PARAMS.get("project", None)
if self.action == "list" and project_id is not None: if self.action == "list" and project_id is not None:
project = get_object_or_404(models.Project, pk=project_id) project = get_object_or_404(models.Project, pk=project_id)
use_admin_serializer = permissions_service.is_project_owner(self.request.user, project) use_admin_serializer = permissions_service.is_project_admin(self.request.user, project)
if use_admin_serializer: if use_admin_serializer:
return self.admin_serializer_class return self.admin_serializer_class
@ -518,10 +625,22 @@ class MembershipViewSet(ModelCrudViewSet):
project = models.Project.objects.get(id=data["project_id"]) project = models.Project.objects.get(id=data["project_id"])
invitation_extra_text = data.get("invitation_extra_text", None) invitation_extra_text = data.get("invitation_extra_text", None)
self.check_permissions(request, 'bulk_create', project) self.check_permissions(request, 'bulk_create', project)
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
# TODO: this should be moved to main exception handler instead # TODO: this should be moved to main exception handler instead
# of handling explicit exception catchin here. # of handling explicit exception catchin here.
if "bulk_memberships" in data and isinstance(data["bulk_memberships"], list):
members = len(data["bulk_memberships"])
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
project.owner,
project,
members
)
if not enough_slots:
raise exc.NotEnoughSlotsForProject(project.is_private, members, not_enough_slots_error)
try: try:
members = services.create_members_in_bulk(data["bulk_memberships"], members = services.create_members_in_bulk(data["bulk_memberships"],
project=project, project=project,
@ -539,15 +658,26 @@ class MembershipViewSet(ModelCrudViewSet):
invitation = self.get_object() invitation = self.get_object()
self.check_permissions(request, 'resend_invitation', invitation.project) self.check_permissions(request, 'resend_invitation', invitation.project)
self.pre_conditions_on_save(invitation)
services.send_invitation(invitation=invitation) services.send_invitation(invitation=invitation)
return response.NoContent() return response.NoContent()
def pre_delete(self, obj): def pre_delete(self, obj):
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project): 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")) raise exc.BadRequest(_("The project must have an owner and at least one of the users must be an active admin"))
def pre_save(self, obj): def pre_save(self, obj):
if not obj.id:
members = 1
(enough_slots, not_enough_slots_error) = users_service.has_available_slot_for_project(
self.request.user,
obj.project,
members
)
if not enough_slots:
raise exc.NotEnoughSlotsForProject(obj.project.is_private, members, not_enough_slots_error)
if not obj.token: if not obj.token:
obj.token = str(uuid.uuid1()) obj.token = str(uuid.uuid1())

View File

@ -19,12 +19,11 @@ from django.apps import AppConfig
from django.apps import apps from django.apps import apps
from django.db.models import signals from django.db.models import signals
from . import signals as handlers
## Project Signals ## Project Signals
def connect_projects_signals(): def connect_projects_signals():
from . import signals as handlers
# On project object is created apply template. # On project object is created apply template.
signals.post_save.connect(handlers.project_post_save, signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"), sender=apps.get_model("projects", "Project"),
@ -51,6 +50,7 @@ def disconnect_projects_signals():
## Memberships Signals ## Memberships Signals
def connect_memberships_signals(): def connect_memberships_signals():
from . import signals as handlers
# On membership object is deleted, update role-points relation. # On membership object is deleted, update role-points relation.
signals.pre_delete.connect(handlers.membership_post_delete, signals.pre_delete.connect(handlers.membership_post_delete,
sender=apps.get_model("projects", "Membership"), sender=apps.get_model("projects", "Membership"),
@ -71,6 +71,7 @@ def disconnect_memberships_signals():
## US Statuses Signals ## US Statuses Signals
def connect_us_status_signals(): def connect_us_status_signals():
from . import signals as handlers
signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status, signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_us_status,
sender=apps.get_model("projects", "UserStoryStatus"), sender=apps.get_model("projects", "UserStoryStatus"),
dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status") dispatch_uid="try_to_close_or_open_user_stories_when_edit_us_status")
@ -85,6 +86,7 @@ def disconnect_us_status_signals():
## Tasks Statuses Signals ## Tasks Statuses Signals
def connect_task_status_signals(): def connect_task_status_signals():
from . import signals as handlers
signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status, signals.post_save.connect(handlers.try_to_close_or_open_user_stories_when_edit_task_status,
sender=apps.get_model("projects", "TaskStatus"), sender=apps.get_model("projects", "TaskStatus"),
dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status") dispatch_uid="try_to_close_or_open_user_stories_when_edit_task_status")

View File

@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib import admin from django.contrib import admin
from django.contrib.contenttypes import generic from django.contrib.contenttypes.admin import GenericTabularInline
from . import models from . import models
@ -38,7 +38,7 @@ class AttachmentAdmin(admin.ModelAdmin):
return super().formfield_for_foreignkey(db_field, request, **kwargs) return super().formfield_for_foreignkey(db_field, request, **kwargs)
class AttachmentInline(generic.GenericTabularInline): class AttachmentInline(GenericTabularInline):
model = models.Attachment model = models.Attachment
fields = ("attached_file", "owner") fields = ("attached_file", "owner")
extra = 0 extra = 0

View File

@ -25,6 +25,7 @@ from django.contrib.contenttypes.models import ContentType
from taiga.base import filters from taiga.base import filters
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelCrudViewSet
from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404 from taiga.base.api.utils import get_object_or_404
from taiga.projects.notifications.mixins import WatchedResourceMixin from taiga.projects.notifications.mixins import WatchedResourceMixin
@ -35,7 +36,9 @@ from . import serializers
from . import models from . import models
class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCrudViewSet): class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin,
BlockedByProjectMixin, ModelCrudViewSet):
model = models.Attachment model = models.Attachment
serializer_class = serializers.AttachmentSerializer serializer_class = serializers.AttachmentSerializer
filter_fields = ["project", "object_id"] filter_fields = ["project", "object_id"]

View File

@ -20,7 +20,7 @@ import hashlib
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes import generic from django.contrib.contenttypes.fields import GenericForeignKey
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
@ -42,7 +42,7 @@ class Attachment(models.Model):
verbose_name=_("content type")) verbose_name=_("content type"))
object_id = models.PositiveIntegerField(null=False, blank=False, object_id = models.PositiveIntegerField(null=False, blank=False,
verbose_name=_("object id")) verbose_name=_("object id"))
content_object = generic.GenericForeignKey("content_type", "object_id") content_object = GenericForeignKey("content_type", "object_id")
created_date = models.DateTimeField(null=False, blank=False, created_date = models.DateTimeField(null=False, blank=False,
verbose_name=_("created date"), verbose_name=_("created date"),
default=timezone.now) default=timezone.now)

View File

@ -24,3 +24,12 @@ VIDEOCONFERENCES_CHOICES = (
("custom", _("Custom")), ("custom", _("Custom")),
("talky", _("Talky")), ("talky", _("Talky")),
) )
BLOCKED_BY_NONPAYMENT = "blocked-by-nonpayment"
BLOCKED_BY_STAFF = "blocked-by-staff"
BLOCKED_BY_OWNER_LEAVING = "blocked-by-owner-leaving"
BLOCKING_CODES = [
(BLOCKED_BY_NONPAYMENT, _("This project is blocked due to payment failure")),
(BLOCKED_BY_STAFF, _("This project is blocked by admin staff")),
(BLOCKED_BY_OWNER_LEAVING, _("This project is blocked because the owner left"))
]

View File

@ -19,6 +19,7 @@ from django.utils.translation import ugettext_lazy as _
from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelCrudViewSet
from taiga.base.api import ModelUpdateRetrieveViewSet from taiga.base.api import ModelUpdateRetrieveViewSet
from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from taiga.base import filters from taiga.base import filters
from taiga.base import response from taiga.base import response
@ -38,7 +39,7 @@ from . import services
# Custom Attribute ViewSets # Custom Attribute ViewSets
####################################################### #######################################################
class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet):
model = models.UserStoryCustomAttribute model = models.UserStoryCustomAttribute
serializer_class = serializers.UserStoryCustomAttributeSerializer serializer_class = serializers.UserStoryCustomAttributeSerializer
permission_classes = (permissions.UserStoryCustomAttributePermission,) permission_classes = (permissions.UserStoryCustomAttributePermission,)
@ -49,7 +50,7 @@ class UserStoryCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet):
bulk_update_order_action = services.bulk_update_userstory_custom_attribute_order bulk_update_order_action = services.bulk_update_userstory_custom_attribute_order
class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet):
model = models.TaskCustomAttribute model = models.TaskCustomAttribute
serializer_class = serializers.TaskCustomAttributeSerializer serializer_class = serializers.TaskCustomAttributeSerializer
permission_classes = (permissions.TaskCustomAttributePermission,) permission_classes = (permissions.TaskCustomAttributePermission,)
@ -60,7 +61,7 @@ class TaskCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet):
bulk_update_order_action = services.bulk_update_task_custom_attribute_order bulk_update_order_action = services.bulk_update_task_custom_attribute_order
class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet): class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, BlockedByProjectMixin, ModelCrudViewSet):
model = models.IssueCustomAttribute model = models.IssueCustomAttribute
serializer_class = serializers.IssueCustomAttributeSerializer serializer_class = serializers.IssueCustomAttributeSerializer
permission_classes = (permissions.IssueCustomAttributePermission,) permission_classes = (permissions.IssueCustomAttributePermission,)
@ -76,7 +77,7 @@ class IssueCustomAttributeViewSet(BulkUpdateOrderMixin, ModelCrudViewSet):
####################################################### #######################################################
class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin, class BaseCustomAttributesValuesViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ModelUpdateRetrieveViewSet): BlockedByProjectMixin, ModelUpdateRetrieveViewSet):
def get_object_for_snapshot(self, obj): def get_object_for_snapshot(self, obj):
return getattr(obj, self.content_object) return getattr(obj, self.content_object)

View File

@ -21,9 +21,11 @@ from django.utils.translation import ugettext_lazy as _
TEXT_TYPE = "text" TEXT_TYPE = "text"
MULTILINE_TYPE = "multiline" MULTILINE_TYPE = "multiline"
DATE_TYPE = "date" DATE_TYPE = "date"
URL_TYPE = "url"
TYPES_CHOICES = ( TYPES_CHOICES = (
(TEXT_TYPE, _("Text")), (TEXT_TYPE, _("Text")),
(MULTILINE_TYPE, _("Multi-Line Text")), (MULTILINE_TYPE, _("Multi-Line Text")),
(DATE_TYPE, _("Date")) (DATE_TYPE, _("Date")),
(URL_TYPE, _("Url"))
) )

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('custom_attributes', '0006_auto_20151014_1645'),
]
operations = [
migrations.AlterField(
model_name='issuecustomattribute',
name='type',
field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]),
),
migrations.AlterField(
model_name='taskcustomattribute',
name='type',
field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]),
),
migrations.AlterField(
model_name='userstorycustomattribute',
name='type',
field=models.CharField(default='text', max_length=16, verbose_name='type', choices=[('text', 'Text'), ('multiline', 'Multi-Line Text'), ('date', 'Date'), ('url', 'Url')]),
),
]

View File

@ -17,7 +17,7 @@
from taiga.base.api.permissions import TaigaResourcePermission from taiga.base.api.permissions import TaigaResourcePermission
from taiga.base.api.permissions import HasProjectPerm from taiga.base.api.permissions import HasProjectPerm
from taiga.base.api.permissions import IsProjectOwner from taiga.base.api.permissions import IsProjectAdmin
from taiga.base.api.permissions import AllowAny from taiga.base.api.permissions import AllowAny
from taiga.base.api.permissions import IsSuperUser from taiga.base.api.permissions import IsSuperUser
@ -27,39 +27,39 @@ from taiga.base.api.permissions import IsSuperUser
####################################################### #######################################################
class UserStoryCustomAttributePermission(TaigaResourcePermission): class UserStoryCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner() create_perms = IsProjectAdmin()
update_perms = IsProjectOwner() update_perms = IsProjectAdmin()
partial_update_perms = IsProjectOwner() partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectOwner() destroy_perms = IsProjectAdmin()
list_perms = AllowAny() list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner() bulk_update_order_perms = IsProjectAdmin()
class TaskCustomAttributePermission(TaigaResourcePermission): class TaskCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner() create_perms = IsProjectAdmin()
update_perms = IsProjectOwner() update_perms = IsProjectAdmin()
partial_update_perms = IsProjectOwner() partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectOwner() destroy_perms = IsProjectAdmin()
list_perms = AllowAny() list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner() bulk_update_order_perms = IsProjectAdmin()
class IssueCustomAttributePermission(TaigaResourcePermission): class IssueCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner() create_perms = IsProjectAdmin()
update_perms = IsProjectOwner() update_perms = IsProjectAdmin()
partial_update_perms = IsProjectOwner() partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectOwner() destroy_perms = IsProjectAdmin()
list_perms = AllowAny() list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner() bulk_update_order_perms = IsProjectAdmin()
###################################################### ######################################################
@ -67,7 +67,7 @@ class IssueCustomAttributePermission(TaigaResourcePermission):
####################################################### #######################################################
class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission): class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_us') retrieve_perms = HasProjectPerm('view_us')
update_perms = HasProjectPerm('modify_us') update_perms = HasProjectPerm('modify_us')
@ -75,7 +75,7 @@ class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission):
class TaskCustomAttributesValuesPermission(TaigaResourcePermission): class TaskCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_tasks') retrieve_perms = HasProjectPerm('view_tasks')
update_perms = HasProjectPerm('modify_task') update_perms = HasProjectPerm('modify_task')
@ -83,7 +83,7 @@ class TaskCustomAttributesValuesPermission(TaigaResourcePermission):
class IssueCustomAttributesValuesPermission(TaigaResourcePermission): class IssueCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser() enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None global_perms = None
retrieve_perms = HasProjectPerm('view_issues') retrieve_perms = HasProjectPerm('view_issues')
update_perms = HasProjectPerm('modify_issue') update_perms = HasProjectPerm('modify_issue')

View File

@ -37,7 +37,8 @@ class DiscoverModeFilterBackend(FilterBackend):
if discover_mode: if discover_mode:
# discover_mode enabled # discover_mode enabled
qs = qs.filter(anon_permissions__contains=["view_project"]) qs = qs.filter(anon_permissions__contains=["view_project"],
blocked_code__isnull=True)
return super().filter_queryset(request, qs.distinct(), view) return super().filter_queryset(request, qs.distinct(), view)
@ -70,7 +71,7 @@ class CanViewProjectObjFilterBackend(FilterBackend):
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) | memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) |
Q(is_owner=True)) Q(is_admin=True))
projects_list = [membership.project_id for membership in memberships_qs] projects_list = [membership.project_id for membership in memberships_qs]

View File

@ -19,10 +19,10 @@ from contextlib import suppress
from functools import partial from functools import partial
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from taiga.base.utils.urls import get_absolute_url
from taiga.base.utils.iterators import as_tuple from taiga.base.utils.iterators import as_tuple
from taiga.base.utils.iterators import as_dict from taiga.base.utils.iterators import as_dict
from taiga.mdrender.service import render as mdrender from taiga.mdrender.service import render as mdrender
@ -49,7 +49,7 @@ def _get_generic_values(ids:tuple, *, typename=None, attr:str="name") -> tuple:
@as_dict @as_dict
def _get_users_values(ids:set) -> dict: def _get_users_values(ids:set) -> dict:
user_model = apps.get_model("users", "User") user_model = get_user_model()
ids = filter(lambda x: x is not None, ids) ids = filter(lambda x: x is not None, ids)
qs = user_model.objects.filter(pk__in=tuple(ids)) qs = user_model.objects.filter(pk__in=tuple(ids))

View File

@ -14,12 +14,10 @@
import uuid import uuid
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from django.apps import apps from django.contrib.auth import get_user_model
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.conf import settings
from django_pgjson.fields import JsonField from django_pgjson.fields import JsonField
from taiga.mdrender.service import get_diff_of_htmls from taiga.mdrender.service import get_diff_of_htmls
@ -96,7 +94,7 @@ class HistoryEntry(models.Model):
@cached_property @cached_property
def owner(self): def owner(self):
pk = self.user["pk"] pk = self.user["pk"]
model = apps.get_model("users", "User") model = get_user_model()
try: try:
return model.objects.get(pk=pk) return model.objects.get(pk=pk)
except model.DoesNotExist: except model.DoesNotExist:

View File

@ -16,10 +16,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm,
IsProjectOwner, AllowAny, IsProjectAdmin, AllowAny,
IsObjectOwner, PermissionComponent) IsObjectOwner, PermissionComponent)
from taiga.permissions.service import is_project_owner from taiga.permissions.service import is_project_admin
from taiga.projects.history.services import get_model_from_key, get_pk_from_key from taiga.projects.history.services import get_model_from_key, get_pk_from_key
@ -38,7 +38,7 @@ class IsCommentProjectOwner(PermissionComponent):
model = get_model_from_key(obj.key) model = get_model_from_key(obj.key)
pk = get_pk_from_key(obj.key) pk = get_pk_from_key(obj.key)
project = model.objects.get(pk=pk) project = model.objects.get(pk=pk)
return is_project_owner(request.user, project) return is_project_admin(request.user, project)
class UserStoryHistoryPermission(TaigaResourcePermission): class UserStoryHistoryPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')

View File

@ -16,24 +16,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
from taiga.base import filters from taiga.base import filters
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from taiga.base import response from taiga.base import response
from taiga.base.decorators import detail_route, list_route from taiga.base.decorators import list_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.mixins import BlockedByProjectMixin
from taiga.base.api.utils import get_object_or_404 from taiga.base.api.utils import get_object_or_404
from taiga.users.models import User
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin from taiga.projects.occ import OCCResourceMixin
from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.mixins import HistoryResourceMixin
from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType from taiga.projects.models import Project, IssueStatus, Severity, Priority, IssueType
from taiga.projects.milestones.models import Milestone
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
from . import models from . import models
@ -43,7 +40,7 @@ from . import serializers
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin, class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ModelCrudViewSet): BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Issue.objects.all() queryset = models.Issue.objects.all()
permission_classes = (permissions.IssuePermission, ) permission_classes = (permissions.IssuePermission, )
filter_backends = (filters.CanViewIssuesFilterBackend, filter_backends = (filters.CanViewIssuesFilterBackend,
@ -157,8 +154,6 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
super().pre_save(obj) super().pre_save(obj)
def pre_conditions_on_save(self, obj): def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)
if obj.milestone and obj.milestone.project != obj.project: if obj.milestone and obj.milestone.project != obj.project:
raise exc.PermissionDenied(_("You don't have permissions to set this sprint " raise exc.PermissionDenied(_("You don't have permissions to set this sprint "
"to this issue.")) "to this issue."))
@ -179,6 +174,8 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
raise exc.PermissionDenied(_("You don't have permissions to set this type " raise exc.PermissionDenied(_("You don't have permissions to set this type "
"to this issue.")) "to this issue."))
super().pre_conditions_on_save(obj)
@list_route(methods=["GET"]) @list_route(methods=["GET"])
def by_ref(self, request): def by_ref(self, request):
ref = request.QUERY_PARAMS.get("ref", None) ref = request.QUERY_PARAMS.get("ref", None)
@ -232,6 +229,9 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
data = serializer.data data = serializer.data
project = Project.objects.get(pk=data["project_id"]) project = Project.objects.get(pk=data["project_id"])
self.check_permissions(request, 'bulk_create', project) self.check_permissions(request, 'bulk_create', project)
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
issues = services.create_issues_in_bulk( issues = services.create_issues_in_bulk(
data["bulk_issues"], project=project, owner=request.user, data["bulk_issues"], project=project, owner=request.user,
status=project.default_issue_status, severity=project.default_severity, status=project.default_issue_status, severity=project.default_severity,

View File

@ -19,12 +19,11 @@ from django.apps import AppConfig
from django.apps import apps from django.apps import apps
from django.db.models import signals 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
def connect_issues_signals(): def connect_issues_signals():
from taiga.projects import signals as generic_handlers
from . import signals as handlers
# Finished date # Finished date
signals.pre_save.connect(handlers.set_finished_date_when_edit_issue, signals.pre_save.connect(handlers.set_finished_date_when_edit_issue,
sender=apps.get_model("issues", "Issue"), sender=apps.get_model("issues", "Issue"),
@ -43,6 +42,8 @@ def connect_issues_signals():
def connect_issues_custom_attributes_signals(): def connect_issues_custom_attributes_signals():
from taiga.projects.custom_attributes import signals as custom_attributes_handlers
signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue, signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_issue,
sender=apps.get_model("issues", "Issue"), sender=apps.get_model("issues", "Issue"),
dispatch_uid="create_custom_attribute_value_when_create_issue") dispatch_uid="create_custom_attribute_value_when_create_issue")

View File

@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models from django.db import models
from django.contrib.contenttypes import generic from django.contrib.contenttypes.fields import GenericRelation
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.dispatch import receiver from django.dispatch import receiver
@ -63,7 +63,7 @@ class Issue(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, models.
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
default=None, related_name="issues_assigned_to_me", default=None, related_name="issues_assigned_to_me",
verbose_name=_("assigned to")) verbose_name=_("assigned to"))
attachments = generic.GenericRelation("attachments.Attachment") attachments = GenericRelation("attachments.Attachment")
external_reference = TextArrayField(default=None, verbose_name=_("external reference")) external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
_importing = None _importing = None

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