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>
- Andrés Moya <andres.moya@kaleidos.net>
- Andrey Alekseenko <al42and@gmail.com>
<<<<<<< HEAD
=======
- Brett Profitt <brett.profitt@gmail.com>
>>>>>>> master
- Bruno Clermont <bruno@robotinfra.com>
- Chris Wilson <chris.wilson@aridhia.com>
- David Burke <david@burkesoftware.com>
- Hector Colina <hcolina@gmail.com>
- Joe Letts
- Julien Palard
- luyikei <luyikei.qmltu@gmail.com>
- Motius GmbH <mail@motius.de>
- Ricky Posner <e@eposner.com>
- Yamila Moreno <yamila.moreno@kaleidos.net>
- Brett Profitt <brett.profitt@gmail.com>
- Yaser Alraddadi <yaser@yr.sa>

View File

@ -1,6 +1,19 @@
# 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)
### Features
@ -10,7 +23,7 @@
- Filter projects list by
- is_looking_for_people
- is_featured
- is_backlog_activated
- is_backlog_activated
- is_kanban_activated
- Search projects by text query (order by ranking name > tags > description)
- Order projects list:

View File

@ -1,8 +1,8 @@
-r requirements.txt
factory_boy==2.6.0
factory_boy==2.6.1
py==1.4.31
pytest==2.8.5
pytest==2.8.7
pytest-django==2.9.1
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
django-picklefield==0.3.2
django-sampledatahelper==0.3.0
gunicorn==19.3.0
django-sampledatahelper==0.4.0
gunicorn==19.4.5
psycopg2==2.6.1
Pillow==2.9.0
Pillow==3.1.1
pytz==2015.7
six==1.10.0
amqp==1.4.7
djmail==0.11
amqp==1.4.9
djmail==0.12.0.post1
django-pgjson==0.3.1
djorm-pgarray==1.2
django-jinja==2.1.1
django-jinja==2.1.2
jinja2==2.8
pygments==2.0.2
django-sites==0.8
django-sites==0.9
Markdown==2.6.5
fn==0.4.3
diff-match-patch==20121119
requests==2.8.1
requests==2.9.1
django-sr==0.0.4
easy-thumbnails==2.2.1
celery==3.1.19
easy-thumbnails==2.3
celery==3.1.20
redis==2.10.5
Unidecode==0.04.18
raven==5.9.2
Unidecode==0.04.19
raven==5.10.2
bleach==1.4.2
django-ipware==1.1.2
premailer==2.9.6
django-ipware==1.1.3
premailer==2.9.7
cssutils==1.0.1 # Compatible with python 3.5
django-transactional-cleanup==0.1.15
lxml==3.5.0
git+https://github.com/Xof/django-pglocks.git@dbb8d7375066859f897604132bd437832d2014ea
pyjwkest==1.0.9
pyjwkest==1.1.5
python-dateutil==2.4.2
netaddr==0.7.18

View File

