Fixing merge

remotes/origin/enhancement/email-actions 1.4.0
Alejandro Alonso 2014-12-10 09:19:41 +01:00
commit 7abeb74eee
123 changed files with 3298 additions and 769 deletions

View File

@ -18,7 +18,6 @@ notifications:
- jespinog@gmail.com - jespinog@gmail.com
- andrei.antoukh@gmail.com - andrei.antoukh@gmail.com
- bameda@dbarragan.com - bameda@dbarragan.com
- anler86@gmail.com
on_success: change on_success: change
on_failure: change on_failure: change
after_success: after_success:

View File

@ -1,5 +1,28 @@
# Changelog # # Changelog #
## 1.4.0 Abies veitchii (2014-12-10)
### Features
- Bitbucket integration:
+ Change status of user stories, tasks and issues with the commit messages.
- Gitlab integration:
+ Change status of user stories, tasks and issues with the commit messages.
+ Sync issues creation in Taiga from Gitlab.
- Support throttling.
+ for anonymous users
+ for authenticated users
+ in import mode
- Add project members stats endpoint.
- Support of leave project.
- Control of leave a project without admin user.
- Improving OCC (Optimistic concurrency control)
- Improving some SQL queries using djrom directly
### Misc
- Lots of small and not so small bugfixes.
## 1.3.0 Dryas hookeriana (2014-11-18) ## 1.3.0 Dryas hookeriana (2014-11-18)
### Features ### Features

View File

@ -7,7 +7,7 @@ Examples of contributions include:
- Code patches. - Code patches.
- Documentation improvements. - Documentation improvements.
- Bug reports. - Bug reports.
- Patch reviews. - UI enhancements
Before start developing one big feature (with intentions of including it on taiga code base), it is Before start developing one big feature (with intentions of including it on taiga code base), it is
strongly recommended chat about it using our [mailing list](http://groups.google.com/d/forum/taigaio). strongly recommended chat about it using our [mailing list](http://groups.google.com/d/forum/taigaio).

View File

@ -31,3 +31,9 @@ scripts https://github.com/taigaio/taiga-scripts (warning: alpha state)
[Taiga has a mailing list](http://groups.google.com/d/forum/taigaio). Feel free to join it and ask any questions you may have. [Taiga has a mailing list](http://groups.google.com/d/forum/taigaio). Feel free to join it and ask any questions you may have.
To subscribe for announcements of releases, important changes and so on, please follow [@taigaio](https://twitter.com/taigaio) on Twitter. To subscribe for announcements of releases, important changes and so on, please follow [@taigaio](https://twitter.com/taigaio) on Twitter.
## Donations ##
We are grateful for your emails volunteering donations to Taiga. We feel comfortable accepting them under these conditions: The first that we will only do so while we are in the current beta / pre-revenue stage and that whatever money is donated will go towards a bounty fund. Starting Q2 2015 we will be engaging much more actively with our community to help further the development of Taiga, and we will use these donations to reward people working alongside us.
If you wish to make a donation to this Taiga fund, you can do so via http://www.paypal.com using the email: eposner@taiga.io

View File

@ -26,6 +26,7 @@ redis==2.10.3
Unidecode==0.04.16 Unidecode==0.04.16
raven==5.1.1 raven==5.1.1
bleach==1.4 bleach==1.4
django-ipware==0.1.0
# Comment it if you are using python >= 3.4 # Comment it if you are using python >= 3.4
enum34==1.0 enum34==1.0

View File

@ -194,7 +194,9 @@ INSTALLED_APPS = [
"taiga.mdrender", "taiga.mdrender",
"taiga.export_import", "taiga.export_import",
"taiga.feedback", "taiga.feedback",
"taiga.github_hook", "taiga.hooks.github",
"taiga.hooks.gitlab",
"taiga.hooks.bitbucket",
"rest_framework", "rest_framework",
"djmail", "djmail",
@ -291,6 +293,15 @@ REST_FRAMEWORK = {
# Mainly used for api debug. # Mainly used for api debug.
"taiga.auth.backends.Session", "taiga.auth.backends.Session",
), ),
"DEFAULT_THROTTLE_CLASSES": (
"taiga.base.throttling.AnonRateThrottle",
"taiga.base.throttling.UserRateThrottle"
),
"DEFAULT_THROTTLE_RATES": {
"anon": None,
"user": None,
"import-mode": None
},
"FILTER_BACKEND": "taiga.base.filters.FilterBackend", "FILTER_BACKEND": "taiga.base.filters.FilterBackend",
"EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler", "EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler",
"PAGINATE_BY": 30, "PAGINATE_BY": 30,
@ -299,6 +310,7 @@ REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z" "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z"
} }
DEFAULT_PROJECT_TEMPLATE = "scrum" DEFAULT_PROJECT_TEMPLATE = "scrum"
PUBLIC_REGISTER_ENABLED = False PUBLIC_REGISTER_ENABLED = False
@ -342,9 +354,13 @@ CHANGE_NOTIFICATIONS_MIN_INTERVAL = 0 #seconds
# List of functions called for filling correctly the ProjectModulesConfig associated to a project # List of functions called for filling correctly the ProjectModulesConfig associated to a project
# This functions should receive a Project parameter and return a dict with the desired configuration # This functions should receive a Project parameter and return a dict with the desired configuration
PROJECT_MODULES_CONFIGURATORS = { PROJECT_MODULES_CONFIGURATORS = {
"github": "taiga.github_hook.services.get_or_generate_config", "github": "taiga.hooks.github.services.get_or_generate_config",
"gitlab": "taiga.hooks.gitlab.services.get_or_generate_config",
"bitbucket": "taiga.hooks.bitbucket.services.get_or_generate_config",
} }
BITBUCKET_VALID_ORIGIN_IPS = ["131.103.20.165", "131.103.20.166"]
GITLAB_VALID_ORIGIN_IPS = []
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
TEST_RUNNER="django.test.runner.DiscoverRunner" TEST_RUNNER="django.test.runner.DiscoverRunner"

View File

@ -32,20 +32,29 @@ from .development import *
#MEDIA_ROOT = '/home/taiga/media' #MEDIA_ROOT = '/home/taiga/media'
#STATIC_ROOT = '/home/taiga/static' #STATIC_ROOT = '/home/taiga/static'
# EMAIL SETTINGS EXAMPLE
#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
#EMAIL_USE_TLS = False #EMAIL_USE_TLS = False
#EMAIL_HOST = 'localhost' #EMAIL_HOST = 'localhost'
#EMAIL_PORT = 25
#EMAIL_HOST_USER = 'user' #EMAIL_HOST_USER = 'user'
#EMAIL_HOST_PASSWORD = 'password' #EMAIL_HOST_PASSWORD = 'password'
#EMAIL_PORT = 25
#DEFAULT_FROM_EMAIL = "john@doe.com" #DEFAULT_FROM_EMAIL = "john@doe.com"
# GMAIL SETTINGS EXAMPLE # GMAIL SETTINGS EXAMPLE
#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' #EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
#EMAIL_USE_TLS = True #EMAIL_USE_TLS = True
#EMAIL_HOST = 'smtp.gmail.com' #EMAIL_HOST = 'smtp.gmail.com'
#EMAIL_PORT = 587
#EMAIL_HOST_USER = 'youremail@gmail.com' #EMAIL_HOST_USER = 'youremail@gmail.com'
#EMAIL_HOST_PASSWORD = 'yourpassword' #EMAIL_HOST_PASSWORD = 'yourpassword'
#EMAIL_PORT = 587
# THROTTLING
#REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
# "anon": "20/min",
# "user": "200/min",
# "import-mode": "20/sec"
#}
# GITHUB SETTINGS # GITHUB SETTINGS
#GITHUB_URL = "https://github.com/" #GITHUB_URL = "https://github.com/"

View File

@ -24,3 +24,9 @@ MEDIA_ROOT = "/tmp"
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
INSTALLED_APPS = INSTALLED_APPS + ["tests"] INSTALLED_APPS = INSTALLED_APPS + ["tests"]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"anon": None,
"user": None,
"import-mode": None
}

View File

@ -22,7 +22,6 @@ import warnings
from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.core.paginator import Paginator, InvalidPage from django.core.paginator import Paginator, InvalidPage
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 as _get_object_or_404
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework import exceptions from rest_framework import exceptions
@ -31,6 +30,7 @@ from rest_framework.settings import api_settings
from . import views from . import views
from . import mixins from . import mixins
from .utils import get_object_or_404
def strict_positive_int(integer_string, cutoff=None): def strict_positive_int(integer_string, cutoff=None):
@ -45,17 +45,6 @@ def strict_positive_int(integer_string, cutoff=None):
return ret return ret
def get_object_or_404(queryset, *filter_args, **filter_kwargs):
"""
Same as Django's standard shortcut, but make sure to raise 404
if the filter_kwargs don't match the required types.
"""
try:
return _get_object_or_404(queryset, *filter_args, **filter_kwargs)
except (TypeError, ValueError):
raise Http404
class GenericAPIView(views.APIView): class GenericAPIView(views.APIView):
""" """
Base class for all other generic views. Base class for all other generic views.

View File

@ -20,7 +20,6 @@
import warnings import warnings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.shortcuts import get_object_or_404
from django.http import Http404 from django.http import Http404
from django.db import transaction as tx from django.db import transaction as tx
@ -29,6 +28,8 @@ from rest_framework.response import Response
from rest_framework.request import clone_request from rest_framework.request import clone_request
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from .utils import get_object_or_404
def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None): def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None):
""" """

32
taiga/base/api/utils.py Normal file
View File

@ -0,0 +1,32 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# This code is partially taken from django-rest-framework:
# Copyright (c) 2011-2014, Tom Christie
from django.http import Http404
from django.shortcuts import get_object_or_404 as _get_object_or_404
def get_object_or_404(queryset, *filter_args, **filter_kwargs):
"""
Same as Django's standard shortcut, but make sure to raise 404
if the filter_kwargs don't match the required types.
"""
try:
return _get_object_or_404(queryset, *filter_args, **filter_kwargs)
except (TypeError, ValueError):
raise Http404

View File

@ -103,28 +103,13 @@ class PermissionBasedFilterBackend(FilterBackend):
memberships_qs = Membership.objects.filter(user=request.user) memberships_qs = Membership.objects.filter(user=request.user)
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | Q(is_owner=True))
# Force users_role table inclusion
memberships_qs = memberships_qs.exclude(role__slug="not valid slug")
where_sql = ["users_role.permissions @> ARRAY['{}']".format(self.permission)]
memberships_qs = memberships_qs.extra(where=where_sql)
projects_list = [membership.project_id for membership in memberships_qs] projects_list = [membership.project_id for membership in memberships_qs]
if len(projects_list) == 0: qs = qs.filter(Q(project_id__in=projects_list) | Q(project__public_permissions__contains=[self.permission]))
qs = qs.filter(Q(project__owner=request.user))
elif len(projects_list) == 1:
qs = qs.filter(Q(project__owner=request.user) | Q(project=projects_list[0]))
else: else:
qs = qs.filter(Q(project__owner=request.user) | Q(project__in=projects_list)) qs = qs.filter(project__anon_permissions__contains=[self.permission])
extra_where = ExtraWhere(["projects_project.public_permissions @> ARRAY['{}']".format(
self.permission)], [])
qs.query.where.add(extra_where, OR)
else:
qs = qs.exclude(project__owner=-1)
extra_where = ExtraWhere(["projects_project.anon_permissions @> ARRAY['{}']".format(
self.permission)], [])
qs.query.where.add(extra_where, AND)
return super().filter_queryset(request, qs.distinct(), view) return super().filter_queryset(request, qs.distinct(), view)
@ -197,19 +182,13 @@ class CanViewProjectObjFilterBackend(FilterBackend):
memberships_qs = Membership.objects.filter(user=request.user) memberships_qs = Membership.objects.filter(user=request.user)
if project_id: if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id) memberships_qs = memberships_qs.filter(project_id=project_id)
memberships_qs = memberships_qs.exclude(role__slug="not valid slug") # Force users_role table inclusion memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) | Q(is_owner=True))
memberships_qs = memberships_qs.extra(where=["users_role.permissions @> ARRAY['view_project']"])
projects_list = [membership.project_id for membership in memberships_qs] projects_list = [membership.project_id for membership in memberships_qs]
if len(projects_list) == 0: qs = qs.filter(Q(id__in=projects_list) | Q(public_permissions__contains=["view_project"]))
qs = qs.filter(Q(owner=request.user))
elif len(projects_list) == 1:
qs = qs.filter(Q(owner=request.user) | Q(id=projects_list[0]))
else: else:
qs = qs.filter(Q(owner=request.user) | Q(id__in=projects_list)) qs = qs.filter(public_permissions__contains=["view_project"])
qs.query.where.add(ExtraWhere(["projects_project.public_permissions @> ARRAY['view_project']"], []), OR)
else:
qs.query.where.add(ExtraWhere(["projects_project.anon_permissions @> ARRAY['view_project']"], []), AND)
return super().filter_queryset(request, qs.distinct(), view) return super().filter_queryset(request, qs.distinct(), view)
@ -219,8 +198,7 @@ class IsProjectMemberFilterBackend(FilterBackend):
if request.user.is_authenticated() and request.user.is_superuser: if request.user.is_authenticated() and request.user.is_superuser:
queryset = queryset queryset = queryset
elif request.user.is_authenticated(): elif request.user.is_authenticated():
queryset = queryset.filter(Q(project__members=request.user) | queryset = queryset.filter(project__members=request.user)
Q(project__owner=request.user))
else: else:
queryset = queryset.none() queryset = queryset.none()
@ -232,12 +210,16 @@ class TagsFilter(FilterBackend):
self.filter_name = filter_name self.filter_name = filter_name
def _get_tags_queryparams(self, params): def _get_tags_queryparams(self, params):
return params.get(self.filter_name, "") tags = params.get(self.filter_name, None)
if tags:
return tags.split(",")
return None
def filter_queryset(self, request, queryset, view): def filter_queryset(self, request, queryset, view):
query_tags = self._get_tags_queryparams(request.QUERY_PARAMS) query_tags = self._get_tags_queryparams(request.QUERY_PARAMS)
if query_tags: if query_tags:
queryset = tags.filter(queryset, contains=query_tags) queryset = queryset.filter(tags__contains=query_tags)
return super().filter_queryset(request, queryset, view) return super().filter_queryset(request, queryset, view)

View File

@ -29,94 +29,3 @@ class TaggedMixin(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def get_queryset_table(queryset):
"""Return queryset model's table name"""
return queryset.model._meta.db_table
def _filter_bin(queryset, value, operator):
"""tags <operator> <value>"""
if not isinstance(value, str):
value = ",".join(value)
sql = "{table_name}.tags {operator} string_to_array(%s, ',')"
where_clause = sql.format(table_name=get_queryset_table(queryset), operator=operator)
queryset = queryset.extra(where=[where_clause], params=[value])
return queryset
_filter_contains = partial(_filter_bin, operator="@>")
_filter_contained_by = partial(_filter_bin, operator="<@")
_filter_overlap = partial(_filter_bin, operator="&&")
def _filter_index(queryset, index, value):
"""tags[<index>] == <value>"""
sql = "{table_name}.tags[{index}] = %s"
where_clause = sql.format(table_name=get_queryset_table(queryset), index=index)
queryset = queryset.extra(where=[where_clause], params=[value])
return queryset
def _filter_len(queryset, value):
"""len(tags) == <value>"""
sql = "array_length({table_name}.tags, 1) = %s"
where_clause = sql.format(table_name=get_queryset_table(queryset))
queryset = queryset.extra(where=[where_clause], params=[value])
return queryset
def _filter_len_operator(queryset, value, operator):
"""len(tags) <operator> <value>"""
operator = {"gt": ">", "lt": "<", "gte": ">=", "lte": "<="}[operator]
sql = "array_length({table_name}.tags, 1) {operator} %s"
where_clause = sql.format(table_name=get_queryset_table(queryset), operator=operator)
queryset = queryset.extra(where=[where_clause], params=[value])
return queryset
def _filter_index_operator(queryset, value, operator):
"""tags[<operator>] == value"""
index = int(operator) + 1
sql = "{table_name}.tags[{index}] = %s"
where_clause = sql.format(table_name=get_queryset_table(queryset), index=index)
queryset = queryset.extra(where=[where_clause], params=[value])
return queryset
def _tags_filter(**filters_map):
filter_re = re.compile(r"""(?:(len__)(gte|lte|lt|gt)
|
(index__)(\d+))""", re.VERBOSE)
def get_filter(filter_name, strict=False):
return filters_map[filter_name] if strict else filters_map.get(filter_name)
def get_filter_matching(filter_name):
match = filter_re.search(filter_name)
filter_name, operator = (group for group in match.groups() if group)
return partial(get_filter(filter_name, strict=True), operator=operator)
def tags_filter(model_or_qs, **filters):
"Filter a queryset but adding support to filters that work with postgresql array fields"
if hasattr(model_or_qs, "_meta"):
qs = model_or_qs._default_manager.get_queryset()
else:
qs = model_or_qs
for filter_name, filter_value in filters.items():
try:
filter = get_filter(filter_name) or get_filter_matching(filter_name)
except (LookupError, AttributeError):
qs = qs.filter(**{filter_name: filter_value})
else:
qs = filter(queryset=qs, value=filter_value)
return qs
return tags_filter
filter = _tags_filter(contains=_filter_contains,
contained_by=_filter_contained_by,
overlap=_filter_overlap,
len=_filter_len,
len__=_filter_len_operator,
index__=_filter_index_operator)

25
taiga/base/throttling.py Normal file
View File

@ -0,0 +1,25 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework import throttling
class AnonRateThrottle(throttling.AnonRateThrottle):
scope = "anon"
class UserRateThrottle(throttling.UserRateThrottle):
scope = "user"

View File

@ -28,6 +28,7 @@ from taiga.base.decorators import detail_route
from taiga.projects.models import Project, Membership from taiga.projects.models import Project, Membership
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from . import mixins
from . import serializers from . import serializers
from . import service from . import service
from . import permissions from . import permissions
@ -37,7 +38,7 @@ class Http400(APIException):
status_code = 400 status_code = 400
class ProjectImporterViewSet(CreateModelMixin, GenericViewSet): class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet):
model = Project model = Project
permission_classes = (permissions.ImportPermission, ) permission_classes = (permissions.ImportPermission, )