@ -30,7 +30,7 @@ DEBUG = False
DATABASES = {
"default": {
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2",
"ENGINE": "django.db.backends.postgresql",
"NAME": "taiga",
}
}
@ -320,7 +320,6 @@ INSTALLED_APPS = [
"sr",
"easy_thumbnails",
"raven.contrib.django.raven_compat",
"django_transactional_cleanup",
]
WSGI_APPLICATION = "taiga.wsgi.application"
@ -347,7 +346,7 @@ LOGGING = {
"handlers": {
"null": {
"level":"DEBUG",
"class":"django.utils.log.NullHandler",
"class":"logging.NullHandler",
},
"console":{
"level":"DEBUG",
@ -434,7 +433,9 @@ REST_FRAMEWORK = {
# Extra expose header related to Taiga APP (see taiga.base.middleware.cors=)
APP_EXTRA_EXPOSE_HEADERS = [
"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"
@ -522,6 +523,12 @@ WEBHOOKS_ENABLED = False
FRONT_SITEMAP_ENABLED = False
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 *

View File

@ -1,6 +1,7 @@
# 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
@ -24,7 +25,7 @@ from .development import *
DATABASES = {
'default': {
'ENGINE': 'transaction_hooks.backends.postgresql_psycopg2',
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'taiga',
'USER': 'taiga',
'PASSWORD': 'changeme',

View File

@ -25,6 +25,7 @@ not uses clasess and uses simple functions.
"""
from django.apps import apps
from django.contrib.auth import get_user_model
from django.db import transaction as tx
from django.db import IntegrityError
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
"""
user_model = apps.get_model("users", "User")
user_model = get_user_model()
if user_model.objects.filter(username=username):
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:
raise exc.WrongArguments(reason)
user_model = apps.get_model("users", "User")
user_model = get_user_model()
user = user_model(username=username,
email=email,
full_name=full_name)
@ -159,7 +160,7 @@ def private_register_for_new_user(token:str, username:str, email:str,
if is_registered:
raise exc.WrongArguments(reason)
user_model = apps.get_model("users", "User")
user_model = get_user_model()
user = user_model(username=username,
email=email,
full_name=full_name)

View File

@ -14,7 +14,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.contrib.auth import get_user_model
from taiga.base import exceptions as exc
from django.apps import apps
@ -47,7 +47,7 @@ def get_user_for_token(token, scope, max_age=None):
except signing.BadSignature:
raise exc.NotAuthenticated(_("Invalid token"))
model_cls = apps.get_model("users", "User")
model_cls = get_user_model()
try:
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.translation import ugettext
from django.utils.translation import ugettext_lazy as _
from django.utils.datastructures import SortedDict
from . import ISO_8601
from .settings import api_settings
from collections import OrderedDict
from decimal import Decimal, DecimalException
import copy
import datetime
import inspect
import re
import warnings
from decimal import Decimal, DecimalException
def is_non_str_iterable(obj):
@ -255,7 +255,7 @@ class Field(object):
return [self.to_native(item) for item in value]
elif isinstance(value, dict):
# Make sure we preserve field ordering, if it exists
ret = SortedDict()
ret = OrderedDict()
for key, val in value.items():
ret[key] = self.to_native(val)
return ret
@ -270,7 +270,7 @@ class Field(object):
return {}
def metadata(self):
metadata = SortedDict()
metadata = OrderedDict()
metadata["type"] = self.type_label
metadata["required"] = getattr(self, "required", False)
optional_attrs = ["read_only", "label", "help_text",

View File

@ -53,9 +53,9 @@ from taiga.base import response
from .settings import api_settings
from .utils import get_object_or_404
from .. import exceptions as exc
from ..decorators import model_pk_lock
def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None):
"""
Given a model instance, and an optional pk and slug field,
@ -243,3 +243,32 @@ class DestroyModelMixin:
obj.delete()
self.post_delete(obj)
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 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.utils.translation import ugettext as _
@ -206,9 +206,9 @@ class HasMandatoryParam(PermissionComponent):
return False
class IsProjectOwner(PermissionComponent):
class IsProjectAdmin(PermissionComponent):
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):

View File

@ -59,11 +59,11 @@ from django.core.paginator import Page
from django.db import models
from django.forms import widgets
from django.utils import six
from django.utils.datastructures import SortedDict
from django.utils.translation import ugettext as _
from .settings import api_settings
from collections import OrderedDict
import copy
import datetime
import inspect
@ -148,7 +148,7 @@ class DictWithMetadata(dict):
return dict(self)
class SortedDictWithMetadata(SortedDict):
class OrderedDictWithMetadata(OrderedDict):
"""
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
pickle and may in some instances be unpickleable.
"""
return SortedDict(self).__dict__
return OrderedDict(self).__dict__
def _is_protected_type(obj):
@ -194,7 +194,7 @@ def _get_declared_fields(bases, attrs):
if hasattr(base, "base_fields"):
fields = list(base.base_fields.items()) + fields
return SortedDict(fields)
return OrderedDict(fields)
class SerializerMetaclass(type):
@ -222,7 +222,7 @@ class BaseSerializer(WritableField):
pass
_options_class = SerializerOptions
_dict_class = SortedDictWithMetadata
_dict_class = OrderedDictWithMetadata
def __init__(self, instance=None, data=None, files=None,
context=None, partial=False, many=None,
@ -268,7 +268,7 @@ class BaseSerializer(WritableField):
This will be the set of any explicitly declared fields,
plus the set of fields returned by get_default_fields().
"""
ret = SortedDict()
ret = OrderedDict()
# Get the explicitly declared 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 self.opts.fields:
assert isinstance(self.opts.fields, (list, tuple)), "`fields` must be a list or tuple"
new = SortedDict()
new = OrderedDict()
for key in self.opts.fields:
new[key] = ret[key]
ret = new
@ -458,7 +458,10 @@ class BaseSerializer(WritableField):
many = hasattr(value, "__iter__") and not isinstance(value, (Page, dict, six.text_type))
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)
def field_from_native(self, data, files, field_name, into):
@ -610,7 +613,10 @@ class BaseSerializer(WritableField):
DeprecationWarning, stacklevel=2)
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:
self._data = self.to_native(obj)
@ -645,7 +651,7 @@ class BaseSerializer(WritableField):
Useful for things like responding to OPTIONS requests, or generating
API schemas for auto-documentation.
"""
return SortedDict(
return OrderedDict(
[(field_name, field.metadata())
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, \
"Serializer class '%s' is missing `model` Meta option" % self.__class__.__name__
opts = cls._meta.concrete_model._meta
ret = SortedDict()
ret = OrderedDict()
nested = bool(self.opts.depth)
# Deal with adding the primary key field

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,8 @@
import json
from collections import OrderedDict
from django.conf import settings
from django.core.exceptions import PermissionDenied
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.defaults import server_error
from django.views.generic import View
from django.utils.datastructures import SortedDict
from django.utils.encoding import smart_text
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
# generic views override this implementation and add additional
# information for POST and PUT methods, based on the serializer.
ret = SortedDict()
ret = OrderedDict()
ret['name'] = self.get_view_name()
ret['description'] = self.get_view_description()
ret['renders'] = [renderer.media_type for renderer in self.renderer_classes]

View File

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

View File

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

View File

@ -17,7 +17,6 @@
from django_pglocks import advisory_lock
def detail_route(methods=['get'], **kwargs):
"""
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
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):
if isinstance(exc.detail, (dict, list, tuple,)):
detail = exc.detail
@ -232,6 +254,9 @@ def exception_handler(exc):
headers["WWW-Authenticate"] = exc.auth_header
if getattr(exc, "wait", None):
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)
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
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from django.apps import apps
@ -141,7 +142,7 @@ class PermissionBasedFilterBackend(FilterBackend):
if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) |
Q(is_owner=True))
Q(is_admin=True))
projects_list = [membership.project_id for membership in memberships_qs]
@ -242,7 +243,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) |
Q(is_owner=True))
Q(is_admin=True))
projects_list = [membership.project_id for membership in memberships_qs]
@ -286,7 +287,7 @@ class BaseIsProjectAdminFilterBackend(object):
return []
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:
memberships_qs = memberships_qs.filter(project_id=project_id)

View File

@ -19,7 +19,8 @@ import datetime
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.utils import timezone
@ -28,7 +29,6 @@ from taiga.base.mails import mail_builder
from taiga.projects.models import Project, Membership
from taiga.projects.history.models import HistoryEntry
from taiga.projects.history.services import get_history_queryset_by_model_instance
from taiga.users.models import User
class Command(BaseCommand):
@ -50,7 +50,7 @@ class Command(BaseCommand):
# Register email
context = {"lang": locale,
"user": User.objects.all().order_by("?").first(),
"user": get_user_model().objects.all().order_by("?").first(),
"cancel_token": "cancel-token"}
email = mail_builder.registered_user(test_email, context)
@ -58,7 +58,7 @@ class Command(BaseCommand):
# Membership invitation
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"
context = {"lang": locale, "membership": membership}
@ -88,19 +88,19 @@ class Command(BaseCommand):
email.send()
# 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.send()
# 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.send()
# Export/Import emails
context = {
"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(),
"error_subject": "Error generating project dump",
"error_message": "Error generating project dump",
@ -109,7 +109,7 @@ class Command(BaseCommand):
email.send()
context = {
"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_message": "Error importing project dump",
}
@ -120,7 +120,7 @@ class Command(BaseCommand):
context = {
"lang": locale,
"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(),
"deletion_date": deletion_date,
}
@ -129,7 +129,7 @@ class Command(BaseCommand):
context = {
"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(),
}
email = mail_builder.load_dump(test_email, context)
@ -157,13 +157,13 @@ class Command(BaseCommand):
context = {
"lang": locale,
"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],
"user": User.objects.all().order_by("?").first(),
"user": get_user_model().objects.all().order_by("?").first(),
}
for notification_email in notification_emails:
model = get_model(*notification_email[0].split("."))
model = apps.get_model(*notification_email[0].split("."))
snapshot = {
"subject": "Tests subject",
"ref": 123123,
@ -187,3 +187,38 @@ class Command(BaseCommand):
cls = type("InlineCSSTemplateMail", (InlineCSSTemplateMail,), {"name": notification_email[1]})
email = cls()
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.
"""The various HTTP responses for use in returning proper HTTP codes."""
from http.client import responses
from django import http
from django.core.handlers.wsgi import STATUS_CODE_TEXT
from django.template.response import SimpleTemplateResponse
from django.utils import six
@ -114,7 +115,7 @@ class Response(SimpleTemplateResponse):
"""
# TODO: Deprecate and use a template tag instead
# 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):
"""

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

View File

@ -104,6 +104,7 @@ HTTP_417_EXPECTATION_FAILED = 417
HTTP_428_PRECONDITION_REQUIRED = 428
HTTP_429_TOO_MANY_REQUESTS = 429
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
HTTP_451_BLOCKED = 451
HTTP_500_INTERNAL_SERVER_ERROR = 500
HTTP_501_NOT_IMPLEMENTED = 501
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.db.models import signals
from . import signal_handlers as handlers
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_delete.connect(handlers.on_delete_any_model, dispatch_uid="events_delete")
def disconnect_events_signals():
from . import signal_handlers as handlers
signals.post_save.disconnect(dispatch_uid="events_change")
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.tasks.models import Task
from taiga.projects.serializers import ProjectSerializer
from taiga.users import services as users_service
from . import mixins
from . import serializers
@ -90,6 +91,14 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
data = request.DATA.copy()
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
project_serialized = service.store_project(data)
@ -106,11 +115,19 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
# Create memberships
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)
try:
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()
except Membership.DoesNotExist:
Membership.objects.create(
@ -118,7 +135,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
email=project_serialized.object.owner.email,
user=project_serialized.object.owner,
role=project_serialized.object.roles.all().first(),
is_owner=True
is_admin=True
)
# Create project values choicess
@ -202,6 +219,7 @@ class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixi
try:
dump = json.load(reader(dump))
is_private = dump.get("is_private", False)
except Exception:
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():
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:
task = tasks.load_project_dump.delay(request.user, dump)
task = tasks.load_project_dump.delay(user, dump)
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
return response.Created(response_data)

View File

@ -17,7 +17,8 @@
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 service
@ -89,7 +90,15 @@ def store_tags_colors(project, data):
def dict_to_project(data, owner=None):
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)
@ -138,7 +147,7 @@ def dict_to_project(data, owner=None):
email=proj.owner.email,
user=proj.owner,
role=proj.roles.all().first(),
is_owner=True
is_admin=True
)
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.dump_service import dict_to_project, TaigaImportError
from taiga.export_import.service import get_errors
from taiga.users.models import User
class Command(BaseCommand):
@ -58,7 +59,9 @@ class Command(BaseCommand):
except Project.DoesNotExist:
pass
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:
print("ERROR:", end=" ")
print(e.message)