View File

@ -0,0 +1,21 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import throttling
class ImportThrottlingPolicyMixin:
throttle_classes = (throttling.ImportModeRateThrottle,)

View File

@ -0,0 +1,21 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base import throttling
class ImportModeRateThrottle(throttling.UserRateThrottle):
scope = "import-mode"

View File

@ -22,44 +22,20 @@ from taiga.base import exceptions as exc
from taiga.base.utils import json from taiga.base.utils import json
from taiga.projects.models import Project from taiga.projects.models import Project
from . import event_hooks
from .exceptions import ActionSyntaxException from .exceptions import ActionSyntaxException
import hmac
import hashlib
class BaseWebhookApiViewSet(GenericViewSet):
class GitHubViewSet(GenericViewSet):
# We don't want rest framework to parse the request body and transform it in # We don't want rest framework to parse the request body and transform it in
# a dict in request.DATA, we need it raw # a dict in request.DATA, we need it raw
parser_classes = () parser_classes = ()
# This dict associates the event names we are listening for # This dict associates the event names we are listening for
# with their reponsible classes (extending event_hooks.BaseEventHook) # with their reponsible classes (extending event_hooks.BaseEventHook)
event_hook_classes = { event_hook_classes = {}
"push": event_hooks.PushEventHook,
"issues": event_hooks.IssuesEventHook,
"issue_comment": event_hooks.IssueCommentEventHook,
}
def _validate_signature(self, project, request): def _validate_signature(self, project, request):
x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None) raise NotImplemented
if not x_hub_signature:
return False
sha_name, signature = x_hub_signature.split('=')
if sha_name != 'sha1':
return False
if not hasattr(project, "modules_config"):
return False
if project.modules_config.config is None:
return False
secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8"))
mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1)
return hmac.compare_digest(mac.hexdigest(), signature)
def _get_project(self, request): def _get_project(self, request):
project_id = request.GET.get("project", None) project_id = request.GET.get("project", None)
@ -69,6 +45,16 @@ class GitHubViewSet(GenericViewSet):
except Project.DoesNotExist: except Project.DoesNotExist:
return None return None
def _get_payload(self, request):
try:
payload = json.loads(request.body.decode("utf-8"))
except ValueError:
raise exc.BadRequest(_("The payload is not a valid json"))
return payload
def _get_event_name(self, request):
raise NotImplemented
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
project = self._get_project(request) project = self._get_project(request)
if not project: if not project:
@ -77,12 +63,9 @@ class GitHubViewSet(GenericViewSet):
if not self._validate_signature(project, request): if not self._validate_signature(project, request):
raise exc.BadRequest(_("Bad signature")) raise exc.BadRequest(_("Bad signature"))
event_name = request.META.get("HTTP_X_GITHUB_EVENT", None) event_name = self._get_event_name(request)
try: payload = self._get_payload(request)
payload = json.loads(request.body.decode("utf-8"))
except ValueError:
raise exc.BadRequest(_("The payload is not a valid json"))
event_hook_class = self.event_hook_classes.get(event_name, None) event_hook_class = self.event_hook_classes.get(event_name, None)
if event_hook_class is not None: if event_hook_class is not None:

View File

@ -0,0 +1,80 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework.response import Response
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from taiga.base.api.viewsets import GenericViewSet
from taiga.base import exceptions as exc
from taiga.base.utils import json
from taiga.projects.models import Project
from taiga.hooks.api import BaseWebhookApiViewSet
from . import event_hooks
from ..exceptions import ActionSyntaxException
from urllib.parse import parse_qs
from ipware.ip import get_real_ip
class BitBucketViewSet(BaseWebhookApiViewSet):
event_hook_classes = {
"push": event_hooks.PushEventHook,
}
def _get_payload(self, request):
try:
body = parse_qs(request.body.decode("utf-8"), strict_parsing=True)
payload = body["payload"]
except (ValueError, KeyError):
raise exc.BadRequest(_("The payload is not a valid application/x-www-form-urlencoded"))
return payload
def _validate_signature(self, project, request):
secret_key = request.GET.get("key", None)
if secret_key is None:
return False
if not hasattr(project, "modules_config"):
return False
if project.modules_config.config is None:
return False
project_secret = project.modules_config.config.get("bitbucket", {}).get("secret", "")
if not project_secret:
return False
valid_origin_ips = project.modules_config.config.get("bitbucket", {}).get("valid_origin_ips", settings.BITBUCKET_VALID_ORIGIN_IPS)
origin_ip = get_real_ip(request)
if valid_origin_ips and (not origin_ip or not origin_ip in valid_origin_ips):
return False
return project_secret == secret_key
def _get_project(self, request):
project_id = request.GET.get("project", None)
try:
project = Project.objects.get(id=project_id)
return project
except Project.DoesNotExist:
return None
def _get_event_name(self, request):
return "push"

View File

@ -0,0 +1,102 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
import os
from django.utils.translation import ugettext_lazy as _
from taiga.base import exceptions as exc
from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
from taiga.projects.history.services import take_snapshot
from taiga.projects.notifications.services import send_notifications
from taiga.hooks.event_hooks import BaseEventHook
from taiga.hooks.exceptions import ActionSyntaxException
from .services import get_bitbucket_user
import json
class PushEventHook(BaseEventHook):
def process_event(self):
if self.payload is None:
return
# In bitbucket the payload is a list! :(
for payload_element_text in self.payload:
try:
payload_element = json.loads(payload_element_text)
except ValueError:
raise exc.BadRequest(_("The payload is not valid"))
commits = payload_element.get("commits", [])
for commit in commits:
message = commit.get("message", None)
self._process_message(message, None)
def _process_message(self, message, bitbucket_user):
"""
The message we will be looking for seems like
TG-XX #yyyyyy
Where:
XX: is the ref for us, issue or task
yyyyyy: is the status slug we are setting
"""
if message is None:
return
p = re.compile("tg-(\d+) +#([-\w]+)")
m = p.search(message.lower())
if m:
ref = m.group(1)
status_slug = m.group(2)
self._change_status(ref, status_slug, bitbucket_user)
def _change_status(self, ref, status_slug, bitbucket_user):
if Issue.objects.filter(project=self.project, ref=ref).exists():
modelClass = Issue
statusClass = IssueStatus
elif Task.objects.filter(project=self.project, ref=ref).exists():
modelClass = Task
statusClass = TaskStatus
elif UserStory.objects.filter(project=self.project, ref=ref).exists():
modelClass = UserStory
statusClass = UserStoryStatus
else:
raise ActionSyntaxException(_("The referenced element doesn't exist"))
element = modelClass.objects.get(project=self.project, ref=ref)
try:
status = statusClass.objects.get(project=self.project, slug=status_slug)
except statusClass.DoesNotExist:
raise ActionSyntaxException(_("The status doesn't exist"))
element.status = status
element.save()
snapshot = take_snapshot(element,
comment="Status changed from BitBucket commit",
user=get_bitbucket_user(bitbucket_user))
send_notifications(element, history=snapshot)
def replace_bitbucket_references(project_url, wiki_text):
template = "\g<1>[BitBucket#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url)
return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M)

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.core.files import File
import uuid
def create_github_system_user(apps, schema_editor):
# We get the model from the versioned app registry;
# if we directly import it, it'll be the wrong version
User = apps.get_model("users", "User")
db_alias = schema_editor.connection.alias
random_hash = uuid.uuid4().hex
user = User.objects.using(db_alias).create(
username="bitbucket-{}".format(random_hash),
email="bitbucket-{}@taiga.io".format(random_hash),
full_name="BitBucket",
is_active=False,
is_system=True,
bio="",
)
f = open("taiga/hooks/bitbucket/migrations/logo.png", "rb")
user.photo.save("logo.png", File(f))
user.save()
class Migration(migrations.Migration):
dependencies = [
('users', '0006_auto_20141030_1132')
]
operations = [
migrations.RunPython(create_github_system_user),
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,55 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
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
def get_or_generate_config(project):
config = project.modules_config.config
if config and "bitbucket" in config:
g_config = project.modules_config.config["bitbucket"]
else:
g_config = {
"secret": uuid.uuid4().hex,
"valid_origin_ips": settings.BITBUCKET_VALID_ORIGIN_IPS,
}
url = reverse("bitbucket-hook-list")
url = get_absolute_url(url)
url = "%s?project=%s&key=%s"%(url, project.id, g_config["secret"])
g_config["webhooks_url"] = url
return g_config
def get_bitbucket_user(user_email):
user = None
if user_email:
try:
user = User.objects.get(email=user_email)
except User.DoesNotExist:
pass
if user is None:
user = User.objects.get(is_system=True, username__startswith="bitbucket")
return user

View File

@ -0,0 +1,24 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
class BaseEventHook:
def __init__(self, project, payload):
self.project = project
self.payload = payload
def process_event(self):
raise NotImplementedError("process_event must be overwritten")

View File

59
taiga/hooks/github/api.py Normal file
View File

@ -0,0 +1,59 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework.response import Response
from django.utils.translation import ugettext_lazy as _
from taiga.base.api.viewsets import GenericViewSet
from taiga.base import exceptions as exc
from taiga.base.utils import json
from taiga.projects.models import Project
from taiga.hooks.api import BaseWebhookApiViewSet
from . import event_hooks
import hmac
import hashlib
class GitHubViewSet(BaseWebhookApiViewSet):
event_hook_classes = {
"push": event_hooks.PushEventHook,
"issues": event_hooks.IssuesEventHook,
"issue_comment": event_hooks.IssueCommentEventHook,
}
def _validate_signature(self, project, request):
x_hub_signature = request.META.get("HTTP_X_HUB_SIGNATURE", None)
if not x_hub_signature:
return False
sha_name, signature = x_hub_signature.split('=')
if sha_name != 'sha1':
return False
if not hasattr(project, "modules_config"):
return False
if project.modules_config.config is None:
return False
secret = bytes(project.modules_config.config.get("github", {}).get("secret", "").encode("utf-8"))
mac = hmac.new(secret, msg=request.body,digestmod=hashlib.sha1)
return hmac.compare_digest(mac.hexdigest(), signature)
def _get_event_name(self, request):
return request.META.get("HTTP_X_GITHUB_EVENT", None)

View File

@ -23,22 +23,14 @@ from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory from taiga.projects.userstories.models import UserStory
from taiga.projects.history.services import take_snapshot from taiga.projects.history.services import take_snapshot
from taiga.projects.notifications.services import send_notifications from taiga.projects.notifications.services import send_notifications
from taiga.hooks.event_hooks import BaseEventHook
from taiga.hooks.exceptions import ActionSyntaxException
from .exceptions import ActionSyntaxException
from .services import get_github_user from .services import get_github_user
import re import re
class BaseEventHook:
def __init__(self, project, payload):
self.project = project
self.payload = payload
def process_event(self):
raise NotImplementedError("process_event must be overwritten")
class PushEventHook(BaseEventHook): class PushEventHook(BaseEventHook):
def process_event(self): def process_event(self):
if self.payload is None: if self.payload is None:

View File

@ -20,7 +20,7 @@ def create_github_system_user(apps, schema_editor):
is_system=True, is_system=True,
bio="", bio="",
) )
f = open("taiga/github_hook/migrations/logo.png", "rb") f = open("taiga/hooks/github/migrations/logo.png", "rb")
user.photo.save("logo.png", File(f)) user.photo.save("logo.png", File(f))
user.save() user.save()

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1 @@
# This file is needed to load migrations

View File

71
taiga/hooks/gitlab/api.py Normal file
View File

@ -0,0 +1,71 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework.response import Response
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from taiga.base.api.viewsets import GenericViewSet
from taiga.base import exceptions as exc
from taiga.base.utils import json
from taiga.projects.models import Project
from taiga.hooks.api import BaseWebhookApiViewSet
from . import event_hooks
from ipware.ip import get_real_ip
class GitLabViewSet(BaseWebhookApiViewSet):
event_hook_classes = {
"push": event_hooks.PushEventHook,
"issue": event_hooks.IssuesEventHook,
}
def _validate_signature(self, project, request):
secret_key = request.GET.get("key", None)
if secret_key is None:
return False
if not hasattr(project, "modules_config"):
return False
if project.modules_config.config is None:
return False
project_secret = project.modules_config.config.get("gitlab", {}).get("secret", "")
if not project_secret:
return False
valid_origin_ips = project.modules_config.config.get("gitlab", {}).get("valid_origin_ips", settings.GITLAB_VALID_ORIGIN_IPS)
origin_ip = get_real_ip(request)
if valid_origin_ips and (not origin_ip or origin_ip not in valid_origin_ips):
return False
return project_secret == secret_key
def _get_project(self, request):
project_id = request.GET.get("project", None)
try:
project = Project.objects.get(id=project_id)
return project
except Project.DoesNotExist:
return None
def _get_event_name(self, request):
payload = json.loads(request.body.decode("utf-8"))
return payload.get('object_kind', 'push')

View File