View File

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

View File

@ -21,6 +21,7 @@ import os
from collections import OrderedDict
from django.apps import apps
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.exceptions import ObjectDoesNotExist
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 taiga import mdrender
from taiga.base.api import serializers
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.custom_attributes import models as custom_attributes_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):
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):
@ -263,7 +264,7 @@ class WatcheableObjectModelSerializer(serializers.ModelSerializer):
adding_watcher_emails = list(new_watcher_emails.difference(old_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)
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
def load_project_dump(user, dump):
try:
project = dict_to_project(dump, user.email)
project = dict_to_project(dump, user)
except Exception:
ctx = {
"user": user,

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %}
{% 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>
<p>Hello {{ user }},</p>
<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 }},
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" %}
{% 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>
<p>Hello {{ user }},</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 }},
{{ 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" %}
{% 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>
<p>Hello {{ user }},</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 }},
{{ error_message }}

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %}
{% 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>
<p>Hello {{ user }},</p>
<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 }},
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.urls import include, url
from .routers import router
class FeedbackAppConfig(AppConfig):
name = "taiga.feedback"
@ -30,4 +28,5 @@ class FeedbackAppConfig(AppConfig):
def ready(self):
if settings.FEEDBACK_ENABLED:
from taiga.urls import urlpatterns
from .routers import router
urlpatterns.append(url(r'^api/v1/', include(router.urls)))

View File

@ -1,7 +1,7 @@
{% extends "emails/base-body-html.jinja" %}
{% 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>
<p>Taiga has received feedback from {{ full_name }} <{{ email }}></p>
{% 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 }}>
---------
- 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 }}>
{% endtrans %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,9 @@ class WikiPagesSitemap(Sitemap):
Q(project__is_private=True,
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
queryset = queryset.exclude(project__is_wiki_activated=False)

View File

@ -46,6 +46,7 @@ urls = {
"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):
raise exc.BadRequest(_("Bad signature"))
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
event_name = self._get_event_name(request)
payload = self._get_payload(request)

View File

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

View File

@ -17,10 +17,10 @@
import uuid
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.conf import settings
from taiga.users.models import User
from taiga.base.utils.urls import get_absolute_url
@ -43,4 +43,4 @@ def get_or_generate_config(project):
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
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from taiga.users.models import User
from taiga.users.models import AuthData
from taiga.base.utils.urls import get_absolute_url
@ -49,6 +49,6 @@ def get_github_user(github_id):
pass
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

View File

@ -17,10 +17,10 @@
import uuid
from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from django.conf import settings
from taiga.users.models import User
from taiga.base.utils.urls import get_absolute_url
@ -47,11 +47,11 @@ def get_gitlab_user(user_email):
if user_email:
try:
user = User.objects.get(email=user_email)
except User.DoesNotExist:
user = get_user_model().objects.get(email=user_email)
except get_user_model().DoesNotExist:
pass
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

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,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.contrib.auth import get_user_model
from markdown.extensions import Extension
from markdown.inlinepatterns import Pattern
from markdown.util import etree, AtomicString
from taiga.users.models import User
class MentionsExtension(Extension):
def extendMarkdown(self, md, md_globals):
@ -43,8 +42,8 @@ class MentionsPattern(Pattern):
username = m.group(3)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
user = get_user_model().objects.get(username=username)
except get_user_model().DoesNotExist:
return "@{}".format(username)
url = "/profile/{}".format(username)

View File

@ -7,85 +7,85 @@
# 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 markdown import Extension
from markdown.inlinepatterns import Pattern
from markdown.treeprocessors import Treeprocessor
from markdown.util import etree
from taiga.front.templatetags.functions import resolve
from taiga.base.utils.slug import slugify
import re
class WikiLinkExtension(Extension):
def __init__(self, project, *args, **kwargs):
self.project = project
return super().__init__(*args, **kwargs)
def extendMarkdown(self, md, md_globals):
WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]"
md.inlinePatterns.add("wikilinks",
WikiLinksPattern(md, WIKILINK_RE, self.project),
"<not_strong")
md.treeprocessors.add("relative_to_absolute_links",
RelativeLinksTreeprocessor(md, self.project),
"<prettify")
class WikiLinksPattern(Pattern):
def __init__(self, md, pattern, project):
self.project = project
self.md = md
super().__init__(pattern)
def handleMatch(self, m):
label = m.group(2).strip()
url = resolve("wiki", self.project.slug, slugify(label))
if m.group(3):
title = m.group(3).strip()[1:]
else:
title = label
a = etree.Element("a")
a.text = title
a.set("href", url)
a.set("title", title)
a.set("class", "reference wiki")
return a
SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
class RelativeLinksTreeprocessor(Treeprocessor):
def __init__(self, md, project):
self.project = project
super().__init__(md)
def run(self, root):
links = root.getiterator("a")
for a in links:
href = a.get("href", "")
if SLUG_RE.search(href):
# [wiki](wiki_page) -> <a href="FRONT_HOST/.../wiki/wiki_page" ...
url = resolve("wiki", self.project.slug, href)
a.set("href", url)
a.set("class", "reference wiki")
elif href and href[0] == "/":
# [some link](/some/link) -> <a href="FRONT_HOST/some/link" ...
url = "{}{}".format(resolve("home"), href[1:])
a.set("href", url)
from markdown import Extension
from markdown.inlinepatterns import Pattern
from markdown.treeprocessors import Treeprocessor
from markdown.util import etree
from taiga.front.templatetags.functions import resolve
from taiga.base.utils.slug import slugify
import re
class WikiLinkExtension(Extension):
def __init__(self, project, *args, **kwargs):
self.project = project
return super().__init__(*args, **kwargs)
def extendMarkdown(self, md, md_globals):
WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[^\]]+)?\]\]"
md.inlinePatterns.add("wikilinks",
WikiLinksPattern(md, WIKILINK_RE, self.project),
"<not_strong")
md.treeprocessors.add("relative_to_absolute_links",
RelativeLinksTreeprocessor(md, self.project),
"<prettify")
class WikiLinksPattern(Pattern):
def __init__(self, md, pattern, project):
self.project = project
self.md = md
super().__init__(pattern)
def handleMatch(self, m):
label = m.group(2).strip()
url = resolve("wiki", self.project.slug, slugify(label))
if m.group(3):
title = m.group(3).strip()[1:]
else:
title = label
a = etree.Element("a")
a.text = title
a.set("href", url)
a.set("title", title)
a.set("class", "reference wiki")
return a
SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
class RelativeLinksTreeprocessor(Treeprocessor):
def __init__(self, md, project):
self.project = project
super().__init__(md)
def run(self, root):
links = root.getiterator("a")
for a in links:
href = a.get("href", "")
if SLUG_RE.search(href):
# [wiki](wiki_page) -> <a href="FRONT_HOST/.../wiki/wiki_page" ...
url = resolve("wiki", self.project.slug, href)
a.set("href", url)
a.set("class", "reference wiki")
elif href and href[0] == "/":
# [some link](/some/link) -> <a href="FRONT_HOST/some/link" ...
url = "{}{}".format(resolve("home"), href[1:])
a.set("href", url)

View File

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

View File

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

View File