@ -0,0 +1,127 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
import os
from django.utils.translation import ugettext_lazy as _
from taiga.projects.models import Project, IssueStatus, TaskStatus, UserStoryStatus
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
from taiga.projects.history.services import take_snapshot
from taiga.projects.notifications.services import send_notifications
from taiga.hooks.event_hooks import BaseEventHook
from taiga.hooks.exceptions import ActionSyntaxException
from .services import get_gitlab_user
class PushEventHook(BaseEventHook):
def process_event(self):
if self.payload is None:
return
commits = self.payload.get("commits", [])
for commit in commits:
message = commit.get("message", None)
self._process_message(message, None)
def _process_message(self, message, gitlab_user):
"""
The message we will be looking for seems like
TG-XX #yyyyyy
Where:
XX: is the ref for us, issue or task
yyyyyy: is the status slug we are setting
"""
if message is None:
return
p = re.compile("tg-(\d+) +#([-\w]+)")
m = p.search(message.lower())
if m:
ref = m.group(1)
status_slug = m.group(2)
self._change_status(ref, status_slug, gitlab_user)
def _change_status(self, ref, status_slug, gitlab_user):
if Issue.objects.filter(project=self.project, ref=ref).exists():
modelClass = Issue
statusClass = IssueStatus
elif Task.objects.filter(project=self.project, ref=ref).exists():
modelClass = Task
statusClass = TaskStatus
elif UserStory.objects.filter(project=self.project, ref=ref).exists():
modelClass = UserStory
statusClass = UserStoryStatus
else:
raise ActionSyntaxException(_("The referenced element doesn't exist"))
element = modelClass.objects.get(project=self.project, ref=ref)
try:
status = statusClass.objects.get(project=self.project, slug=status_slug)
except statusClass.DoesNotExist:
raise ActionSyntaxException(_("The status doesn't exist"))
element.status = status
element.save()
snapshot = take_snapshot(element,
comment="Status changed from GitLab commit",
user=get_gitlab_user(gitlab_user))
send_notifications(element, history=snapshot)
def replace_gitlab_references(project_url, wiki_text):
template = "\g<1>[GitLab#\g<2>]({}/issues/\g<2>)\g<3>".format(project_url)
return re.sub(r"(\s|^)#(\d+)(\s|$)", template, wiki_text, 0, re.M)
class IssuesEventHook(BaseEventHook):
def process_event(self):
if self.payload.get('object_attributes', {}).get("action", "") != "open":
return
subject = self.payload.get('object_attributes', {}).get('title', None)
description = self.payload.get('object_attributes', {}).get('description', None)
gitlab_reference = self.payload.get('object_attributes', {}).get('url', None)
project_url = None
if gitlab_reference:
project_url = os.path.basename(os.path.basename(gitlab_reference))
if not all([subject, gitlab_reference, project_url]):
raise ActionSyntaxException(_("Invalid issue information"))
issue = Issue.objects.create(
project=self.project,
subject=subject,
description=replace_gitlab_references(project_url, description),
status=self.project.default_issue_status,
type=self.project.default_issue_type,
severity=self.project.default_severity,
priority=self.project.default_priority,
external_reference=['gitlab', gitlab_reference],
owner=get_gitlab_user(None)
)
take_snapshot(issue, user=get_gitlab_user(None))
snapshot = take_snapshot(issue, comment="Created from GitLab", user=get_gitlab_user(None))
send_notifications(issue, history=snapshot)

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.core.files import File
import uuid
def create_github_system_user(apps, schema_editor):
# We get the model from the versioned app registry;
# if we directly import it, it'll be the wrong version
User = apps.get_model("users", "User")
db_alias = schema_editor.connection.alias
random_hash = uuid.uuid4().hex
user = User.objects.using(db_alias).create(
username="gitlab-{}".format(random_hash),
email="gitlab-{}@taiga.io".format(random_hash),
full_name="GitLab",
is_active=False,
is_system=True,
bio="",
)
f = open("taiga/hooks/gitlab/migrations/logo.png", "rb")
user.photo.save("logo.png", File(f))
user.save()
class Migration(migrations.Migration):
dependencies = [
('users', '0006_auto_20141030_1132')
]
operations = [
migrations.RunPython(create_github_system_user),
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1 @@
# This file is needed to load migrations

View File

@ -0,0 +1,55 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
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
def get_or_generate_config(project):
config = project.modules_config.config
if config and "gitlab" in config:
g_config = project.modules_config.config["gitlab"]
else:
g_config = {
"secret": uuid.uuid4().hex,
"valid_origin_ips": settings.GITLAB_VALID_ORIGIN_IPS,
}
url = reverse("gitlab-hook-list")
url = get_absolute_url(url)
url = "{}?project={}&key={}".format(url, project.id, g_config["secret"])
g_config["webhooks_url"] = url
return g_config
def get_gitlab_user(user_email):
user = None
if user_email:
try:
user = User.objects.get(email=user_email)
except User.DoesNotExist:
pass
if user is None:
user = User.objects.get(is_system=True, username__startswith="gitlab")
return user

View File

@ -54,7 +54,6 @@ class MentionsPattern(Pattern):
a = etree.Element('a') a = etree.Element('a')
a.text = link_text a.text = link_text
a.set('href', url) a.set('href', url)
a.set('alt', user.get_full_name())
a.set('title', user.get_full_name()) a.set('title', user.get_full_name())
a.set('class', "mention") a.set('class', "mention")

View File

@ -73,7 +73,6 @@ class TaigaReferencesPattern(Pattern):
a = etree.Element('a') a = etree.Element('a')
a.text = link_text a.text = link_text
a.set('href', url) a.set('href', url)
a.set('alt', subject)
a.set('title', subject) a.set('title', subject)
a.set('class', html_classes) a.set('class', html_classes)

View File

@ -15,69 +15,73 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import
from __future__ import unicode_literals
from markdown import Extension from markdown import Extension
from markdown.inlinepatterns import Pattern from markdown.inlinepatterns import Pattern
from markdown.treeprocessors import Treeprocessor
from markdown.util import etree from markdown.util import etree
from taiga.front import resolve
import re import re
def build_url(label, base, end):
""" Build a url from the label, a base, and an end. """
clean_label = re.sub(r'([ ]+_)|(_[ ]+)|([ ]+)', '_', label)
return '%s%s%s' % (base, clean_label, end)
class WikiLinkExtension(Extension): class WikiLinkExtension(Extension):
def __init__(self, configs): def __init__(self, project, *args, **kwargs):
# set extension defaults self.project = project
self.config = { return super().__init__(*args, **kwargs)
'base_url': ['/', 'String to append to beginning or URL.'],
'end_url': ['/', 'String to append to end of URL.'],
'html_class': ['wikilink', 'CSS hook. Leave blank for none.'],
'build_url': [build_url, 'Callable formats URL from label.'],
}
configs = dict(configs) or {}
# Override defaults with user settings
for key, value in configs.items():
self.setConfig(key, value)
def extendMarkdown(self, md, md_globals): def extendMarkdown(self, md, md_globals):
WIKILINK_RE = r"\[\[([\w0-9_ -]+)(\|[\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 self.md = md
super().__init__(pattern)
# append to end of inline patterns
WIKILINK_RE = r'\[\[([\w0-9_ -]+)(\|[\w0-9_ -]+)?\]\]'
wikilinkPattern = WikiLinks(WIKILINK_RE, self.getConfigs())
wikilinkPattern.md = md
md.inlinePatterns.add('wikilink', wikilinkPattern, "<not_strong")
class WikiLinks(Pattern):
def __init__(self, pattern, config):
super(WikiLinks, self).__init__(pattern)
self.config = config
def handleMatch(self, m): def handleMatch(self, m):
base_url, end_url, html_class = self._getMeta()
label = m.group(2).strip() label = m.group(2).strip()
url = self.config['build_url'](label, base_url, end_url) url = resolve("wiki", self.project.slug, label)
if m.group(3): if m.group(3):
title = m.group(3).strip()[1:] title = m.group(3).strip()[1:]
else: else:
title = label title = label
a = etree.Element('a') a = etree.Element("a")
a.text = title a.text = title
a.set('href', url) a.set("href", url)
if html_class: a.set("title", title)
a.set('class', html_class) a.set("class", "reference wiki")
return a return a
def _getMeta(self):
""" Return meta data or config data. """ SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
base_url = self.config['base_url']
end_url = self.config['end_url'] class RelativeLinksTreeprocessor(Treeprocessor):
html_class = self.config['html_class'] def __init__(self, md, project):
return base_url, end_url, html_class 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

@ -63,18 +63,19 @@ bleach.ALLOWED_ATTRIBUTES["img"] = ["alt", "src"]
bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style"] bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style"]
def _make_extensions_list(wikilinks_config=None, project=None): def _make_extensions_list(project=None):
return [AutolinkExtension(), return [AutolinkExtension(),
AutomailExtension(), AutomailExtension(),
SemiSaneListExtension(), SemiSaneListExtension(),
SpacedLinkExtension(), SpacedLinkExtension(),
StrikethroughExtension(), StrikethroughExtension(),
WikiLinkExtension(wikilinks_config), WikiLinkExtension(project),
EmojifyExtension(), EmojifyExtension(),
MentionsExtension(), MentionsExtension(),
TaigaReferencesExtension(project), TaigaReferencesExtension(project),
"extra", "extra",
"codehilite", "codehilite",
"sane_lists",
"nl2br"] "nl2br"]
@ -103,11 +104,7 @@ def _get_markdown(project):
def build_url(*args, **kwargs): def build_url(*args, **kwargs):
return args[1] + slugify(args[0]) return args[1] + slugify(args[0])
wikilinks_config = {"base_url": "/project/{}/wiki/".format(project.slug), extensions = _make_extensions_list(project=project)
"end_url": "",
"build_url": build_url}
extensions = _make_extensions_list(wikilinks_config=wikilinks_config,
project=project)
md = Markdown(extensions=extensions) md = Markdown(extensions=extensions)
md.extracted_data = {"mentions": [], "references": []} md.extracted_data = {"mentions": [], "references": []}
return md return md

View File

@ -39,14 +39,15 @@ def _get_object_project(obj):
def is_project_owner(user, obj): def is_project_owner(user, obj):
"""
The owner attribute of a project is just an historical reference
"""
if user.is_superuser: if user.is_superuser:
return True return True
project = _get_object_project(obj) project = _get_object_project(obj)
if project and project.owner == user:
return True
membership = _get_user_project_membership(user, project) membership = _get_user_project_membership(user, project)
if membership and membership.is_owner: if membership and membership.is_owner:
return True return True
@ -80,17 +81,14 @@ def get_user_project_permissions(user, project):
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS)) members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS)) public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS))
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS)) anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
elif project.owner == user:
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS))
members_permissions = list(map(lambda perm: perm[0], 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 []
elif membership: elif membership:
if membership.is_owner: if membership.is_owner:
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS)) owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS))
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
else: else:
owner_permissions = [] owner_permissions = []
members_permissions = _get_membership_permissions(membership) members_permissions = []
members_permissions = members_permissions + _get_membership_permissions(membership)
public_permissions = project.public_permissions if project.public_permissions is not None else [] public_permissions = project.public_permissions if project.public_permissions is not None else []
anon_permissions = project.anon_permissions if project.anon_permissions is not None else [] anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
elif user.is_authenticated(): elif user.is_authenticated():

View File

@ -0,0 +1,17 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
default_app_config = "taiga.projects.apps.ProjectsAppConfig"

View File

@ -80,6 +80,12 @@ class ProjectViewSet(ModelCrudViewSet):
self.check_permissions(request, 'stats', project) self.check_permissions(request, 'stats', project)
return Response(services.get_stats_for_project(project)) return Response(services.get_stats_for_project(project))
@detail_route(methods=['get'])
def member_stats(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'member_stats', project)
return Response(services.get_member_stats_for_project(project))
@detail_route(methods=['get']) @detail_route(methods=['get'])
def issues_stats(self, request, pk=None): def issues_stats(self, request, pk=None):
project = self.get_object() project = self.get_object()
@ -148,6 +154,13 @@ class ProjectViewSet(ModelCrudViewSet):
template.save() template.save()
return Response(serializers.ProjectTemplateSerializer(template).data, status=201) return Response(serializers.ProjectTemplateSerializer(template).data, status=201)
@detail_route(methods=['post'])
def leave(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, 'leave', project)
services.remove_user_from_project(request.user, project)
return Response(status=status.HTTP_200_OK)
def pre_save(self, obj): def pre_save(self, obj):
if not obj.id: if not obj.id:
obj.owner = self.request.user obj.owner = self.request.user
@ -228,6 +241,10 @@ class MembershipViewSet(ModelCrudViewSet):
services.send_invitation(invitation=invitation) services.send_invitation(invitation=invitation)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def pre_delete(self, obj):
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project):
raise exc.BadRequest(_("At least one of the user must be an active admin"))
def pre_save(self, obj): def pre_save(self, obj):
if not obj.token: if not obj.token:
obj.token = str(uuid.uuid1()) obj.token = str(uuid.uuid1())

47
taiga/projects/apps.py Normal file
View File

@ -0,0 +1,47 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import AppConfig
from django.apps import apps
from django.db.models import signals
from . import signals as handlers
class ProjectsAppConfig(AppConfig):
name = "taiga.projects"
verbose_name = "Projects"
def ready(self):
# On membership object is deleted, update role-points relation.
signals.pre_delete.connect(handlers.membership_post_delete,
sender=apps.get_model("projects", "Membership"),
dispatch_uid='membership_pre_delete')
# On membership object is deleted, update watchers of all objects relation.
signals.post_delete.connect(handlers.update_watchers_on_membership_post_delete,
sender=apps.get_model("projects", "Membership"),
dispatch_uid='update_watchers_on_membership_post_delete')
# On membership object is deleted, update watchers of all objects relation.
signals.post_save.connect(handlers.create_notify_policy,
sender=apps.get_model("projects", "Membership"),
dispatch_uid='create-notify-policy')
# On project object is created apply template.
signals.post_save.connect(handlers.project_post_save,
sender=apps.get_model("projects", "Project"),
dispatch_uid='project_post_save')

View File