@ -16,12 +16,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from .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
def _get_user_project_membership(user, project):
Membership = apps.get_model("projects", "Membership")
if user.is_anonymous():
return None
@ -39,10 +38,17 @@ def _get_object_project(obj):
def is_project_owner(user, obj):
"""
The owner attribute of a project is just an historical reference
"""
project = _get_object_project(obj)
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:
return True
@ -51,7 +57,7 @@ def is_project_owner(user, obj):
return False
membership = _get_user_project_membership(user, project)
if membership and membership.is_owner:
if membership and membership.is_admin:
return True
return False
@ -79,43 +85,41 @@ def _get_membership_permissions(membership):
def get_user_project_permissions(user, project):
membership = _get_user_project_membership(user, project)
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))
public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS))
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
elif membership:
if membership.is_owner:
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS))
if membership.is_admin:
admins_permissions = list(map(lambda perm: perm[0], ADMINS_PERMISSIONS))
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
else:
owner_permissions = []
admins_permissions = []
members_permissions = []
members_permissions = members_permissions + _get_membership_permissions(membership)
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 []
elif user.is_authenticated():
owner_permissions = []
admins_permissions = []
members_permissions = []
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 []
else:
owner_permissions = []
admins_permissions = []
members_permissions = []
public_permissions = []
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):
if project.is_private:
project.anon_permissions = []
project.public_permissions = []
else:
"""
If a project is public anonymous and registered users should have at least visualization permissions
"""
# If a project is public anonymous and registered users should have at
# least visualization permissions.
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
project.anon_permissions = list(set((project.anon_permissions 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.utils.translation import ugettext as _
from django.utils import timezone
from django.http import Http404
from taiga.base import filters
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 detail_route
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.utils import get_object_or_404
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.likes.mixins.viewsets import LikedResourceMixin, FansViewSetMixin
from taiga.permissions import service as permissions_service
from taiga.users import services as users_service
from . import filters as project_filters
from . import models
@ -61,7 +64,9 @@ from . import services
######################################################
## Project
######################################################
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet):
class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin,
BlockeableSaveMixin, BlockeableDeleteMixin, ModelCrudViewSet):
queryset = models.Project.objects.all()
serializer_class = serializers.ProjectDetailSerializer
admin_serializer_class = serializers.ProjectDetailAdminSerializer
@ -88,6 +93,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
"total_activity_last_month",
"total_activity_last_year")
def is_blocked(self, obj):
return obj.blocked_code is not None
def _get_order_by_field_name(self):
order_by_query_param = project_filters.CanViewProjectObjFilterBackend.order_by_query_param
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):
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)
# so we add some custom prefetching
qs = qs.prefetch_related("members")
qs = qs.prefetch_related("memberships")
qs = qs.prefetch_related(Prefetch("notify_policies",
NotifyPolicy.objects.exclude(notify_level=NotifyLevel.none), to_attr="valid_notify_policies"))
@ -137,7 +147,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
else:
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
return serializer_class
@ -158,6 +168,8 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
except Exception:
raise exc.WrongArguments(_("Invalid image format"))
self.pre_conditions_on_save(self.object)
self.object.logo = 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.check_permissions(request, "remove_logo", self.object)
self.pre_conditions_on_save(self.object)
self.object.logo = None
self.object.save(update_fields=["logo"])
@ -182,6 +194,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def watch(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "watch", project)
self.pre_conditions_on_save(project)
notify_level = request.DATA.get("notify_level", NotifyLevel.involved)
project.add_watcher(self.request.user, notify_level=notify_level)
return response.Ok()
@ -190,6 +203,7 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def unwatch(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "unwatch", project)
self.pre_conditions_on_save(project)
user = self.request.user
project.remove_watcher(user)
return response.Ok()
@ -207,77 +221,6 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
services.update_projects_order_in_bulk(data, "user_order", request.user)
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"])
def create_template(self, request, **kwargs):
template_name = request.DATA.get('template_name', None)
@ -305,13 +248,161 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
template.save()
return response.Created(serializers.ProjectTemplateSerializer(template).data)
@detail_route(methods=['post'])
@detail_route(methods=['POST'])
def leave(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'leave', project)
self.pre_conditions_on_save(project)
services.remove_user_from_project(request.user, project)
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):
update_permissions = False
if not obj.id:
@ -329,9 +420,15 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
def pre_save(self, obj):
if not obj.id:
obj.owner = self.request.user
# TODO REFACTOR THIS
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)
super().pre_save(obj)
@ -342,10 +439,9 @@ class ProjectViewSet(LikedResourceMixin, HistoryResourceMixin, ModelCrudViewSet)
if obj is None:
raise Http404
obj.delete_related_content()
self.pre_delete(obj)
self.pre_conditions_on_delete(obj)
obj.delete_related_content()
obj.delete()
self.post_delete(obj)
return response.NoContent()
@ -365,7 +461,9 @@ class ProjectWatchersViewSet(WatchersViewSetMixin, ModelListViewSet):
## Custom values for selectors
######################################################
class PointsViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class PointsViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Points
serializer_class = serializers.PointsSerializer
permission_classes = (permissions.PointsPermission,)
@ -379,7 +477,9 @@ class PointsViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
move_on_destroy_project_default_field = "default_points"
class UserStoryStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class UserStoryStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.UserStoryStatus
serializer_class = serializers.UserStoryStatusSerializer
permission_classes = (permissions.UserStoryStatusPermission,)
@ -393,7 +493,9 @@ class UserStoryStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrd
move_on_destroy_project_default_field = "default_us_status"
class TaskStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class TaskStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.TaskStatus
serializer_class = serializers.TaskStatusSerializer
permission_classes = (permissions.TaskStatusPermission,)
@ -407,7 +509,9 @@ class TaskStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMix
move_on_destroy_project_default_field = "default_task_status"
class SeverityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class SeverityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Severity
serializer_class = serializers.SeveritySerializer
permission_classes = (permissions.SeverityPermission,)
@ -421,7 +525,8 @@ class SeverityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin
move_on_destroy_project_default_field = "default_severity"
class PriorityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class PriorityViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.Priority
serializer_class = serializers.PrioritySerializer
permission_classes = (permissions.PriorityPermission,)
@ -435,7 +540,8 @@ class PriorityViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin
move_on_destroy_project_default_field = "default_priority"
class IssueTypeViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class IssueTypeViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.IssueType
serializer_class = serializers.IssueTypeSerializer
permission_classes = (permissions.IssueTypePermission,)
@ -449,7 +555,8 @@ class IssueTypeViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixi
move_on_destroy_project_default_field = "default_issue_type"
class IssueStatusViewSet(MoveOnDestroyMixin, ModelCrudViewSet, BulkUpdateOrderMixin):
class IssueStatusViewSet(MoveOnDestroyMixin, BlockedByProjectMixin,
ModelCrudViewSet, BulkUpdateOrderMixin):
model = models.IssueStatus
serializer_class = serializers.IssueStatusSerializer
permission_classes = (permissions.IssueStatusPermission,)
@ -480,7 +587,7 @@ class ProjectTemplateViewSet(ModelCrudViewSet):
## Members & Invitations
######################################################
class MembershipViewSet(ModelCrudViewSet):
class MembershipViewSet(BlockedByProjectMixin, ModelCrudViewSet):
model = models.Membership
admin_serializer_class = serializers.MembershipAdminSerializer
serializer_class = serializers.MembershipSerializer
@ -490,17 +597,17 @@ class MembershipViewSet(ModelCrudViewSet):
def get_serializer_class(self):
use_admin_serializer = False
if self.action == "create":
use_admin_serializer = True
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)
if self.action == "list" and project_id is not None:
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:
return self.admin_serializer_class
@ -518,10 +625,22 @@ class MembershipViewSet(ModelCrudViewSet):
project = models.Project.objects.get(id=data["project_id"])
invitation_extra_text = data.get("invitation_extra_text", None)
self.check_permissions(request, 'bulk_create', project)
if project.blocked_code is not None:
raise exc.Blocked(_("Blocked element"))
# TODO: this should be moved to main exception handler instead
# 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:
members = services.create_members_in_bulk(data["bulk_memberships"],
project=project,
@ -539,15 +658,26 @@ class MembershipViewSet(ModelCrudViewSet):
invitation = self.get_object()
self.check_permissions(request, 'resend_invitation', invitation.project)
self.pre_conditions_on_save(invitation)
services.send_invitation(invitation=invitation)
return response.NoContent()
def pre_delete(self, obj):
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project):
raise exc.BadRequest(_("At least one of the user must be an active admin"))
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):
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:
obj.token = str(uuid.uuid1())

View File

@ -19,12 +19,11 @@ from django.apps import AppConfig
from django.apps import apps
from django.db.models import signals
from . import signals as handlers
## Project Signals
def connect_projects_signals():
from . import signals as handlers
# On project object is created apply template.
signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"),
@ -51,6 +50,7 @@ def disconnect_projects_signals():
## Memberships Signals
def connect_memberships_signals():
from . import signals as handlers
# On membership object is deleted, update role-points relation.
signals.pre_delete.connect(handlers.membership_post_delete,
sender=apps.get_model("projects", "Membership"),
@ -71,6 +71,7 @@ def disconnect_memberships_signals():
## US Statuses 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,
sender=apps.get_model("projects", "UserStoryStatus"),
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
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,
sender=apps.get_model("projects", "TaskStatus"),
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/>.
from django.contrib import admin
from django.contrib.contenttypes import generic
from django.contrib.contenttypes.admin import GenericTabularInline
from . import models
@ -38,7 +38,7 @@ class AttachmentAdmin(admin.ModelAdmin):
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class AttachmentInline(generic.GenericTabularInline):
class AttachmentInline(GenericTabularInline):
model = models.Attachment
fields = ("attached_file", "owner")
extra = 0

View File

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

View File

@ -20,7 +20,7 @@ import hashlib
from django.db import models
from django.conf import settings
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.translation import ugettext_lazy as _
from django.utils.text import get_valid_filename
@ -42,7 +42,7 @@ class Attachment(models.Model):
verbose_name=_("content type"))
object_id = models.PositiveIntegerField(null=False, blank=False,
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,
verbose_name=_("created date"),
default=timezone.now)

View File

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

View File