@ -3,10 +3,10 @@
"model": "projects.projecttemplate", "model": "projects.projecttemplate",
"fields": { "fields": {
"is_issues_activated": true, "is_issues_activated": true,
"task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#ff9900\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#ffcc00\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#669900\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#999999\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}]", "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff9900\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#ffcc00\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#999999\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]",
"is_backlog_activated": true, "is_backlog_activated": true,
"modified_date": "2014-07-25T10:02:46.479Z", "modified_date": "2014-07-25T10:02:46.479Z",
"us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"wip_limit\": null, \"name\": \"New\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"wip_limit\": null, \"name\": \"In progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready for test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"wip_limit\": null, \"name\": \"Done\"}]", "us_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"wip_limit\": null, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#ff8a84\", \"order\": 2, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready\", \"slug\": \"ready\"}, {\"color\": \"#ff9900\", \"order\": 3, \"is_closed\": false, \"wip_limit\": null, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#fcc000\", \"order\": 4, \"is_closed\": false, \"wip_limit\": null, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#669900\", \"order\": 5, \"is_closed\": true, \"wip_limit\": null, \"name\": \"Done\", \"slug\": \"done\"}]",
"is_wiki_activated": true, "is_wiki_activated": true,
"roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]",
"points": "[{\"value\": null, \"order\": 1, \"name\": \"?\"}, {\"value\": 0.0, \"order\": 2, \"name\": \"0\"}, {\"value\": 0.5, \"order\": 3, \"name\": \"1/2\"}, {\"value\": 1.0, \"order\": 4, \"name\": \"1\"}, {\"value\": 2.0, \"order\": 5, \"name\": \"2\"}, {\"value\": 3.0, \"order\": 6, \"name\": \"3\"}, {\"value\": 5.0, \"order\": 7, \"name\": \"5\"}, {\"value\": 8.0, \"order\": 8, \"name\": \"8\"}, {\"value\": 10.0, \"order\": 9, \"name\": \"10\"}, {\"value\": 15.0, \"order\": 10, \"name\": \"15\"}, {\"value\": 20.0, \"order\": 11, \"name\": \"20\"}, {\"value\": 40.0, \"order\": 12, \"name\": \"40\"}]", "points": "[{\"value\": null, \"order\": 1, \"name\": \"?\"}, {\"value\": 0.0, \"order\": 2, \"name\": \"0\"}, {\"value\": 0.5, \"order\": 3, \"name\": \"1/2\"}, {\"value\": 1.0, \"order\": 4, \"name\": \"1\"}, {\"value\": 2.0, \"order\": 5, \"name\": \"2\"}, {\"value\": 3.0, \"order\": 6, \"name\": \"3\"}, {\"value\": 5.0, \"order\": 7, \"name\": \"5\"}, {\"value\": 8.0, \"order\": 8, \"name\": \"8\"}, {\"value\": 10.0, \"order\": 9, \"name\": \"10\"}, {\"value\": 15.0, \"order\": 10, \"name\": \"15\"}, {\"value\": 20.0, \"order\": 11, \"name\": \"20\"}, {\"value\": 40.0, \"order\": 12, \"name\": \"40\"}]",
@ -17,7 +17,7 @@
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}", "default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
"slug": "scrum", "slug": "scrum",
"videoconferences_salt": "", "videoconferences_salt": "",
"issue_statuses": "[{\"color\": \"#8C2318\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#5E8C6A\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#88A65E\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#BFB35A\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#89BAB4\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}, {\"color\": \"#CC0000\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\"}, {\"color\": \"#666666\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\"}]", "issue_statuses": "[{\"color\": \"#8C2318\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#5E8C6A\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#88A65E\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#BFB35A\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#89BAB4\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#CC0000\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#666666\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]",
"default_owner_role": "product-owner", "default_owner_role": "product-owner",
"issue_types": "[{\"color\": \"#89BAB4\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#ba89a8\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#89a8ba\", \"order\": 3, \"name\": \"Enhancement\"}]", "issue_types": "[{\"color\": \"#89BAB4\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#ba89a8\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#89a8ba\", \"order\": 3, \"name\": \"Enhancement\"}]",
"videoconferences": null, "videoconferences": null,
@ -30,10 +30,10 @@
"model": "projects.projecttemplate", "model": "projects.projecttemplate",
"fields": { "fields": {
"is_issues_activated": false, "is_issues_activated": false,
"task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}]", "task_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}]",
"is_backlog_activated": false, "is_backlog_activated": false,
"modified_date": "2014-07-25T13:11:42.754Z", "modified_date": "2014-07-25T13:11:42.754Z",
"us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"color\": \"#999999\", \"name\": \"New\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"color\": \"#f57900\", \"name\": \"Ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"color\": \"#729fcf\", \"name\": \"In progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"color\": \"#cc0000\", \"name\": \"Done\"}]", "us_statuses": "[{\"wip_limit\": null, \"order\": 1, \"is_closed\": false, \"color\": \"#999999\", \"name\": \"New\", \"slug\": \"new\"}, {\"wip_limit\": null, \"order\": 2, \"is_closed\": false, \"color\": \"#f57900\", \"name\": \"Ready\", \"slug\": \"ready\"}, {\"wip_limit\": null, \"order\": 3, \"is_closed\": false, \"color\": \"#729fcf\", \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"wip_limit\": null, \"order\": 4, \"is_closed\": false, \"color\": \"#4e9a06\", \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"wip_limit\": null, \"order\": 5, \"is_closed\": true, \"color\": \"#cc0000\", \"name\": \"Done\", \"slug\": \"done\"}]",
"is_wiki_activated": false, "is_wiki_activated": false,
"roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]", "roles": "[{\"order\": 10, \"slug\": \"ux\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"UX\", \"computable\": true}, {\"order\": 20, \"slug\": \"design\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Design\", \"computable\": true}, {\"order\": 30, \"slug\": \"front\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Front\", \"computable\": true}, {\"order\": 40, \"slug\": \"back\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Back\", \"computable\": true}, {\"order\": 50, \"slug\": \"product-owner\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"add_milestone\", \"modify_milestone\", \"delete_milestone\", \"view_milestones\", \"view_project\", \"add_task\", \"modify_task\", \"delete_task\", \"view_tasks\", \"add_us\", \"modify_us\", \"delete_us\", \"view_us\", \"add_wiki_page\", \"modify_wiki_page\", \"delete_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Product Owner\", \"computable\": false}, {\"order\": 60, \"slug\": \"stakeholder\", \"permissions\": [\"add_issue\", \"modify_issue\", \"delete_issue\", \"view_issues\", \"view_milestones\", \"view_project\", \"view_tasks\", \"view_us\", \"modify_wiki_page\", \"view_wiki_pages\", \"add_wiki_link\", \"delete_wiki_link\", \"view_wiki_links\"], \"name\": \"Stakeholder\", \"computable\": false}]",
"points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 15.0, \"name\": \"15\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]", "points": "[{\"value\": null, \"name\": \"?\", \"order\": 1}, {\"value\": 0.0, \"name\": \"0\", \"order\": 2}, {\"value\": 0.5, \"name\": \"1/2\", \"order\": 3}, {\"value\": 1.0, \"name\": \"1\", \"order\": 4}, {\"value\": 2.0, \"name\": \"2\", \"order\": 5}, {\"value\": 3.0, \"name\": \"3\", \"order\": 6}, {\"value\": 5.0, \"name\": \"5\", \"order\": 7}, {\"value\": 8.0, \"name\": \"8\", \"order\": 8}, {\"value\": 10.0, \"name\": \"10\", \"order\": 9}, {\"value\": 15.0, \"name\": \"15\", \"order\": 10}, {\"value\": 20.0, \"name\": \"20\", \"order\": 11}, {\"value\": 40.0, \"name\": \"40\", \"order\": 12}]",
@ -44,7 +44,7 @@
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}", "default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
"slug": "kanban", "slug": "kanban",
"videoconferences_salt": "", "videoconferences_salt": "",
"issue_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\"}, {\"color\": \"#d3d7cf\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\"}, {\"color\": \"#75507b\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\"}]", "issue_statuses": "[{\"color\": \"#999999\", \"order\": 1, \"is_closed\": false, \"name\": \"New\", \"slug\": \"new\"}, {\"color\": \"#729fcf\", \"order\": 2, \"is_closed\": false, \"name\": \"In progress\", \"slug\": \"in-progress\"}, {\"color\": \"#f57900\", \"order\": 3, \"is_closed\": true, \"name\": \"Ready for test\", \"slug\": \"ready-for-test\"}, {\"color\": \"#4e9a06\", \"order\": 4, \"is_closed\": true, \"name\": \"Closed\", \"slug\": \"closed\"}, {\"color\": \"#cc0000\", \"order\": 5, \"is_closed\": false, \"name\": \"Needs Info\", \"slug\": \"needs-info\"}, {\"color\": \"#d3d7cf\", \"order\": 6, \"is_closed\": true, \"name\": \"Rejected\", \"slug\": \"rejected\"}, {\"color\": \"#75507b\", \"order\": 7, \"is_closed\": false, \"name\": \"Postponed\", \"slug\": \"posponed\"}]",
"default_owner_role": "product-owner", "default_owner_role": "product-owner",
"issue_types": "[{\"color\": \"#cc0000\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#729fcf\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#4e9a06\", \"order\": 3, \"name\": \"Enhancement\"}]", "issue_types": "[{\"color\": \"#cc0000\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#729fcf\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#4e9a06\", \"order\": 3, \"name\": \"Enhancement\"}]",
"videoconferences": null, "videoconferences": null,

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('history', '0004_historyentry_is_hidden'),
]
operations = [
migrations.AlterField(
model_name='historyentry',
name='key',
field=models.CharField(default=None, blank=True, max_length=255, db_index=True, null=True),
),
]

View File

@ -48,7 +48,7 @@ class HistoryEntry(models.Model):
user = JsonField(blank=True, default=None, null=True) user = JsonField(blank=True, default=None, null=True)
created_at = models.DateTimeField(default=timezone.now) created_at = models.DateTimeField(default=timezone.now)
type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES) type = models.SmallIntegerField(choices=HISTORY_TYPE_CHOICES)
key = models.CharField(max_length=255, null=True, default=None, blank=True) key = models.CharField(max_length=255, null=True, default=None, blank=True, db_index=True)
# Stores the last diff # Stores the last diff
diff = JsonField(null=True, default=None) diff = JsonField(null=True, default=None)

View File

@ -18,35 +18,46 @@ from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm,
IsProjectOwner, AllowAny, IsProjectOwner, AllowAny,
IsObjectOwner, PermissionComponent) IsObjectOwner, PermissionComponent)
from taiga.permissions.service import is_project_owner
from taiga.projects.history.services import get_model_from_key, get_pk_from_key
class IsCommentDeleter(PermissionComponent): class IsCommentDeleter(PermissionComponent):
def check_permissions(self, request, view, obj=None): def check_permissions(self, request, view, obj=None):
return obj.delete_comment_user and obj.delete_comment_user.get("pk", "not-pk") == request.user.pk return obj.delete_comment_user and obj.delete_comment_user.get("pk", "not-pk") == request.user.pk
class IsCommentOwner(PermissionComponent): class IsCommentOwner(PermissionComponent):
def check_permissions(self, request, view, obj=None): def check_permissions(self, request, view, obj=None):
return obj.user and obj.user.get("pk", "not-pk") == request.user.pk return obj.user and obj.user.get("pk", "not-pk") == request.user.pk
class IsCommentProjectOwner(PermissionComponent):
def check_permissions(self, request, view, obj=None):
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)
class UserStoryHistoryPermission(TaigaResourcePermission): class UserStoryHistoryPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
delete_comment_perms = IsProjectOwner() | IsCommentOwner() delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
class TaskHistoryPermission(TaigaResourcePermission): class TaskHistoryPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
delete_comment_perms = IsProjectOwner() | IsCommentOwner() delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
class IssueHistoryPermission(TaigaResourcePermission): class IssueHistoryPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
delete_comment_perms = IsProjectOwner() | IsCommentOwner() delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
class WikiHistoryPermission(TaigaResourcePermission): class WikiHistoryPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
delete_comment_perms = IsProjectOwner() | IsCommentOwner() delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter() undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()

View File

@ -81,6 +81,14 @@ def get_model_from_key(key:str) -> object:
return apps.get_model(class_name) return apps.get_model(class_name)
def get_pk_from_key(key:str) -> object:
"""
Get pk from key
"""
class_name, pk = key.split(":", 1)
return pk
def register_values_implementation(typename:str, fn=None): def register_values_implementation(typename:str, fn=None):
""" """
Register values implementation for specified typename. Register values implementation for specified typename.
@ -243,6 +251,24 @@ def get_last_snapshot_for_key(key:str) -> FrozenObj:
# Public api # Public api
def get_modified_fields(obj:object, last_modifications):
"""
Get the modified fields for an object through his last modifications
"""
key = make_key_from_model_object(obj)
entry_model = apps.get_model("history", "HistoryEntry")
history_entries = (entry_model.objects
.filter(key=key)
.order_by("-created_at")
.values_list("diff", flat=True)
[0:last_modifications])
modified_fields = []
for history_entry in history_entries:
modified_fields += history_entry.keys()
return modified_fields
@tx.atomic @tx.atomic
def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False): def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False):
""" """

View File

@ -2,7 +2,11 @@
"description", "description",
"description_html", "description_html",
"content", "content",
"content_html" "content_html",
"backlog_order",
"kanban_order",
"taskboard_order",
"us_order"
] %} ] %}
<dl> <dl>

View File

@ -2,7 +2,11 @@
"description_diff", "description_diff",
"description_html", "description_html",
"content_diff", "content_diff",
"content_html" "content_html",
"backlog_order",
"kanban_order",
"taskboard_order",
"us_order"
] %} ] %}
{% for field_name, values in changed_fields.items() %} {% for field_name, values in changed_fields.items() %}
{% if field_name not in excluded_fields %} {% if field_name not in excluded_fields %}

View File

@ -79,7 +79,7 @@ class IssuesFilter(filters.FilterBackend):
filterdata = self._prepare_filters_data(request) filterdata = self._prepare_filters_data(request)
if "tags" in filterdata: if "tags" in filterdata:
queryset = tags.filter(queryset, contains=filterdata["tags"]) queryset = queryset.filter(tags__contains=filterdata["tags"])
for name, value in filter(lambda x: x[0] != "tags", filterdata.items()): for name, value in filter(lambda x: x[0] != "tags", filterdata.items()):
if None in value: if None in value:

View File

@ -223,6 +223,13 @@ class Command(BaseCommand):
comment=self.sd.paragraph(), comment=self.sd.paragraph(),
user=wiki_page.owner) user=wiki_page.owner)
# Add history entry
wiki_page.content=self.sd.paragraphs(3,15)
wiki_page.save()
take_snapshot(wiki_page,
comment=self.sd.paragraph(),
user=wiki_page.owner)
return wiki_page return wiki_page
def create_bug(self, project): def create_bug(self, project):
@ -253,6 +260,13 @@ class Command(BaseCommand):
comment=self.sd.paragraph(), comment=self.sd.paragraph(),
user=bug.owner) user=bug.owner)
# Add history entry
bug.status=self.sd.db_object_from_queryset(IssueStatus.objects.filter(project=project))
bug.save()
take_snapshot(bug,
comment=self.sd.paragraph(),
user=bug.owner)
return bug return bug
def create_task(self, project, milestone, us, min_date, max_date, closed=False): def create_task(self, project, milestone, us, min_date, max_date, closed=False):
@ -284,6 +298,13 @@ class Command(BaseCommand):
comment=self.sd.paragraph(), comment=self.sd.paragraph(),
user=task.owner) user=task.owner)
# Add history entry
task.status=self.sd.db_object_from_queryset(project.task_statuses.all())
task.save()
take_snapshot(task,
comment=self.sd.paragraph(),
user=task.owner)
return task return task
def create_us(self, project, milestone=None, computable_project_roles=[]): def create_us(self, project, milestone=None, computable_project_roles=[]):
@ -318,6 +339,13 @@ class Command(BaseCommand):
comment=self.sd.paragraph(), comment=self.sd.paragraph(),
user=us.owner) user=us.owner)
# Add history entry
us.status=self.sd.db_object_from_queryset(project.us_statuses.filter(is_closed=False))
us.save()
take_snapshot(us,
comment=self.sd.paragraph(),
user=us.owner)
return us return us
def create_milestone(self, project, start_date, end_date): def create_milestone(self, project, start_date, end_date):

View File