@ -21,9 +21,11 @@ from django.utils.translation import ugettext_lazy as _
TEXT_TYPE = "text"
MULTILINE_TYPE = "multiline"
DATE_TYPE = "date"
URL_TYPE = "url"
TYPES_CHOICES = (
(TEXT_TYPE, _("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 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 IsSuperUser
@ -27,39 +27,39 @@ from taiga.base.api.permissions import IsSuperUser
#######################################################
class UserStoryCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner()
update_perms = IsProjectOwner()
partial_update_perms = IsProjectOwner()
destroy_perms = IsProjectOwner()
create_perms = IsProjectAdmin()
update_perms = IsProjectAdmin()
partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectAdmin()
list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner()
bulk_update_order_perms = IsProjectAdmin()
class TaskCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner()
update_perms = IsProjectOwner()
partial_update_perms = IsProjectOwner()
destroy_perms = IsProjectOwner()
create_perms = IsProjectAdmin()
update_perms = IsProjectAdmin()
partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectAdmin()
list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner()
bulk_update_order_perms = IsProjectAdmin()
class IssueCustomAttributePermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_project')
create_perms = IsProjectOwner()
update_perms = IsProjectOwner()
partial_update_perms = IsProjectOwner()
destroy_perms = IsProjectOwner()
create_perms = IsProjectAdmin()
update_perms = IsProjectAdmin()
partial_update_perms = IsProjectAdmin()
destroy_perms = IsProjectAdmin()
list_perms = AllowAny()
bulk_update_order_perms = IsProjectOwner()
bulk_update_order_perms = IsProjectAdmin()
######################################################
@ -67,7 +67,7 @@ class IssueCustomAttributePermission(TaigaResourcePermission):
#######################################################
class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_us')
update_perms = HasProjectPerm('modify_us')
@ -75,7 +75,7 @@ class UserStoryCustomAttributesValuesPermission(TaigaResourcePermission):
class TaskCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_tasks')
update_perms = HasProjectPerm('modify_task')
@ -83,7 +83,7 @@ class TaskCustomAttributesValuesPermission(TaigaResourcePermission):
class IssueCustomAttributesValuesPermission(TaigaResourcePermission):
enought_perms = IsProjectOwner() | IsSuperUser()
enought_perms = IsProjectAdmin() | IsSuperUser()
global_perms = None
retrieve_perms = HasProjectPerm('view_issues')
update_perms = HasProjectPerm('modify_issue')

View File

@ -37,7 +37,8 @@ class DiscoverModeFilterBackend(FilterBackend):
if discover_mode:
# 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)
@ -70,7 +71,7 @@ class CanViewProjectObjFilterBackend(FilterBackend):
if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) |
Q(is_owner=True))
Q(is_admin=True))
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 django.apps import apps
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
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_dict
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
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)
qs = user_model.objects.filter(pk__in=tuple(ids))

View File

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

View File

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

View File

@ -16,24 +16,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext as _
from django.db.models import Q
from django.http import HttpResponse
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base import response
from taiga.base.decorators import detail_route, list_route
from taiga.base.decorators import list_route
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.users.models import User
from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin
from taiga.projects.occ import OCCResourceMixin
from taiga.projects.history.mixins import HistoryResourceMixin
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 . import models
@ -43,7 +40,7 @@ from . import serializers
class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
ModelCrudViewSet):
BlockedByProjectMixin, ModelCrudViewSet):
queryset = models.Issue.objects.all()
permission_classes = (permissions.IssuePermission, )
filter_backends = (filters.CanViewIssuesFilterBackend,
@ -157,8 +154,6 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
super().pre_save(obj)
def pre_conditions_on_save(self, obj):
super().pre_conditions_on_save(obj)
if obj.milestone and obj.milestone.project != obj.project:
raise exc.PermissionDenied(_("You don't have permissions to set this sprint "
"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 "
"to this issue."))
super().pre_conditions_on_save(obj)
@list_route(methods=["GET"])
def by_ref(self, request):
ref = request.QUERY_PARAMS.get("ref", None)
@ -232,6 +229,9 @@ class IssueViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, W
data = serializer.data
project = Project.objects.get(pk=data["project_id"])
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(
data["bulk_issues"], project=project, owner=request.user,
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.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():
from taiga.projects import signals as generic_handlers
from . import signals as handlers
# Finished date
signals.pre_save.connect(handlers.set_finished_date_when_edit_issue,
sender=apps.get_model("issues", "Issue"),
@ -43,6 +42,8 @@ def connect_issues_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,
sender=apps.get_model("issues", "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/>.
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.utils import timezone
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,
default=None, related_name="issues_assigned_to_me",
verbose_name=_("assigned to"))
attachments = generic.GenericRelation("attachments.Attachment")
attachments = GenericRelation("attachments.Attachment")
external_reference = TextArrayField(default=None, verbose_name=_("external reference"))
_importing = None

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