@ -31,12 +31,10 @@ from djorm_pgarray.fields import TextArrayField
from taiga.permissions.permissions import ANON_PERMISSIONS, USER_PERMISSIONS from taiga.permissions.permissions import ANON_PERMISSIONS, USER_PERMISSIONS
from taiga.base.tags import TaggedMixin from taiga.base.tags import TaggedMixin
from taiga.users.models import Role
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.dicts import dict_sum from taiga.base.utils.dicts import dict_sum
from taiga.base.utils.sequence import arithmetic_progression from taiga.base.utils.sequence import arithmetic_progression
from taiga.base.utils.slug import slugify_uniquely_for_queryset from taiga.base.utils.slug import slugify_uniquely_for_queryset
from taiga.projects.notifications.services import create_notify_policy_if_not_exists
from . import choices from . import choices
@ -598,6 +596,7 @@ class ProjectTemplate(models.Model):
for us_status in project.us_statuses.all(): for us_status in project.us_statuses.all():
self.us_statuses.append({ self.us_statuses.append({
"name": us_status.name, "name": us_status.name,
"slug": us_status.slug,
"is_closed": us_status.is_closed, "is_closed": us_status.is_closed,
"color": us_status.color, "color": us_status.color,
"wip_limit": us_status.wip_limit, "wip_limit": us_status.wip_limit,
@ -616,6 +615,7 @@ class ProjectTemplate(models.Model):
for task_status in project.task_statuses.all(): for task_status in project.task_statuses.all():
self.task_statuses.append({ self.task_statuses.append({
"name": task_status.name, "name": task_status.name,
"slug": task_status.slug,
"is_closed": task_status.is_closed, "is_closed": task_status.is_closed,
"color": task_status.color, "color": task_status.color,
"order": task_status.order, "order": task_status.order,
@ -625,6 +625,7 @@ class ProjectTemplate(models.Model):
for issue_status in project.issue_statuses.all(): for issue_status in project.issue_statuses.all():
self.issue_statuses.append({ self.issue_statuses.append({
"name": issue_status.name, "name": issue_status.name,
"slug": issue_status.slug,
"is_closed": issue_status.is_closed, "is_closed": issue_status.is_closed,
"color": issue_status.color, "color": issue_status.color,
"order": issue_status.order, "order": issue_status.order,
@ -671,6 +672,8 @@ class ProjectTemplate(models.Model):
self.default_owner_role = self.roles[0].get("slug", None) self.default_owner_role = self.roles[0].get("slug", None)
def apply_to_project(self, project): def apply_to_project(self, project):
Role = apps.get_model("users", "Role")
if project.id is None: if project.id is None:
raise Exception("Project need an id (must be a saved project)") raise Exception("Project need an id (must be a saved project)")
@ -685,6 +688,7 @@ class ProjectTemplate(models.Model):
for us_status in self.us_statuses: for us_status in self.us_statuses:
UserStoryStatus.objects.create( UserStoryStatus.objects.create(
name=us_status["name"], name=us_status["name"],
slug=us_status["slug"],
is_closed=us_status["is_closed"], is_closed=us_status["is_closed"],
color=us_status["color"], color=us_status["color"],
wip_limit=us_status["wip_limit"], wip_limit=us_status["wip_limit"],
@ -703,6 +707,7 @@ class ProjectTemplate(models.Model):
for task_status in self.task_statuses: for task_status in self.task_statuses:
TaskStatus.objects.create( TaskStatus.objects.create(
name=task_status["name"], name=task_status["name"],
slug=task_status["slug"],
is_closed=task_status["is_closed"], is_closed=task_status["is_closed"],
color=task_status["color"], color=task_status["color"],
order=task_status["order"], order=task_status["order"],
@ -712,6 +717,7 @@ class ProjectTemplate(models.Model):
for issue_status in self.issue_statuses: for issue_status in self.issue_statuses:
IssueStatus.objects.create( IssueStatus.objects.create(
name=issue_status["name"], name=issue_status["name"],
slug=issue_status["slug"],
is_closed=issue_status["is_closed"], is_closed=issue_status["is_closed"],
color=issue_status["color"], color=issue_status["color"],
order=issue_status["order"], order=issue_status["order"],
@ -777,58 +783,3 @@ class ProjectTemplate(models.Model):
project.default_severity = Severity.objects.get(name=self.default_options["severity"], project=project) project.default_severity = Severity.objects.get(name=self.default_options["severity"], project=project)
return project return project
# On membership object is deleted, update role-points relation.
@receiver(signals.pre_delete, sender=Membership, dispatch_uid='membership_pre_delete')
def membership_post_delete(sender, instance, using, **kwargs):
instance.project.update_role_points()
# On membership object is deleted, update watchers of all objects relation.
@receiver(signals.post_delete, sender=Membership, dispatch_uid='update_watchers_on_membership_post_delete')
def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs):
models = [apps.get_model("userstories", "UserStory"),
apps.get_model("tasks", "Task"),
apps.get_model("issues", "Issue")]
# `user_id` is used beacuse in some momments
# instance.user can contain pointer to now
# removed object from a database.
for model in models:
model.watchers.through.objects.filter(user_id=instance.user_id).delete()
# On membership object is deleted, update watchers of all objects relation.
@receiver(signals.post_save, sender=Membership, dispatch_uid='create-notify-policy')
def create_notify_policy(sender, instance, using, **kwargs):
if instance.user:
create_notify_policy_if_not_exists(instance.project, instance.user)
@receiver(signals.post_save, sender=Project, dispatch_uid='project_post_save')
def project_post_save(sender, instance, created, **kwargs):
"""
Populate new project dependen default data
"""
if not created:
return
if instance._importing:
return
template = getattr(instance, "creation_template", None)
if template is None:
template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE)
template.apply_to_project(instance)
instance.save()
try:
owner_role = instance.roles.get(slug=template.default_owner_role)
except Role.DoesNotExist:
owner_role = instance.roles.first()
if owner_role:
Membership.objects.create(user=instance.owner, project=instance, role=owner_role,
is_owner=True, email=instance.owner.email)

View File

@ -32,6 +32,7 @@ from taiga.projects.history.choices import HistoryType
from taiga.projects.history.services import (make_key_from_model_object, from taiga.projects.history.services import (make_key_from_model_object,
get_last_snapshot_for_key, get_last_snapshot_for_key,
get_model_from_key) get_model_from_key)
from taiga.permissions.service import user_has_perm
from taiga.users.models import User from taiga.users.models import User
from .models import HistoryChangeNotification from .models import HistoryChangeNotification
@ -121,6 +122,23 @@ def analize_object_for_watchers(obj:object, history:object):
obj.watchers.add(user) obj.watchers.add(user)
def _filter_by_permissions(obj, user):
UserStory = apps.get_model("userstories", "UserStory")
Issue = apps.get_model("issues", "Issue")
Task = apps.get_model("tasks", "Task")
WikiPage = apps.get_model("wiki", "WikiPage")
if isinstance(obj, UserStory):
return user_has_perm(user, "view_us", obj)
elif isinstance(obj, Issue):
return user_has_perm(user, "view_issues", obj)
elif isinstance(obj, Task):
return user_has_perm(user, "view_tasks", obj)
elif isinstance(obj, WikiPage):
return user_has_perm(user, "view_wiki_pages", obj)
return False
def get_users_to_notify(obj, *, discard_users=None) -> list: def get_users_to_notify(obj, *, discard_users=None) -> list:
""" """
Get filtered set of users to notify for specified Get filtered set of users to notify for specified
@ -149,6 +167,8 @@ def get_users_to_notify(obj, *, discard_users=None) -> list:
if discard_users: if discard_users:
candidates = candidates - set(discard_users) candidates = candidates - set(discard_users)
candidates = filter(partial(_filter_by_permissions, obj), candidates)
return frozenset(candidates) return frozenset(candidates)
@ -187,6 +207,9 @@ def _make_template_mail(name:str):
@transaction.atomic @transaction.atomic
def send_notifications(obj, *, history): def send_notifications(obj, *, history):
if history.is_hidden:
return None
key = make_key_from_model_object(obj) key = make_key_from_model_object(obj)
owner = User.objects.get(pk=history.user["pk"]) owner = User.objects.get(pk=history.user["pk"])
notification, created = (HistoryChangeNotification.objects.select_for_update() notification, created = (HistoryChangeNotification.objects.select_for_update()

View File

@ -19,23 +19,62 @@ from django.utils.translation import ugettext_lazy as _
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
from taiga.base.utils import db from taiga.base.utils import db
from taiga.projects.history.services import get_modified_fields
class OCCResourceMixin(object): class OCCResourceMixin(object):
""" """
Rest Framework resource mixin for resources that need to have concurrent Rest Framework resource mixin for resources that need to have concurrent
accesses and editions controlled. accesses and editions controlled.
""" """
def pre_save(self, obj): def _extract_param_version(self):
current_version = obj.version
param_version = self.request.DATA.get('version', None) param_version = self.request.DATA.get('version', None)
try:
param_version = param_version and int(param_version)
except (ValueError, TypeError):
raise exc.WrongArguments({"version": "The version must be an integer"})
if obj.id is not None and current_version != param_version: return param_version
def _validate_param_version(self, param_version, current_version):
if param_version is not None:
if param_version < 0:
return False
if current_version is not None and param_version > current_version:
return False
return True
def _validate_and_update_version(self, obj):
current_version = None
if obj.id:
current_version = type(obj).objects.model.objects.get(id=obj.id).version
# Extract param version
param_version = self._extract_param_version()
if not self._validate_param_version(param_version, current_version):
raise exc.WrongArguments({"version": "The version is not valid"})
if current_version != param_version:
diff_versions = current_version - param_version
modifying_fields = set(self.request.DATA.keys())
if "version" in modifying_fields:
modifying_fields.remove("version")
modified_fields = set(get_modified_fields(obj, diff_versions))
if "version" in modifying_fields:
modified_fields.remove("version")
both_modified = modifying_fields & modified_fields
if both_modified:
raise exc.WrongArguments({"version": "The version doesn't match with the current one"}) raise exc.WrongArguments({"version": "The version doesn't match with the current one"})
if obj.id: if obj.id:
obj.version = models.F('version') + 1 obj.version = models.F('version') + 1
def pre_save(self, obj):
self._validate_and_update_version(obj)
super().pre_save(obj) super().pre_save(obj)
def post_save(self, obj, created=False): def post_save(self, obj, created=False):

View File

@ -13,20 +13,41 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext_lazy as _
from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm, from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm,
IsAuthenticated, IsProjectOwner, IsAuthenticated, IsProjectOwner,
AllowAny, IsSuperUser) AllowAny, IsSuperUser, PermissionComponent)
from taiga.base import exceptions as exc
from taiga.projects.models import Membership
from . import services
class CanLeaveProject(PermissionComponent):
def check_permissions(self, request, view, obj=None):
if not obj or not request.user.is_authenticated():
return False
try:
if not services.can_user_leave_project(request.user, obj):
raise exc.PermissionDenied(_("You can't leave the project if there are no more owners"))
return True
except Membership.DoesNotExist:
return False
class ProjectPermission(TaigaResourcePermission): class ProjectPermission(TaigaResourcePermission):
retrieve_perms = HasProjectPerm('view_project') retrieve_perms = HasProjectPerm('view_project')
create_perms = IsAuthenticated() create_perms = IsAuthenticated()
update_perms = IsProjectOwner() update_perms = IsProjectOwner()
partial_update_perms = IsProjectOwner()
destroy_perms = IsProjectOwner() destroy_perms = IsProjectOwner()
modules_perms = IsProjectOwner() modules_perms = IsProjectOwner()
list_perms = AllowAny() list_perms = AllowAny()
stats_perms = AllowAny() stats_perms = AllowAny()
member_stats_perms = HasProjectPerm('view_project')
star_perms = IsAuthenticated() star_perms = IsAuthenticated()
unstar_perms = IsAuthenticated() unstar_perms = IsAuthenticated()
issues_stats_perms = AllowAny() issues_stats_perms = AllowAny()
@ -35,6 +56,7 @@ class ProjectPermission(TaigaResourcePermission):
tags_colors_perms = HasProjectPerm('view_project') tags_colors_perms = HasProjectPerm('view_project')
fans_perms = HasProjectPerm('view_project') fans_perms = HasProjectPerm('view_project')
create_template_perms = IsSuperUser() create_template_perms = IsSuperUser()
leave_perms = CanLeaveProject()
class MembershipPermission(TaigaResourcePermission): class MembershipPermission(TaigaResourcePermission):

View File

@ -30,6 +30,7 @@ from taiga.users.validators import RoleExistsValidator
from taiga.permissions.service import get_user_project_permissions, is_project_owner from taiga.permissions.service import get_user_project_permissions, is_project_owner
from . import models from . import models
from . import services
from . validators import ProjectExistsValidator from . validators import ProjectExistsValidator
@ -202,6 +203,17 @@ class MembershipSerializer(ModelSerializer):
return attrs return attrs
def validate_is_owner(self, attrs, source):
is_owner = attrs[source]
project = attrs.get("project", None)
if project is None:
project = self.object.project
if self.object and not services.project_has_valid_owners(project, exclude_user=self.object.user):
raise serializers.ValidationError(_("At least one of the user must be an active admin"))
return attrs
class ProjectMembershipSerializer(ModelSerializer): class ProjectMembershipSerializer(ModelSerializer):
role_name = serializers.CharField(source='role.name', required=False) role_name = serializers.CharField(source='role.name', required=False)
@ -310,6 +322,7 @@ class ProjectTemplateSerializer(ModelSerializer):
class Meta: class Meta:
model = models.ProjectTemplate model = models.ProjectTemplate
read_only_fields = ("created_date", "modified_date")
class StarredSerializer(ModelSerializer): class StarredSerializer(ModelSerializer):

View File

@ -30,9 +30,11 @@ from .filters import get_issues_filters_data
from .stats import get_stats_for_project_issues from .stats import get_stats_for_project_issues
from .stats import get_stats_for_project from .stats import get_stats_for_project
from .stats import get_member_stats_for_project
from .members import create_members_in_bulk from .members import create_members_in_bulk
from .members import get_members_from_bulk from .members import get_members_from_bulk
from .members import remove_user_from_project, project_has_valid_owners, can_user_leave_project
from .invitations import send_invitation from .invitations import send_invitation
from .invitations import find_invited_user from .invitations import find_invited_user

View File

@ -2,7 +2,6 @@ from taiga.base.utils import db, text
from .. import models from .. import models
def get_members_from_bulk(bulk_data, **additional_fields): def get_members_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of members. """Convert `bulk_data` into a list of members.
@ -31,3 +30,29 @@ def create_members_in_bulk(bulk_data, callback=None, precall=None, **additional_
members = get_members_from_bulk(bulk_data, **additional_fields) members = get_members_from_bulk(bulk_data, **additional_fields)
db.save_in_bulk(members, callback, precall) db.save_in_bulk(members, callback, precall)
return members return members
def remove_user_from_project(user, project):
models.Membership.objects.get(project=project, user=user).delete()
def project_has_valid_owners(project, exclude_user=None):
"""
Checks if the project has any owner membership with a user different than the specified
"""
owner_memberships = project.memberships.filter(is_owner=True, user__is_active=True)
if exclude_user:
owner_memberships = owner_memberships.exclude(user=exclude_user)
return owner_memberships.count() > 0
def can_user_leave_project(user, project):
membership = project.memberships.get(user=user)
if not membership.is_owner:
return True
if not project_has_valid_owners(project, exclude_user=user):
return False
return True

View File

@ -18,6 +18,8 @@ from django.db.models import Q, Count
import datetime import datetime
import copy import copy
from taiga.projects.history.models import HistoryEntry
def _get_milestones_stats_for_backlog(project): def _get_milestones_stats_for_backlog(project):
""" """
@ -212,3 +214,77 @@ def get_stats_for_project(project):
'speed': speed, 'speed': speed,
} }
return project_stats return project_stats
def _get_closed_bugs_per_member_stats(project):
# Closed bugs per user
closed_bugs = project.issues.filter(status__is_closed=True)\
.values('assigned_to')\
.annotate(count=Count('assigned_to'))\
.order_by()
closed_bugs = { p["assigned_to"]: p["count"] for p in closed_bugs}
return closed_bugs
def _get_iocaine_tasks_per_member_stats(project):
# Iocaine tasks assigned per user
iocaine_tasks = project.tasks.filter(is_iocaine=True)\
.values('assigned_to')\
.annotate(count=Count('assigned_to'))\
.order_by()
iocaine_tasks = { t["assigned_to"]: t["count"] for t in iocaine_tasks}
return iocaine_tasks
def _get_wiki_changes_per_member_stats(project):
# Wiki changes
wiki_changes = {}
wiki_page_keys = ["wiki.wikipage:%s"%id for id in project.wiki_pages.values_list("id", flat=True)]
history_entries = HistoryEntry.objects.filter(key__in=wiki_page_keys).values('user')
for entry in history_entries:
editions = wiki_changes.get(entry["user"]["pk"], 0)
wiki_changes[entry["user"]["pk"]] = editions + 1
return wiki_changes
def _get_created_bugs_per_member_stats(project):
# Created_bugs
created_bugs = project.issues\
.values('owner')\
.annotate(count=Count('owner'))\
.order_by()
created_bugs = { p["owner"]: p["count"] for p in created_bugs }
return created_bugs
def _get_closed_tasks_per_member_stats(project):
# Closed tasks
closed_tasks = project.tasks.filter(status__is_closed=True)\
.values('assigned_to')\
.annotate(count=Count('assigned_to'))\
.order_by()
closed_tasks = {p["assigned_to"]: p["count"] for p in closed_tasks}
return closed_tasks
def get_member_stats_for_project(project):
base_counters = {id: 0 for id in project.members.values_list("id", flat=True)}
closed_bugs = base_counters.copy()
closed_bugs.update(_get_closed_bugs_per_member_stats(project))
iocaine_tasks = base_counters.copy()
iocaine_tasks.update(_get_iocaine_tasks_per_member_stats(project))
wiki_changes = base_counters.copy()
wiki_changes.update(_get_wiki_changes_per_member_stats(project))
created_bugs = base_counters.copy()
created_bugs.update(_get_created_bugs_per_member_stats(project))
closed_tasks = base_counters.copy()
closed_tasks.update(_get_closed_tasks_per_member_stats(project))
member_stats = {
"closed_bugs": closed_bugs,
"iocaine_tasks": iocaine_tasks,
"wiki_changes": wiki_changes,
"created_bugs": created_bugs,
"closed_tasks": closed_tasks,
}
return member_stats

View File

@ -14,7 +14,11 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import apps
from django.conf import settings
from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags from taiga.projects.services.tags_colors import update_project_tags_colors_handler, remove_unused_tags
from taiga.projects.notifications.services import create_notify_policy_if_not_exists
#################################### ####################################
@ -35,3 +39,54 @@ def update_project_tags_when_create_or_edit_taggable_item(sender, instance, **kw
def update_project_tags_when_delete_taggable_item(sender, instance, **kwargs): def update_project_tags_when_delete_taggable_item(sender, instance, **kwargs):
remove_unused_tags(instance.project) remove_unused_tags(instance.project)
instance.project.save() instance.project.save()
def membership_post_delete(sender, instance, using, **kwargs):
instance.project.update_role_points()
def update_watchers_on_membership_post_delete(sender, instance, using, **kwargs):
models = [apps.get_model("userstories", "UserStory"),
apps.get_model("tasks", "Task"),
apps.get_model("issues", "Issue")]
# `user_id` is used beacuse in some momments
# instance.user can contain pointer to now
# removed object from a database.
for model in models:
model.watchers.through.objects.filter(user_id=instance.user_id).delete()
def create_notify_policy(sender, instance, using, **kwargs):
if instance.user:
create_notify_policy_if_not_exists(instance.project, instance.user)
def project_post_save(sender, instance, created, **kwargs):
"""
Populate new project dependen default data
"""
if not created:
return
if instance._importing:
return
template = getattr(instance, "creation_template", None)
if template is None:
ProjectTemplate = apps.get_model("projects", "ProjectTemplate")
template = ProjectTemplate.objects.get(slug=settings.DEFAULT_PROJECT_TEMPLATE)
template.apply_to_project(instance)
instance.save()
Role = apps.get_model("users", "Role")
try:
owner_role = instance.roles.get(slug=template.default_owner_role)
except Role.DoesNotExist:
owner_role = instance.roles.first()
if owner_role:
Membership = apps.get_model("projects", "Membership")
Membership.objects.create(user=instance.owner, project=instance, role=owner_role,
is_owner=True, email=instance.owner.email)

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.shortcuts import get_object_or_404
from taiga.base import filters, response from taiga.base import filters, response
from taiga.base import exceptions as exc from taiga.base import exceptions as exc
@ -79,3 +80,27 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
return response.Ok(tasks_serialized.data) return response.Ok(tasks_serialized.data)
return response.BadRequest(serializer.errors) return response.BadRequest(serializer.errors)
def _bulk_update_order(self, order_field, request, **kwargs):
serializer = serializers.UpdateTasksOrderBulkSerializer(data=request.DATA)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
data = serializer.data
project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project)
services.update_tasks_order_in_bulk(data["bulk_tasks"],
project=project,
field=order_field)
services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user)
return response.NoContent()
@list_route(methods=["POST"])
def bulk_update_taskboard_order(self, request, **kwargs):
return self._bulk_update_order("taskboard_order", request, **kwargs)
@list_route(methods=["POST"])
def bulk_update_us_order(self, request, **kwargs):
return self._bulk_update_order("us_order", request, **kwargs)

View File

@ -27,3 +27,4 @@ class TaskPermission(TaigaResourcePermission):
destroy_perms = HasProjectPerm('delete_task') destroy_perms = HasProjectPerm('delete_task')
list_perms = AllowAny() list_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_task') bulk_create_perms = HasProjectPerm('add_task')
bulk_update_order_perms = HasProjectPerm('modify_task')

View File

@ -18,9 +18,9 @@ from rest_framework import serializers
from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField
from taiga.mdrender.service import render as mdrender from taiga.mdrender.service import render as mdrender
from taiga.projects.validators import ProjectExistsValidator, TaskStatusExistsValidator from taiga.projects.validators import ProjectExistsValidator
from taiga.projects.milestones.validators import SprintExistsValidator from taiga.projects.milestones.validators import SprintExistsValidator
from taiga.projects.userstories.validators import UserStoryExistsValidator from taiga.projects.tasks.validators import TaskExistsValidator
from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.notifications.validators import WatchersValidator
from . import models from . import models
@ -70,10 +70,21 @@ class NeighborTaskSerializer(serializers.ModelSerializer):
depth = 0 depth = 0
class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator, TaskStatusExistsValidator, class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator,
UserStoryExistsValidator, Serializer): TaskExistsValidator, Serializer):
project_id = serializers.IntegerField() project_id = serializers.IntegerField()
sprint_id = serializers.IntegerField() sprint_id = serializers.IntegerField()
status_id = serializers.IntegerField(required=False) status_id = serializers.IntegerField(required=False)
us_id = serializers.IntegerField(required=False) us_id = serializers.IntegerField(required=False)
bulk_tasks = serializers.CharField() bulk_tasks = serializers.CharField()
## Order bulk serializers
class _TaskOrderBulkSerializer(TaskExistsValidator, Serializer):
task_id = serializers.IntegerField()
order = serializers.IntegerField()
class UpdateTasksOrderBulkSerializer(ProjectExistsValidator, Serializer):
project_id = serializers.IntegerField()
bulk_tasks = _TaskOrderBulkSerializer(many=True)

View File

@ -15,6 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.utils import db, text from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot
from taiga.events import events
from . import models from . import models
@ -43,3 +45,33 @@ def create_tasks_in_bulk(bulk_data, callback=None, precall=None, **additional_fi
tasks = get_tasks_from_bulk(bulk_data, **additional_fields) tasks = get_tasks_from_bulk(bulk_data, **additional_fields)
db.save_in_bulk(tasks, callback, precall) db.save_in_bulk(tasks, callback, precall)
return tasks return tasks
def update_tasks_order_in_bulk(bulk_data:list, field:str, project:object):
"""
Update the order of some tasks.
`bulk_data` should be a list of tuples with the following format:
[(<task id>, {<field>: <value>, ...}), ...]
"""
task_ids = []
new_order_values = []
for task_data in bulk_data:
task_ids.append(task_data["task_id"])
new_order_values.append({field: task_data["order"]})
events.emit_event_for_ids(ids=task_ids,
content_type="tasks.task",
projectid=project.pk)
db.update_in_bulk_with_ids(task_ids, new_order_values, model=models.Task)
def snapshot_tasks_in_bulk(bulk_data, user):
task_ids = []
for task_data in bulk_data:
try:
task = models.Task.objects.get(pk=task_data['task_id'])
take_snapshot(task, user=user)
except models.UserStory.DoesNotExist:
pass

View File

@ -0,0 +1,14 @@
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from . import models
class TaskExistsValidator:
def validate_task_id(self, attrs, source):
value = attrs[source]
if not models.Task.objects.filter(pk=value).exists():
msg = _("There's no task with that id")
raise serializers.ValidationError(msg)
return attrs

View File

@ -110,8 +110,7 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
return response.Ok(user_stories_serialized.data) return response.Ok(user_stories_serialized.data)
return response.BadRequest(serializer.errors) return response.BadRequest(serializer.errors)
@list_route(methods=["POST"]) def _bulk_update_order(self, order_field, request, **kwargs):
def bulk_update_backlog_order(self, request, **kwargs):
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA)
if not serializer.is_valid(): if not serializer.is_valid():
return response.BadRequest(serializer.errors) return response.BadRequest(serializer.errors)
@ -122,42 +121,22 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
self.check_permissions(request, "bulk_update_order", project) self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"], services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project, project=project,
field="backlog_order") field=order_field)
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user) services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent() return response.NoContent()
@list_route(methods=["POST"])
def bulk_update_backlog_order(self, request, **kwargs):
return self._bulk_update_order("backlog_order", request, **kwargs)
@list_route(methods=["POST"]) @list_route(methods=["POST"])
def bulk_update_sprint_order(self, request, **kwargs): def bulk_update_sprint_order(self, request, **kwargs):
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) return self._bulk_update_order("sprint_order", request, **kwargs)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
data = serializer.data
project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project,
field="sprint_order")
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent()
@list_route(methods=["POST"]) @list_route(methods=["POST"])
def bulk_update_kanban_order(self, request, **kwargs): def bulk_update_kanban_order(self, request, **kwargs):
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA) return self._bulk_update_order("kanban_order", request, **kwargs)
if not serializer.is_valid():
return response.BadRequest(serializer.errors)
data = serializer.data
project = get_object_or_404(Project, pk=data["project_id"])
self.check_permissions(request, "bulk_update_order", project)
services.update_userstories_order_in_bulk(data["bulk_stories"],
project=project,
field="kanban_order")
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
return response.NoContent()
@transaction.atomic @transaction.atomic
def create(self, *args, **kwargs): def create(self, *args, **kwargs):

View File

@ -132,8 +132,16 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications") router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
# GitHub webhooks # GitHub webhooks
from taiga.github_hook.api import GitHubViewSet from taiga.hooks.github.api import GitHubViewSet
router.register(r"github-hook", GitHubViewSet, base_name="github-hook") router.register(r"github-hook", GitHubViewSet, base_name="github-hook")
# Gitlab webhooks
from taiga.hooks.gitlab.api import GitLabViewSet
router.register(r"gitlab-hook", GitLabViewSet, base_name="gitlab-hook")
# Bitbucket webhooks
from taiga.hooks.bitbucket.api import BitBucketViewSet
router.register(r"bitbucket-hook", BitBucketViewSet, base_name="bitbucket-hook")
# feedback # feedback
# - see taiga.feedback.routers and taiga.feedback.apps # - see taiga.feedback.routers and taiga.feedback.apps

View File

@ -40,6 +40,7 @@ from taiga.base.api import ModelCrudViewSet
from taiga.base.utils.slug import slugify_uniquely from taiga.base.utils.slug import slugify_uniquely
from taiga.projects.votes import services as votes_service from taiga.projects.votes import services as votes_service
from taiga.projects.serializers import StarredSerializer from taiga.projects.serializers import StarredSerializer
from taiga.permissions.service import is_project_owner
from . import models from . import models
from . import serializers from . import serializers
@ -53,8 +54,8 @@ class MembersFilterBackend(BaseFilterBackend):
if project_id: if project_id:
Project = apps.get_model('projects', 'Project') Project = apps.get_model('projects', 'Project')
project = get_object_or_404(Project, pk=project_id) project = get_object_or_404(Project, pk=project_id)
if request.user.is_authenticated() and (project.memberships.filter(user=request.user).exists() or project.owner == request.user): if request.user.is_authenticated() and project.memberships.filter(user=request.user).exists():
return queryset.filter(Q(memberships__project=project) | Q(id=project.owner.id)).distinct() return queryset.filter(memberships__project=project).distinct()
else: else:
raise exc.PermissionDenied(_("You don't have permisions to see this project users.")) raise exc.PermissionDenied(_("You don't have permisions to see this project users."))

View File

@ -400,6 +400,13 @@ class AttachmentFactory(Factory):
attached_file = factory.django.FileField(data=b"File contents") attached_file = factory.django.FileField(data=b"File contents")
class HistoryEntryFactory(Factory):
class Meta:
model = "history.HistoryEntry"
strategy = factory.CREATE_STRATEGY
type = 1
def create_issue(**kwargs): def create_issue(**kwargs):
"Create an issue and along with its dependencies." "Create an issue and along with its dependencies."
owner = kwargs.pop("owner", None) owner = kwargs.pop("owner", None)

View File

@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest import pytest
import mock from unittest import mock
import functools import functools

View File

@ -69,6 +69,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
return m return m
@pytest.fixture @pytest.fixture

View File

@ -56,10 +56,23 @@ def data():
user=m.project_member_with_perms, user=m.project_member_with_perms,
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
f.MembershipFactory(project=m.private_project2, f.MembershipFactory(project=m.private_project2,
user=m.project_member_without_perms, user=m.project_member_without_perms,
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
return m return m
@pytest.fixture @pytest.fixture

View File

@ -7,6 +7,9 @@ from taiga.base.utils import json
from tests import factories as f from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
from taiga.projects.votes.services import add_vote from taiga.projects.votes.services import add_vote
from taiga.projects.occ import OCCResourceMixin
from unittest import mock
import pytest import pytest
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -64,6 +67,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_issue = f.IssueFactory(project=m.public_project, m.public_issue = f.IssueFactory(project=m.public_project,
status__project=m.public_project, status__project=m.public_project,
severity__project=m.public_project, severity__project=m.public_project,
@ -120,6 +135,7 @@ def test_issue_update(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
issue_data = IssueSerializer(data.public_issue).data issue_data = IssueSerializer(data.public_issue).data
issue_data["subject"] = "test" issue_data["subject"] = "test"
issue_data = json.dumps(issue_data) issue_data = json.dumps(issue_data)
@ -150,6 +166,7 @@ def test_issue_delete(client, data):
data.project_member_without_perms, data.project_member_without_perms,
data.project_member_with_perms, data.project_member_with_perms,
] ]
results = helper_test_http_method(client, 'delete', public_url, None, users) results = helper_test_http_method(client, 'delete', public_url, None, users)
assert results == [401, 403, 403, 204] assert results == [401, 403, 403, 204]
results = helper_test_http_method(client, 'delete', private_url1, None, users) results = helper_test_http_method(client, 'delete', private_url1, None, users)
@ -249,6 +266,7 @@ def test_issue_patch(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
patch_data = json.dumps({"subject": "test", "version": data.public_issue.version}) patch_data = json.dumps({"subject": "test", "version": data.public_issue.version})
results = helper_test_http_method(client, 'patch', public_url, patch_data, users) results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
assert results == [401, 403, 403, 200, 200] assert results == [401, 403, 403, 200, 200]
@ -291,7 +309,6 @@ def test_issue_bulk_create(client, data):
data.project_owner data.project_owner
] ]
bulk_data = json.dumps({"bulk_issues": "test1\ntest2", bulk_data = json.dumps({"bulk_issues": "test1\ntest2",
"project_id": data.public_issue.project.pk}) "project_id": data.public_issue.project.pk})
results = helper_test_http_method(client, 'post', url, bulk_data, users) results = helper_test_http_method(client, 'post', url, bulk_data, users)

View File

@ -64,6 +64,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_milestone = f.MilestoneFactory(project=m.public_project) m.public_milestone = f.MilestoneFactory(project=m.public_project)
m.private_milestone1 = f.MilestoneFactory(project=m.private_project1) m.private_milestone1 = f.MilestoneFactory(project=m.private_project1)
m.private_milestone2 = f.MilestoneFactory(project=m.private_project2) m.private_milestone2 = f.MilestoneFactory(project=m.private_project2)

View File

@ -44,6 +44,7 @@ def data():
email=m.project_member_with_perms.email, email=m.project_member_with_perms.email,
role__project=m.private_project1, role__project=m.private_project1,
role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS))) role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
f.MembershipFactory(project=m.private_project1, f.MembershipFactory(project=m.private_project1,
user=m.project_member_without_perms, user=m.project_member_without_perms,
email=m.project_member_without_perms.email, email=m.project_member_without_perms.email,
@ -60,6 +61,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_points = f.PointsFactory(project=m.public_project) m.public_points = f.PointsFactory(project=m.public_project)
m.private_points1 = f.PointsFactory(project=m.private_project1) m.private_points1 = f.PointsFactory(project=m.private_project1)
m.private_points2 = f.PointsFactory(project=m.private_project2) m.private_points2 = f.PointsFactory(project=m.private_project2)
@ -1427,31 +1440,31 @@ def test_membership_list(client, data):
response = client.get(url) response = client.get(url)
projects_data = json.loads(response.content.decode('utf-8')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 3 assert len(projects_data) == 5
assert response.status_code == 200 assert response.status_code == 200
client.login(data.registered_user) client.login(data.registered_user)
response = client.get(url) response = client.get(url)
projects_data = json.loads(response.content.decode('utf-8')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 3 assert len(projects_data) == 5
assert response.status_code == 200 assert response.status_code == 200
client.login(data.project_member_without_perms) client.login(data.project_member_without_perms)
response = client.get(url) response = client.get(url)
projects_data = json.loads(response.content.decode('utf-8')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 3 assert len(projects_data) == 5
assert response.status_code == 200 assert response.status_code == 200
client.login(data.project_member_with_perms) client.login(data.project_member_with_perms)
response = client.get(url) response = client.get(url)
projects_data = json.loads(response.content.decode('utf-8')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 5 assert len(projects_data) == 8
assert response.status_code == 200 assert response.status_code == 200
client.login(data.project_owner) client.login(data.project_owner)
response = client.get(url) response = client.get(url)
projects_data = json.loads(response.content.decode('utf-8')) projects_data = json.loads(response.content.decode('utf-8'))
assert len(projects_data) == 5 assert len(projects_data) == 8
assert response.status_code == 200 assert response.status_code == 200

View File

@ -53,6 +53,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
ContentType = apps.get_model("contenttypes", "ContentType") ContentType = apps.get_model("contenttypes", "ContentType")
Project = apps.get_model("projects", "Project") Project = apps.get_model("projects", "Project")

View File

@ -65,6 +65,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.view_only_membership = f.MembershipFactory(project=m.private_project2, m.view_only_membership = f.MembershipFactory(project=m.private_project2,
user=m.other_user, user=m.other_user,
role__project=m.private_project2, role__project=m.private_project2,

View File

@ -62,6 +62,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_issue = f.IssueFactory(project=m.public_project, m.public_issue = f.IssueFactory(project=m.public_project,
status__project=m.public_project, status__project=m.public_project,
severity__project=m.public_project, severity__project=m.public_project,

View File

@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse
from taiga.base.utils import json from taiga.base.utils import json
from taiga.projects.tasks.serializers import TaskSerializer from taiga.projects.tasks.serializers import TaskSerializer
from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
from taiga.projects.occ import OCCResourceMixin
from tests import factories as f from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
from unittest import mock
import pytest import pytest
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -63,6 +66,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_task = f.TaskFactory(project=m.public_project, m.public_task = f.TaskFactory(project=m.public_project,
status__project=m.public_project, status__project=m.public_project,
milestone__project=m.public_project, milestone__project=m.public_project,
@ -120,6 +135,7 @@ def test_task_update(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
task_data = TaskSerializer(data.public_task).data task_data = TaskSerializer(data.public_task).data
task_data["subject"] = "test" task_data["subject"] = "test"
task_data = json.dumps(task_data) task_data = json.dumps(task_data)
@ -240,6 +256,7 @@ def test_task_patch(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
patch_data = json.dumps({"subject": "test", "version": data.public_task.version}) patch_data = json.dumps({"subject": "test", "version": data.public_task.version})
results = helper_test_http_method(client, 'patch', public_url, patch_data, users) results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
assert results == [401, 403, 403, 200, 200] assert results == [401, 403, 403, 200, 200]

View File

@ -61,6 +61,17 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
return m return m

View File

@ -103,7 +103,7 @@ def test_user_list(client, data):
response = client.get(url) response = client.get(url)
users_data = json.loads(response.content.decode('utf-8')) users_data = json.loads(response.content.decode('utf-8'))
assert len(users_data) == 4 assert len(users_data) == 6
assert response.status_code == 200 assert response.status_code == 200

View File

@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse
from taiga.base.utils import json from taiga.base.utils import json
from taiga.projects.userstories.serializers import UserStorySerializer from taiga.projects.userstories.serializers import UserStorySerializer
from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
from taiga.projects.occ import OCCResourceMixin
from tests import factories as f from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
from unittest import mock
import pytest import pytest
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -63,6 +66,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_points = f.PointsFactory(project=m.public_project) m.public_points = f.PointsFactory(project=m.public_project)
m.private_points1 = f.PointsFactory(project=m.private_project1) m.private_points1 = f.PointsFactory(project=m.private_project1)
m.private_points2 = f.PointsFactory(project=m.private_project2) m.private_points2 = f.PointsFactory(project=m.private_project2)
@ -118,6 +133,7 @@ def test_user_story_update(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
user_story_data = UserStorySerializer(data.public_user_story).data user_story_data = UserStorySerializer(data.public_user_story).data
user_story_data["subject"] = "test" user_story_data["subject"] = "test"
user_story_data = json.dumps(user_story_data) user_story_data = json.dumps(user_story_data)
@ -223,6 +239,7 @@ def test_user_story_patch(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
patch_data = json.dumps({"subject": "test", "version": data.public_user_story.version}) patch_data = json.dumps({"subject": "test", "version": data.public_user_story.version})
results = helper_test_http_method(client, 'patch', public_url, patch_data, users) results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
assert results == [401, 403, 403, 200, 200] assert results == [401, 403, 403, 200, 200]

View File

@ -4,10 +4,13 @@ from taiga.base.utils import json
from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer
from taiga.projects.wiki.models import WikiPage, WikiLink from taiga.projects.wiki.models import WikiPage, WikiLink
from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS from taiga.permissions.permissions import MEMBERS_PERMISSIONS, ANON_PERMISSIONS, USER_PERMISSIONS
from taiga.projects.occ import OCCResourceMixin
from tests import factories as f from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
from unittest import mock
import pytest import pytest
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -64,6 +67,18 @@ def data():
role__project=m.private_project2, role__project=m.private_project2,
role__permissions=[]) role__permissions=[])
f.MembershipFactory(project=m.public_project,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project1,
user=m.project_owner,
is_owner=True)
f.MembershipFactory(project=m.private_project2,
user=m.project_owner,
is_owner=True)
m.public_wiki_page = f.WikiPageFactory(project=m.public_project) m.public_wiki_page = f.WikiPageFactory(project=m.public_project)
m.private_wiki_page1 = f.WikiPageFactory(project=m.private_project1) m.private_wiki_page1 = f.WikiPageFactory(project=m.private_project1)
m.private_wiki_page2 = f.WikiPageFactory(project=m.private_project2) m.private_wiki_page2 = f.WikiPageFactory(project=m.private_project2)
@ -109,6 +124,7 @@ def test_wiki_page_update(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
wiki_page_data = WikiPageSerializer(data.public_wiki_page).data wiki_page_data = WikiPageSerializer(data.public_wiki_page).data
wiki_page_data["content"] = "test" wiki_page_data["content"] = "test"
wiki_page_data = json.dumps(wiki_page_data) wiki_page_data = json.dumps(wiki_page_data)
@ -226,6 +242,7 @@ def test_wiki_page_patch(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
patch_data = json.dumps({"content": "test", "version": data.public_wiki_page.version}) patch_data = json.dumps({"content": "test", "version": data.public_wiki_page.version})
results = helper_test_http_method(client, 'patch', public_url, patch_data, users) results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
assert results == [401, 200, 200, 200, 200] assert results == [401, 200, 200, 200, 200]
@ -288,6 +305,7 @@ def test_wiki_link_update(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data wiki_link_data = WikiLinkSerializer(data.public_wiki_link).data
wiki_link_data["title"] = "test" wiki_link_data["title"] = "test"
wiki_link_data = json.dumps(wiki_link_data) wiki_link_data = json.dumps(wiki_link_data)
@ -405,6 +423,7 @@ def test_wiki_link_patch(client, data):
data.project_owner data.project_owner
] ]
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
patch_data = json.dumps({"title": "test"}) patch_data = json.dumps({"title": "test"})
results = helper_test_http_method(client, 'patch', public_url, patch_data, users) results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
assert results == [401, 200, 200, 200, 200] assert results == [401, 200, 200, 200, 200]

View File

@ -15,6 +15,7 @@ def test_create_user_story_attachment_without_file(client):
Bug test "Don't create attachments without attached_file" Bug test "Don't create attachments without attached_file"
""" """
us = f.UserStoryFactory.create() us = f.UserStoryFactory.create()
membership = f.MembershipFactory(project=us.project, user=us.owner, is_owner=True)
attachment_data = { attachment_data = {
"description": "test", "description": "test",
"attached_file": None, "attached_file": None,
@ -31,6 +32,7 @@ def test_create_user_story_attachment_without_file(client):
def test_create_attachment_on_wrong_project(client): def test_create_attachment_on_wrong_project(client):
issue1 = f.create_issue() issue1 = f.create_issue()
issue2 = f.create_issue(owner=issue1.owner) issue2 = f.create_issue(owner=issue1.owner)
membership = f.MembershipFactory(project=issue1.project, user=issue1.owner, is_owner=True)
assert issue1.owner == issue2.owner assert issue1.owner == issue2.owner
assert issue1.project.owner == issue2.project.owner assert issue1.project.owner == issue2.project.owner

View File

@ -26,7 +26,7 @@ from .. import factories as f
from taiga.projects.history import services from taiga.projects.history import services
from taiga.projects.history.models import HistoryEntry from taiga.projects.history.models import HistoryEntry
from taiga.projects.history.choices import HistoryType from taiga.projects.history.choices import HistoryType
from taiga.projects.history.services import make_key_from_model_object
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@ -143,7 +143,7 @@ def test_issue_resource_history_test(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
role = f.RoleFactory.create(project=project) role = f.RoleFactory.create(project=project)
member = f.MembershipFactory.create(project=project, user=user, role=role) membership = f.MembershipFactory.create(project=project, user=user, role=role, is_owner=True)
issue = f.IssueFactory.create(owner=user, project=project) issue = f.IssueFactory.create(owner=user, project=project)
mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save" mock_path = "taiga.projects.issues.api.IssueViewSet.pre_conditions_on_save"
@ -200,6 +200,7 @@ def test_take_hidden_snapshot():
def test_history_with_only_comment_shouldnot_be_hidden(client): def test_history_with_only_comment_shouldnot_be_hidden(client):
project = f.create_project() project = f.create_project()
us = f.create_userstory(project=project) us = f.create_userstory(project=project)
membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True)
qs_all = HistoryEntry.objects.all() qs_all = HistoryEntry.objects.all()
qs_hidden = qs_all.filter(is_hidden=True) qs_hidden = qs_all.filter(is_hidden=True)
@ -209,7 +210,6 @@ def test_history_with_only_comment_shouldnot_be_hidden(client):
url = reverse("userstories-detail", args=[us.pk]) url = reverse("userstories-detail", args=[us.pk])
data = json.dumps({"comment": "test comment", "version": us.version}) data = json.dumps({"comment": "test comment", "version": us.version})
print(url, data)
client.login(project.owner) client.login(project.owner)
response = client.patch(url, data, content_type="application/json") response = client.patch(url, data, content_type="application/json")
@ -217,3 +217,18 @@ def test_history_with_only_comment_shouldnot_be_hidden(client):
assert qs_all.count() == 1 assert qs_all.count() == 1
assert qs_hidden.count() == 0 assert qs_hidden.count() == 0
def test_delete_comment_by_project_owner(client):
project = f.create_project()
us = f.create_userstory(project=project)
membership = f.MembershipFactory.create(project=project, user=project.owner, is_owner=True)
key = make_key_from_model_object(us)
history_entry = f.HistoryEntryFactory.create(type=HistoryType.change,
comment="testing",
key=key)
client.login(project.owner)
url = reverse("userstory-history-delete-comment", args=(us.id,))
url = "%s?id=%s"%(url, history_entry.id)
response = client.post(url, content_type="application/json")
assert 200 == response.status_code, response.status_code

View File

@ -0,0 +1,269 @@
import pytest
import json
import urllib
from unittest import mock
from django.core.urlresolvers import reverse
from django.core import mail
from django.conf import settings
from taiga.hooks.bitbucket import event_hooks
from taiga.hooks.bitbucket.api import BitBucketViewSet
from taiga.hooks.exceptions import ActionSyntaxException
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
from taiga.projects.models import Membership
from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.notifications.models import NotifyPolicy
from taiga.projects import services
from .. import factories as f
pytestmark = pytest.mark.django_db
def test_bad_signature(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"bitbucket": {
"secret": "tpnIwJDz4e"
}
})
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
data = {}
response = client.post(url, urllib.parse.urlencode(data, True), content_type="application/x-www-form-urlencoded")
response_content = json.loads(response.content.decode("utf-8"))
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
def test_ok_signature(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"bitbucket": {
"secret": "tpnIwJDz4e"
}
})
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {'payload': ['{"commits": []}']}
response = client.post(url,
urllib.parse.urlencode(data, True),
content_type="application/x-www-form-urlencoded",
REMOTE_ADDR=settings.BITBUCKET_VALID_ORIGIN_IPS[0])
assert response.status_code == 200
def test_invalid_ip(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"bitbucket": {
"secret": "tpnIwJDz4e"
}
})
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {'payload': ['{"commits": []}']}
response = client.post(url,
urllib.parse.urlencode(data, True),
content_type="application/x-www-form-urlencoded",
REMOTE_ADDR="111.111.111.112")
assert response.status_code == 400
def test_not_ip_filter(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"bitbucket": {
"secret": "tpnIwJDz4e",
"valid_origin_ips": []
}
})
url = reverse("bitbucket-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {'payload': ['{"commits": []}']}
response = client.post(url,
urllib.parse.urlencode(data, True),
content_type="application/x-www-form-urlencoded",
REMOTE_ADDR="111.111.111.112")
assert response.status_code == 200
def test_push_event_detected(client):
project=f.ProjectFactory()
url = reverse("bitbucket-hook-list")
url = "%s?project=%s"%(url, project.id)
data = {'payload': ['{"commits": [{"message": "test message"}]}']}
BitBucketViewSet._validate_signature = mock.Mock(return_value=True)
with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock:
response = client.post(url, urllib.parse.urlencode(data, True),
content_type="application/x-www-form-urlencoded")
assert process_event_mock.call_count == 1
assert response.status_code == 200
def test_push_event_issue_processing(client):
creation_status = f.IssueStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.IssueStatusFactory(project=creation_status.project)
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = [
'{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(issue.ref, new_status.slug)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
ev_hook.process_event()
issue = Issue.objects.get(id=issue.id)
assert issue.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_task_processing(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = [
'{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(task.ref, new_status.slug)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
task = Task.objects.get(id=task.id)
assert task.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_user_story_processing(client):
creation_status = f.UserStoryStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_us"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.UserStoryStatusFactory(project=creation_status.project)
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = [
'{"commits": [{"message": "test message test TG-%s #%s ok bye!"}]}'%(user_story.ref, new_status.slug)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
ev_hook.process_event()
user_story = UserStory.objects.get(id=user_story.id)
assert user_story.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = [
'{"commits": [{"message": "test message test tg-%s #%s ok bye!"}]}'%(task.ref, new_status.slug.upper())
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
task = Task.objects.get(id=task.id)
assert task.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_task_bad_processing_non_existing_ref(client):
issue_status = f.IssueStatusFactory()
payload = [
'{"commits": [{"message": "test message test TG-6666666 #%s ok bye!"}]}'%(issue_status.slug)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue_status.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The referenced element doesn't exist"
assert len(mail.outbox) == 0
def test_push_event_us_bad_processing_non_existing_status(client):
user_story = f.UserStoryFactory.create()
payload = [
'{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}'%(user_story.ref)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The status doesn't exist"
assert len(mail.outbox) == 0
def test_push_event_bad_processing_non_existing_status(client):
issue = f.IssueFactory.create()
payload = [
'{"commits": [{"message": "test message test TG-%s #non-existing-slug ok bye!"}]}'%(issue.ref)
]
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The status doesn't exist"
assert len(mail.outbox) == 0
def test_api_get_project_modules(client):
project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,))
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
content = json.loads(response.content.decode("utf-8"))
assert "bitbucket" in content
assert content["bitbucket"]["secret"] != ""
assert content["bitbucket"]["webhooks_url"] != ""
def test_api_patch_project_modules(client):
project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,))
client.login(project.owner)
data = {
"bitbucket": {
"secret": "test_secret",
"url": "test_url",
}
}
response = client.patch(url, json.dumps(data), content_type="application/json")
assert response.status_code == 204
config = services.get_modules_config(project).config
assert "bitbucket" in config
assert config["bitbucket"]["secret"] == "test_secret"
assert config["bitbucket"]["webhooks_url"] != "test_url"
def test_replace_bitbucket_references():
assert event_hooks.replace_bitbucket_references("project-url", "#2") == "[BitBucket#2](project-url/issues/2)"
assert event_hooks.replace_bitbucket_references("project-url", "#2 ") == "[BitBucket#2](project-url/issues/2) "
assert event_hooks.replace_bitbucket_references("project-url", " #2 ") == " [BitBucket#2](project-url/issues/2) "
assert event_hooks.replace_bitbucket_references("project-url", " #2") == " [BitBucket#2](project-url/issues/2)"
assert event_hooks.replace_bitbucket_references("project-url", "#test") == "#test"

View File

@ -6,9 +6,9 @@ from unittest import mock
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core import mail from django.core import mail
from taiga.github_hook.api import GitHubViewSet from taiga.hooks.github import event_hooks
from taiga.github_hook import event_hooks from taiga.hooks.github.api import GitHubViewSet
from taiga.github_hook.exceptions import ActionSyntaxException from taiga.hooks.exceptions import ActionSyntaxException
from taiga.projects.issues.models import Issue from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory from taiga.projects.userstories.models import UserStory
@ -75,8 +75,10 @@ def test_push_event_detected(client):
def test_push_event_issue_processing(client): def test_push_event_issue_processing(client):
creation_status = f.IssueStatusFactory() creation_status = f.IssueStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.IssueStatusFactory(project=creation_status.project) new_status = f.IssueStatusFactory(project=creation_status.project)
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project) issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [ payload = {"commits": [
{"message": """test message {"message": """test message
test TG-%s #%s ok test TG-%s #%s ok
@ -93,8 +95,10 @@ def test_push_event_issue_processing(client):
def test_push_event_task_processing(client): def test_push_event_task_processing(client):
creation_status = f.TaskStatusFactory() creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project) new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project) task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [ payload = {"commits": [
{"message": """test message {"message": """test message
test TG-%s #%s ok test TG-%s #%s ok
@ -111,8 +115,10 @@ def test_push_event_task_processing(client):
def test_push_event_user_story_processing(client): def test_push_event_user_story_processing(client):
creation_status = f.UserStoryStatusFactory() creation_status = f.UserStoryStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_us"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.UserStoryStatusFactory(project=creation_status.project) new_status = f.UserStoryStatusFactory(project=creation_status.project)
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project) user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [ payload = {"commits": [
{"message": """test message {"message": """test message
test TG-%s #%s ok test TG-%s #%s ok
@ -130,8 +136,10 @@ def test_push_event_user_story_processing(client):
def test_push_event_processing_case_insensitive(client): def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory() creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project) new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project) task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [ payload = {"commits": [
{"message": """test message {"message": """test message
test tg-%s #%s ok test tg-%s #%s ok
@ -291,12 +299,17 @@ def test_issues_event_bad_issue(client):
def test_issue_comment_event_on_existing_issue_task_and_us(client): def test_issue_comment_event_on_existing_issue_task_and_us(client):
issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"]) project = f.ProjectFactory()
take_snapshot(issue, user=issue.owner) role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"])
task = f.TaskFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"]) membership = f.MembershipFactory(project=project, role=role, user=project.owner)
take_snapshot(task, user=task.owner) user = f.UserFactory()
us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"])
take_snapshot(us, user=us.owner) issue = f.IssueFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project)
take_snapshot(issue, user=user)
task = f.TaskFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project)
take_snapshot(task, user=user)
us = f.UserStoryFactory.create(external_reference=["github", "http://github.com/test/project/issues/11"], owner=project.owner, project=project)
take_snapshot(us, user=user)
payload = { payload = {
"action": "created", "action": "created",
@ -399,6 +412,7 @@ def test_issues_event_bad_comment(client):
def test_api_get_project_modules(client): def test_api_get_project_modules(client):
project = f.create_project() project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,)) url = reverse("projects-modules", args=(project.id,))
@ -413,6 +427,7 @@ def test_api_get_project_modules(client):
def test_api_patch_project_modules(client): def test_api_patch_project_modules(client):
project = f.create_project() project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,)) url = reverse("projects-modules", args=(project.id,))

View File

@ -0,0 +1,385 @@
import pytest
import json
from unittest import mock
from django.core.urlresolvers import reverse
from django.core import mail
from taiga.hooks.gitlab import event_hooks
from taiga.hooks.gitlab.api import GitLabViewSet
from taiga.hooks.exceptions import ActionSyntaxException
from taiga.projects.issues.models import Issue
from taiga.projects.tasks.models import Task
from taiga.projects.userstories.models import UserStory
from taiga.projects.models import Membership
from taiga.projects.history.services import get_history_queryset_by_model_instance, take_snapshot
from taiga.projects.notifications.choices import NotifyLevel
from taiga.projects.notifications.models import NotifyPolicy
from taiga.projects import services
from .. import factories as f
pytestmark = pytest.mark.django_db
def test_bad_signature(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"gitlab": {
"secret": "tpnIwJDz4e"
}
})
url = reverse("gitlab-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "badbadbad")
data = {}
response = client.post(url, json.dumps(data), content_type="application/json")
response_content = json.loads(response.content.decode("utf-8"))
assert response.status_code == 400
assert "Bad signature" in response_content["_error_message"]
def test_ok_signature(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"gitlab": {
"secret": "tpnIwJDz4e",
"valid_origin_ips": ["111.111.111.111"],
}
})
url = reverse("gitlab-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {"test:": "data"}
response = client.post(url,
json.dumps(data),
content_type="application/json",
REMOTE_ADDR="111.111.111.111")
assert response.status_code == 200
def test_invalid_ip(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"gitlab": {
"secret": "tpnIwJDz4e",
"valid_origin_ips": ["111.111.111.111"],
}
})
url = reverse("gitlab-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {"test:": "data"}
response = client.post(url,
json.dumps(data),
content_type="application/json",
REMOTE_ADDR="111.111.111.112")
assert response.status_code == 400
def test_not_ip_filter(client):
project=f.ProjectFactory()
f.ProjectModulesConfigFactory(project=project, config={
"gitlab": {
"secret": "tpnIwJDz4e",
"valid_origin_ips": [],
}
})
url = reverse("gitlab-hook-list")
url = "{}?project={}&key={}".format(url, project.id, "tpnIwJDz4e")
data = {"test:": "data"}
response = client.post(url,
json.dumps(data),
content_type="application/json",
REMOTE_ADDR="111.111.111.111")
assert response.status_code == 200
def test_push_event_detected(client):
project=f.ProjectFactory()
url = reverse("gitlab-hook-list")
url = "%s?project=%s"%(url, project.id)
data = {"commits": [
{"message": "test message"},
]}
GitLabViewSet._validate_signature = mock.Mock(return_value=True)
with mock.patch.object(event_hooks.PushEventHook, "process_event") as process_event_mock:
response = client.post(url, json.dumps(data),
HTTP_X_GITHUB_EVENT="push",
content_type="application/json")
assert process_event_mock.call_count == 1
assert response.status_code == 200
def test_push_event_issue_processing(client):
creation_status = f.IssueStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_issues"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.IssueStatusFactory(project=creation_status.project)
issue = f.IssueFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [
{"message": """test message
test TG-%s #%s ok
bye!
"""%(issue.ref, new_status.slug)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
ev_hook.process_event()
issue = Issue.objects.get(id=issue.id)
assert issue.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_task_processing(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [
{"message": """test message
test TG-%s #%s ok
bye!
"""%(task.ref, new_status.slug)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
task = Task.objects.get(id=task.id)
assert task.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_user_story_processing(client):
creation_status = f.UserStoryStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_us"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.UserStoryStatusFactory(project=creation_status.project)
user_story = f.UserStoryFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [
{"message": """test message
test TG-%s #%s ok
bye!
"""%(user_story.ref, new_status.slug)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
ev_hook.process_event()
user_story = UserStory.objects.get(id=user_story.id)
assert user_story.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_processing_case_insensitive(client):
creation_status = f.TaskStatusFactory()
role = f.RoleFactory(project=creation_status.project, permissions=["view_tasks"])
membership = f.MembershipFactory(project=creation_status.project, role=role, user=creation_status.project.owner)
new_status = f.TaskStatusFactory(project=creation_status.project)
task = f.TaskFactory.create(status=creation_status, project=creation_status.project, owner=creation_status.project.owner)
payload = {"commits": [
{"message": """test message
test tg-%s #%s ok
bye!
"""%(task.ref, new_status.slug.upper())},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(task.project, payload)
ev_hook.process_event()
task = Task.objects.get(id=task.id)
assert task.status.id == new_status.id
assert len(mail.outbox) == 1
def test_push_event_task_bad_processing_non_existing_ref(client):
issue_status = f.IssueStatusFactory()
payload = {"commits": [
{"message": """test message
test TG-6666666 #%s ok
bye!
"""%(issue_status.slug)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue_status.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The referenced element doesn't exist"
assert len(mail.outbox) == 0
def test_push_event_us_bad_processing_non_existing_status(client):
user_story = f.UserStoryFactory.create()
payload = {"commits": [
{"message": """test message
test TG-%s #non-existing-slug ok
bye!
"""%(user_story.ref)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(user_story.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The status doesn't exist"
assert len(mail.outbox) == 0
def test_push_event_bad_processing_non_existing_status(client):
issue = f.IssueFactory.create()
payload = {"commits": [
{"message": """test message
test TG-%s #non-existing-slug ok
bye!
"""%(issue.ref)},
]}
mail.outbox = []
ev_hook = event_hooks.PushEventHook(issue.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "The status doesn't exist"
assert len(mail.outbox) == 0
def test_issues_event_opened_issue(client):
issue = f.IssueFactory.create()
issue.project.default_issue_status = issue.status
issue.project.default_issue_type = issue.type
issue.project.default_severity = issue.severity
issue.project.default_priority = issue.priority
issue.project.save()
Membership.objects.create(user=issue.owner, project=issue.project, role=f.RoleFactory.create(project=issue.project), is_owner=True)
notify_policy = NotifyPolicy.objects.get(user=issue.owner, project=issue.project)
notify_policy.notify_level = NotifyLevel.watch
notify_policy.save()
payload = {
"object_kind": "issue",
"object_attributes": {
"title": "test-title",
"description": "test-body",
"url": "http://gitlab.com/test/project/issues/11",
"action": "open",
},
}
mail.outbox = []
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
ev_hook.process_event()
assert Issue.objects.count() == 2
assert len(mail.outbox) == 1
def test_issues_event_other_than_opened_issue(client):
issue = f.IssueFactory.create()
issue.project.default_issue_status = issue.status
issue.project.default_issue_type = issue.type
issue.project.default_severity = issue.severity
issue.project.default_priority = issue.priority
issue.project.save()
payload = {
"object_kind": "issue",
"object_attributes": {
"title": "test-title",
"description": "test-body",
"url": "http://gitlab.com/test/project/issues/11",
"action": "update",
},
}
mail.outbox = []
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
ev_hook.process_event()
assert Issue.objects.count() == 1
assert len(mail.outbox) == 0
def test_issues_event_bad_issue(client):
issue = f.IssueFactory.create()
issue.project.default_issue_status = issue.status
issue.project.default_issue_type = issue.type
issue.project.default_severity = issue.severity
issue.project.default_priority = issue.priority
issue.project.save()
payload = {
"object_kind": "issue",
"object_attributes": {
"action": "open",
},
}
mail.outbox = []
ev_hook = event_hooks.IssuesEventHook(issue.project, payload)
with pytest.raises(ActionSyntaxException) as excinfo:
ev_hook.process_event()
assert str(excinfo.value) == "Invalid issue information"
assert Issue.objects.count() == 1
assert len(mail.outbox) == 0
def test_api_get_project_modules(client):
project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,))
client.login(project.owner)
response = client.get(url)
assert response.status_code == 200
content = json.loads(response.content.decode("utf-8"))
assert "gitlab" in content
assert content["gitlab"]["secret"] != ""
assert content["gitlab"]["webhooks_url"] != ""
def test_api_patch_project_modules(client):
project = f.create_project()
membership = f.MembershipFactory(project=project, user=project.owner, is_owner=True)
url = reverse("projects-modules", args=(project.id,))
client.login(project.owner)
data = {
"gitlab": {
"secret": "test_secret",
"url": "test_url",
}
}
response = client.patch(url, json.dumps(data), content_type="application/json")
assert response.status_code == 204
config = services.get_modules_config(project).config
assert "gitlab" in config
assert config["gitlab"]["secret"] == "test_secret"
assert config["gitlab"]["webhooks_url"] != "test_url"
def test_replace_gitlab_references():
assert event_hooks.replace_gitlab_references("project-url", "#2") == "[GitLab#2](project-url/issues/2)"
assert event_hooks.replace_gitlab_references("project-url", "#2 ") == "[GitLab#2](project-url/issues/2) "
assert event_hooks.replace_gitlab_references("project-url", " #2 ") == " [GitLab#2](project-url/issues/2) "
assert event_hooks.replace_gitlab_references("project-url", " #2") == " [GitLab#2](project-url/issues/2)"
assert event_hooks.replace_gitlab_references("project-url", "#test") == "#test"

View File

@ -168,6 +168,7 @@ def test_invalid_project_import_with_extra_data(client):
def test_invalid_issue_import(client): def test_invalid_issue_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-issue", args=[project.pk]) url = reverse("importer-issue", args=[project.pk])
@ -179,6 +180,7 @@ def test_invalid_issue_import(client):
def test_valid_user_story_import(client): def test_valid_user_story_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.default_us_status = f.UserStoryStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -199,6 +201,7 @@ def test_valid_user_story_import(client):
def test_valid_issue_import_without_extra_data(client): def test_valid_issue_import_without_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_type = f.IssueTypeFactory.create(project=project)
project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project)
project.default_severity = f.SeverityFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project)
@ -220,6 +223,7 @@ def test_valid_issue_import_without_extra_data(client):
def test_valid_issue_import_with_extra_data(client): def test_valid_issue_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_type = f.IssueTypeFactory.create(project=project)
project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project)
project.default_severity = f.SeverityFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project)
@ -252,6 +256,7 @@ def test_valid_issue_import_with_extra_data(client):
def test_invalid_issue_import_with_extra_data(client): def test_invalid_issue_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_type = f.IssueTypeFactory.create(project=project)
project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project)
project.default_severity = f.SeverityFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project)
@ -275,6 +280,7 @@ def test_invalid_issue_import_with_extra_data(client):
def test_invalid_issue_import_with_bad_choices(client): def test_invalid_issue_import_with_bad_choices(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_issue_type = f.IssueTypeFactory.create(project=project) project.default_issue_type = f.IssueTypeFactory.create(project=project)
project.default_issue_status = f.IssueStatusFactory.create(project=project) project.default_issue_status = f.IssueStatusFactory.create(project=project)
project.default_severity = f.SeverityFactory.create(project=project) project.default_severity = f.SeverityFactory.create(project=project)
@ -333,6 +339,7 @@ def test_invalid_issue_import_with_bad_choices(client):
def test_invalid_us_import(client): def test_invalid_us_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-us", args=[project.pk]) url = reverse("importer-us", args=[project.pk])
@ -344,6 +351,7 @@ def test_invalid_us_import(client):
def test_valid_us_import_without_extra_data(client): def test_valid_us_import_without_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.default_us_status = f.UserStoryStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -362,6 +370,7 @@ def test_valid_us_import_without_extra_data(client):
def test_valid_us_import_with_extra_data(client): def test_valid_us_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.default_us_status = f.UserStoryStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -389,6 +398,7 @@ def test_valid_us_import_with_extra_data(client):
def test_invalid_us_import_with_extra_data(client): def test_invalid_us_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.default_us_status = f.UserStoryStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -409,6 +419,7 @@ def test_invalid_us_import_with_extra_data(client):
def test_invalid_us_import_with_bad_choices(client): def test_invalid_us_import_with_bad_choices(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_us_status = f.UserStoryStatusFactory.create(project=project) project.default_us_status = f.UserStoryStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -428,6 +439,7 @@ def test_invalid_us_import_with_bad_choices(client):
def test_invalid_task_import(client): def test_invalid_task_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-task", args=[project.pk]) url = reverse("importer-task", args=[project.pk])
@ -439,6 +451,7 @@ def test_invalid_task_import(client):
def test_valid_task_import_without_extra_data(client): def test_valid_task_import_without_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project) project.default_task_status = f.TaskStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -457,6 +470,7 @@ def test_valid_task_import_without_extra_data(client):
def test_valid_task_import_with_extra_data(client): def test_valid_task_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project) project.default_task_status = f.TaskStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -484,6 +498,7 @@ def test_valid_task_import_with_extra_data(client):
def test_invalid_task_import_with_extra_data(client): def test_invalid_task_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project) project.default_task_status = f.TaskStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -504,6 +519,7 @@ def test_invalid_task_import_with_extra_data(client):
def test_invalid_task_import_with_bad_choices(client): def test_invalid_task_import_with_bad_choices(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project) project.default_task_status = f.TaskStatusFactory.create(project=project)
project.save() project.save()
client.login(user) client.login(user)
@ -523,6 +539,7 @@ def test_invalid_task_import_with_bad_choices(client):
def test_valid_task_with_user_story(client): def test_valid_task_with_user_story(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
project.default_task_status = f.TaskStatusFactory.create(project=project) project.default_task_status = f.TaskStatusFactory.create(project=project)
us = f.UserStoryFactory.create(project=project) us = f.UserStoryFactory.create(project=project)
project.save() project.save()
@ -542,6 +559,7 @@ def test_valid_task_with_user_story(client):
def test_invalid_wiki_page_import(client): def test_invalid_wiki_page_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-page", args=[project.pk]) url = reverse("importer-wiki-page", args=[project.pk])
@ -553,6 +571,7 @@ def test_invalid_wiki_page_import(client):
def test_valid_wiki_page_import_without_extra_data(client): def test_valid_wiki_page_import_without_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-page", args=[project.pk]) url = reverse("importer-wiki-page", args=[project.pk])
@ -568,6 +587,7 @@ def test_valid_wiki_page_import_without_extra_data(client):
def test_valid_wiki_page_import_with_extra_data(client): def test_valid_wiki_page_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-page", args=[project.pk]) url = reverse("importer-wiki-page", args=[project.pk])
@ -592,6 +612,7 @@ def test_valid_wiki_page_import_with_extra_data(client):
def test_invalid_wiki_page_import_with_extra_data(client): def test_invalid_wiki_page_import_with_extra_data(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-page", args=[project.pk]) url = reverse("importer-wiki-page", args=[project.pk])
@ -610,6 +631,7 @@ def test_invalid_wiki_page_import_with_extra_data(client):
def test_invalid_wiki_link_import(client): def test_invalid_wiki_link_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-link", args=[project.pk]) url = reverse("importer-wiki-link", args=[project.pk])
@ -621,6 +643,7 @@ def test_invalid_wiki_link_import(client):
def test_valid_wiki_link_import(client): def test_valid_wiki_link_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-wiki-link", args=[project.pk]) url = reverse("importer-wiki-link", args=[project.pk])
@ -636,6 +659,7 @@ def test_valid_wiki_link_import(client):
def test_invalid_milestone_import(client): def test_invalid_milestone_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-milestone", args=[project.pk]) url = reverse("importer-milestone", args=[project.pk])
@ -647,6 +671,7 @@ def test_invalid_milestone_import(client):
def test_valid_milestone_import(client): def test_valid_milestone_import(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-milestone", args=[project.pk]) url = reverse("importer-milestone", args=[project.pk])
@ -663,6 +688,7 @@ def test_valid_milestone_import(client):
def test_milestone_import_duplicated_milestone(client): def test_milestone_import_duplicated_milestone(client):
user = f.UserFactory.create() user = f.UserFactory.create()
project = f.ProjectFactory.create(owner=user) project = f.ProjectFactory.create(owner=user)
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
client.login(user) client.login(user)
url = reverse("importer-milestone", args=[project.pk]) url = reverse("importer-milestone", args=[project.pk])

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