commit
7abeb74eee
|
@ -18,7 +18,6 @@ notifications:
|
|||
- jespinog@gmail.com
|
||||
- andrei.antoukh@gmail.com
|
||||
- bameda@dbarragan.com
|
||||
- anler86@gmail.com
|
||||
on_success: change
|
||||
on_failure: change
|
||||
after_success:
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -1,5 +1,28 @@
|
|||
# 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)
|
||||
|
||||
### Features
|
||||
|
|
|
@ -7,7 +7,7 @@ Examples of contributions include:
|
|||
- Code patches.
|
||||
- Documentation improvements.
|
||||
- Bug reports.
|
||||
- Patch reviews.
|
||||
- UI enhancements
|
||||
|
||||
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).
|
||||
|
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
|
|
@ -26,6 +26,7 @@ redis==2.10.3
|
|||
Unidecode==0.04.16
|
||||
raven==5.1.1
|
||||
bleach==1.4
|
||||
django-ipware==0.1.0
|
||||
|
||||
# Comment it if you are using python >= 3.4
|
||||
enum34==1.0
|
||||
|
|
|
@ -194,7 +194,9 @@ INSTALLED_APPS = [
|
|||
"taiga.mdrender",
|
||||
"taiga.export_import",
|
||||
"taiga.feedback",
|
||||
"taiga.github_hook",
|
||||
"taiga.hooks.github",
|
||||
"taiga.hooks.gitlab",
|
||||
"taiga.hooks.bitbucket",
|
||||
|
||||
"rest_framework",
|
||||
"djmail",
|
||||
|
@ -291,6 +293,15 @@ REST_FRAMEWORK = {
|
|||
# Mainly used for api debug.
|
||||
"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",
|
||||
"EXCEPTION_HANDLER": "taiga.base.exceptions.exception_handler",
|
||||
"PAGINATE_BY": 30,
|
||||
|
@ -299,6 +310,7 @@ REST_FRAMEWORK = {
|
|||
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z"
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_PROJECT_TEMPLATE = "scrum"
|
||||
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
|
||||
# This functions should receive a Project parameter and return a dict with the desired configuration
|
||||
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
|
||||
TEST_RUNNER="django.test.runner.DiscoverRunner"
|
||||
|
|
|
@ -32,20 +32,29 @@ from .development import *
|
|||
#MEDIA_ROOT = '/home/taiga/media'
|
||||
#STATIC_ROOT = '/home/taiga/static'
|
||||
|
||||
# EMAIL SETTINGS EXAMPLE
|
||||
#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
#EMAIL_USE_TLS = False
|
||||
#EMAIL_HOST = 'localhost'
|
||||
#EMAIL_PORT = 25
|
||||
#EMAIL_HOST_USER = 'user'
|
||||
#EMAIL_HOST_PASSWORD = 'password'
|
||||
#EMAIL_PORT = 25
|
||||
#DEFAULT_FROM_EMAIL = "john@doe.com"
|
||||
|
||||
# GMAIL SETTINGS EXAMPLE
|
||||
#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
#EMAIL_USE_TLS = True
|
||||
#EMAIL_HOST = 'smtp.gmail.com'
|
||||
#EMAIL_PORT = 587
|
||||
#EMAIL_HOST_USER = 'youremail@gmail.com'
|
||||
#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_URL = "https://github.com/"
|
||||
|
|
|
@ -24,3 +24,9 @@ MEDIA_ROOT = "/tmp"
|
|||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
INSTALLED_APPS = INSTALLED_APPS + ["tests"]
|
||||
|
||||
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
|
||||
"anon": None,
|
||||
"user": None,
|
||||
"import-mode": None
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import warnings
|
|||
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
|
||||
from django.core.paginator import Paginator, InvalidPage
|
||||
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 rest_framework import exceptions
|
||||
|
@ -31,6 +30,7 @@ from rest_framework.settings import api_settings
|
|||
|
||||
from . import views
|
||||
from . import mixins
|
||||
from .utils import get_object_or_404
|
||||
|
||||
|
||||
def strict_positive_int(integer_string, cutoff=None):
|
||||
|
@ -45,17 +45,6 @@ def strict_positive_int(integer_string, cutoff=None):
|
|||
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):
|
||||
"""
|
||||
Base class for all other generic views.
|
||||
|
|
|
@ -20,7 +20,6 @@
|
|||
import warnings
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
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.settings import api_settings
|
||||
|
||||
from .utils import get_object_or_404
|
||||
|
||||
|
||||
def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None):
|
||||
"""
|
||||
|
|
|
@ -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
|
|
@ -103,28 +103,13 @@ class PermissionBasedFilterBackend(FilterBackend):
|
|||
memberships_qs = Membership.objects.filter(user=request.user)
|
||||
if project_id:
|
||||
memberships_qs = memberships_qs.filter(project_id=project_id)
|
||||
|
||||
# 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)
|
||||
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=[self.permission]) | Q(is_owner=True))
|
||||
|
||||
projects_list = [membership.project_id for membership in memberships_qs]
|
||||
|
||||
if len(projects_list) == 0:
|
||||
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]))
|
||||
qs = qs.filter(Q(project_id__in=projects_list) | Q(project__public_permissions__contains=[self.permission]))
|
||||
else:
|
||||
qs = qs.filter(Q(project__owner=request.user) | Q(project__in=projects_list))
|
||||
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)
|
||||
qs = qs.filter(project__anon_permissions__contains=[self.permission])
|
||||
|
||||
return super().filter_queryset(request, qs.distinct(), view)
|
||||
|
||||
|
@ -197,19 +182,13 @@ class CanViewProjectObjFilterBackend(FilterBackend):
|
|||
memberships_qs = Membership.objects.filter(user=request.user)
|
||||
if 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.extra(where=["users_role.permissions @> ARRAY['view_project']"])
|
||||
memberships_qs = memberships_qs.filter(Q(role__permissions__contains=['view_project']) | Q(is_owner=True))
|
||||
|
||||
projects_list = [membership.project_id for membership in memberships_qs]
|
||||
|
||||
if len(projects_list) == 0:
|
||||
qs = qs.filter(Q(owner=request.user))
|
||||
elif len(projects_list) == 1:
|
||||
qs = qs.filter(Q(owner=request.user) | Q(id=projects_list[0]))
|
||||
qs = qs.filter(Q(id__in=projects_list) | Q(public_permissions__contains=["view_project"]))
|
||||
else:
|
||||
qs = qs.filter(Q(owner=request.user) | Q(id__in=projects_list))
|
||||
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)
|
||||
qs = qs.filter(public_permissions__contains=["view_project"])
|
||||
|
||||
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:
|
||||
queryset = queryset
|
||||
elif request.user.is_authenticated():
|
||||
queryset = queryset.filter(Q(project__members=request.user) |
|
||||
Q(project__owner=request.user))
|
||||
queryset = queryset.filter(project__members=request.user)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
|
||||
|
@ -232,12 +210,16 @@ class TagsFilter(FilterBackend):
|
|||
self.filter_name = filter_name
|
||||
|
||||
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):
|
||||
query_tags = self._get_tags_queryparams(request.QUERY_PARAMS)
|
||||
if query_tags:
|
||||
queryset = tags.filter(queryset, contains=query_tags)
|
||||
queryset = queryset.filter(tags__contains=query_tags)
|
||||
|
||||
return super().filter_queryset(request, queryset, view)
|
||||
|
||||
|
|
|
@ -29,94 +29,3 @@ class TaggedMixin(models.Model):
|
|||
|
||||
class Meta:
|
||||
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)
|
||||
|
|
|
@ -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"
|
|
@ -28,6 +28,7 @@ from taiga.base.decorators import detail_route
|
|||
from taiga.projects.models import Project, Membership
|
||||
from taiga.projects.issues.models import Issue
|
||||
|
||||
from . import mixins
|
||||
from . import serializers
|
||||
from . import service
|
||||
from . import permissions
|
||||
|
@ -37,7 +38,7 @@ class Http400(APIException):
|
|||
status_code = 400
|
||||
|
||||
|
||||
class ProjectImporterViewSet(CreateModelMixin, GenericViewSet):
|
||||
class ProjectImporterViewSet(mixins.ImportThrottlingPolicyMixin, CreateModelMixin, GenericViewSet):
|
||||
model = Project
|
||||
permission_classes = (permissions.ImportPermission, )
|
||||
|
||||
|
|
|
@ -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,)
|
|
@ -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"
|
|
@ -22,44 +22,20 @@ from taiga.base import exceptions as exc
|
|||
from taiga.base.utils import json
|
||||
from taiga.projects.models import Project
|
||||
|
||||
from . import event_hooks
|
||||
from .exceptions import ActionSyntaxException
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
|
||||
class GitHubViewSet(GenericViewSet):
|
||||
class BaseWebhookApiViewSet(GenericViewSet):
|
||||
# We don't want rest framework to parse the request body and transform it in
|
||||
# a dict in request.DATA, we need it raw
|
||||
parser_classes = ()
|
||||
|
||||
# This dict associates the event names we are listening for
|
||||
# with their reponsible classes (extending event_hooks.BaseEventHook)
|
||||
event_hook_classes = {
|
||||
"push": event_hooks.PushEventHook,
|
||||
"issues": event_hooks.IssuesEventHook,
|
||||
"issue_comment": event_hooks.IssueCommentEventHook,
|
||||
}
|
||||
event_hook_classes = {}
|
||||
|
||||
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)
|
||||
raise NotImplemented
|
||||
|
||||
def _get_project(self, request):
|
||||
project_id = request.GET.get("project", None)
|
||||
|
@ -69,6 +45,16 @@ class GitHubViewSet(GenericViewSet):
|
|||
except Project.DoesNotExist:
|
||||
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):
|
||||
project = self._get_project(request)
|
||||
if not project:
|
||||
|
@ -77,12 +63,9 @@ class GitHubViewSet(GenericViewSet):
|
|||
if not self._validate_signature(project, request):
|
||||
raise exc.BadRequest(_("Bad signature"))
|
||||
|
||||
event_name = request.META.get("HTTP_X_GITHUB_EVENT", None)
|
||||
event_name = self._get_event_name(request)
|
||||
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except ValueError:
|
||||
raise exc.BadRequest(_("The payload is not a valid json"))
|
||||
payload = self._get_payload(request)
|
||||
|
||||
event_hook_class = self.event_hook_classes.get(event_name, None)
|
||||
if event_hook_class is not None:
|
|
@ -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"
|
|
@ -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)
|
|
@ -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 |
|
@ -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
|
|
@ -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")
|
|
@ -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)
|
|
@ -23,22 +23,14 @@ 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 .exceptions import ActionSyntaxException
|
||||
from .services import get_github_user
|
||||
|
||||
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):
|
||||
def process_event(self):
|
||||
if self.payload is None:
|
|
@ -20,7 +20,7 @@ def create_github_system_user(apps, schema_editor):
|
|||
is_system=True,
|
||||
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.save()
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
@ -0,0 +1 @@
|
|||
# This file is needed to load migrations
|
|
@ -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')
|
|
@ -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)
|
|
@ -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 |
|
@ -0,0 +1 @@
|
|||
# This file is needed to load migrations
|
|
@ -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
|
|
@ -54,7 +54,6 @@ class MentionsPattern(Pattern):
|
|||
a = etree.Element('a')
|
||||
a.text = link_text
|
||||
a.set('href', url)
|
||||
a.set('alt', user.get_full_name())
|
||||
a.set('title', user.get_full_name())
|
||||
a.set('class', "mention")
|
||||
|
||||
|
|
|
@ -73,7 +73,6 @@ class TaigaReferencesPattern(Pattern):
|
|||
a = etree.Element('a')
|
||||
a.text = link_text
|
||||
a.set('href', url)
|
||||
a.set('alt', subject)
|
||||
a.set('title', subject)
|
||||
a.set('class', html_classes)
|
||||
|
||||
|
|
|
@ -15,69 +15,73 @@
|
|||
# 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 __future__ import absolute_import
|
||||
from __future__ import unicode_literals
|
||||
from markdown import Extension
|
||||
from markdown.inlinepatterns import Pattern
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
|
||||
from markdown.util import etree
|
||||
|
||||
from taiga.front import resolve
|
||||
|
||||
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):
|
||||
def __init__(self, configs):
|
||||
# set extension defaults
|
||||
self.config = {
|
||||
'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 __init__(self, project, *args, **kwargs):
|
||||
self.project = project
|
||||
return super().__init__(*args, **kwargs)
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
super().__init__(pattern)
|
||||
|
||||
def handleMatch(self, m):
|
||||
base_url, end_url, html_class = self._getMeta()
|
||||
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):
|
||||
title = m.group(3).strip()[1:]
|
||||
else:
|
||||
title = label
|
||||
|
||||
a = etree.Element('a')
|
||||
a = etree.Element("a")
|
||||
a.text = title
|
||||
a.set('href', url)
|
||||
if html_class:
|
||||
a.set('class', html_class)
|
||||
a.set("href", url)
|
||||
a.set("title", title)
|
||||
a.set("class", "reference wiki")
|
||||
return a
|
||||
|
||||
def _getMeta(self):
|
||||
""" Return meta data or config data. """
|
||||
base_url = self.config['base_url']
|
||||
end_url = self.config['end_url']
|
||||
html_class = self.config['html_class']
|
||||
return base_url, end_url, html_class
|
||||
|
||||
SLUG_RE = re.compile(r"^[-a-zA-Z0-9_]+$")
|
||||
|
||||
class RelativeLinksTreeprocessor(Treeprocessor):
|
||||
def __init__(self, md, project):
|
||||
self.project = project
|
||||
super().__init__(md)
|
||||
|
||||
def run(self, root):
|
||||
links = root.getiterator("a")
|
||||
for a in links:
|
||||
href = a.get("href", "")
|
||||
|
||||
if SLUG_RE.search(href):
|
||||
# [wiki](wiki_page) -> <a href="FRONT_HOST/.../wiki/wiki_page" ...
|
||||
url = resolve("wiki", self.project.slug, href)
|
||||
a.set("href", url)
|
||||
a.set("class", "reference wiki")
|
||||
|
||||
elif href and href[0] == "/":
|
||||
# [some link](/some/link) -> <a href="FRONT_HOST/some/link" ...
|
||||
url = "{}{}".format(resolve("home"), href[1:])
|
||||
a.set("href", url)
|
||||
|
|
|
@ -63,18 +63,19 @@ bleach.ALLOWED_ATTRIBUTES["img"] = ["alt", "src"]
|
|||
bleach.ALLOWED_ATTRIBUTES["*"] = ["class", "style"]
|
||||
|
||||
|
||||
def _make_extensions_list(wikilinks_config=None, project=None):
|
||||
def _make_extensions_list(project=None):
|
||||
return [AutolinkExtension(),
|
||||
AutomailExtension(),
|
||||
SemiSaneListExtension(),
|
||||
SpacedLinkExtension(),
|
||||
StrikethroughExtension(),
|
||||
WikiLinkExtension(wikilinks_config),
|
||||
WikiLinkExtension(project),
|
||||
EmojifyExtension(),
|
||||
MentionsExtension(),
|
||||
TaigaReferencesExtension(project),
|
||||
"extra",
|
||||
"codehilite",
|
||||
"sane_lists",
|
||||
"nl2br"]
|
||||
|
||||
|
||||
|
@ -103,11 +104,7 @@ def _get_markdown(project):
|
|||
def build_url(*args, **kwargs):
|
||||
return args[1] + slugify(args[0])
|
||||
|
||||
wikilinks_config = {"base_url": "/project/{}/wiki/".format(project.slug),
|
||||
"end_url": "",
|
||||
"build_url": build_url}
|
||||
extensions = _make_extensions_list(wikilinks_config=wikilinks_config,
|
||||
project=project)
|
||||
extensions = _make_extensions_list(project=project)
|
||||
md = Markdown(extensions=extensions)
|
||||
md.extracted_data = {"mentions": [], "references": []}
|
||||
return md
|
||||
|
|
|
@ -39,14 +39,15 @@ def _get_object_project(obj):
|
|||
|
||||
|
||||
def is_project_owner(user, obj):
|
||||
"""
|
||||
The owner attribute of a project is just an historical reference
|
||||
"""
|
||||
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
project = _get_object_project(obj)
|
||||
|
||||
if project and project.owner == user:
|
||||
return True
|
||||
|
||||
membership = _get_user_project_membership(user, project)
|
||||
if membership and membership.is_owner:
|
||||
return True
|
||||
|
@ -80,17 +81,14 @@ def get_user_project_permissions(user, project):
|
|||
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
|
||||
public_permissions = list(map(lambda perm: perm[0], USER_PERMISSIONS))
|
||||
anon_permissions = list(map(lambda perm: perm[0], ANON_PERMISSIONS))
|
||||
elif 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:
|
||||
if membership.is_owner:
|
||||
owner_permissions = list(map(lambda perm: perm[0], OWNERS_PERMISSIONS))
|
||||
members_permissions = list(map(lambda perm: perm[0], MEMBERS_PERMISSIONS))
|
||||
else:
|
||||
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 []
|
||||
anon_permissions = project.anon_permissions if project.anon_permissions is not None else []
|
||||
elif user.is_authenticated():
|
||||
|
|
|
@ -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"
|
|
@ -80,6 +80,12 @@ class ProjectViewSet(ModelCrudViewSet):
|
|||
self.check_permissions(request, 'stats', 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'])
|
||||
def issues_stats(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
|
@ -148,6 +154,13 @@ class ProjectViewSet(ModelCrudViewSet):
|
|||
template.save()
|
||||
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):
|
||||
if not obj.id:
|
||||
obj.owner = self.request.user
|
||||
|
@ -228,6 +241,10 @@ class MembershipViewSet(ModelCrudViewSet):
|
|||
services.send_invitation(invitation=invitation)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def pre_delete(self, obj):
|
||||
if obj.user is not None and not services.can_user_leave_project(obj.user, obj.project):
|
||||
raise exc.BadRequest(_("At least one of the user must be an active admin"))
|
||||
|
||||
def pre_save(self, obj):
|
||||
if not obj.token:
|
||||
obj.token = str(uuid.uuid1())
|
||||
|
|
|
@ -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')
|
|
@ -3,10 +3,10 @@
|
|||
"model": "projects.projecttemplate",
|
||||
"fields": {
|
||||
"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,
|
||||
"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,
|
||||
"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\"}]",
|
||||
|
@ -17,7 +17,7 @@
|
|||
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
|
||||
"slug": "scrum",
|
||||
"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",
|
||||
"issue_types": "[{\"color\": \"#89BAB4\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#ba89a8\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#89a8ba\", \"order\": 3, \"name\": \"Enhancement\"}]",
|
||||
"videoconferences": null,
|
||||
|
@ -30,10 +30,10 @@
|
|||
"model": "projects.projecttemplate",
|
||||
"fields": {
|
||||
"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,
|
||||
"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,
|
||||
"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}]",
|
||||
|
@ -44,7 +44,7 @@
|
|||
"default_options": "{\"us_status\": \"New\", \"task_status\": \"New\", \"priority\": \"Normal\", \"issue_type\": \"Bug\", \"severity\": \"Normal\", \"points\": \"?\", \"issue_status\": \"New\"}",
|
||||
"slug": "kanban",
|
||||
"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",
|
||||
"issue_types": "[{\"color\": \"#cc0000\", \"order\": 1, \"name\": \"Bug\"}, {\"color\": \"#729fcf\", \"order\": 2, \"name\": \"Question\"}, {\"color\": \"#4e9a06\", \"order\": 3, \"name\": \"Enhancement\"}]",
|
||||
"videoconferences": null,
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -48,7 +48,7 @@ class HistoryEntry(models.Model):
|
|||
user = JsonField(blank=True, default=None, null=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
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
|
||||
diff = JsonField(null=True, default=None)
|
||||
|
|
|
@ -18,35 +18,46 @@ from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm,
|
|||
IsProjectOwner, AllowAny,
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
class IsCommentOwner(PermissionComponent):
|
||||
def check_permissions(self, request, view, obj=None):
|
||||
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):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
delete_comment_perms = IsProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter()
|
||||
delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
|
||||
|
||||
|
||||
class TaskHistoryPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
delete_comment_perms = IsProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter()
|
||||
delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
|
||||
|
||||
|
||||
class IssueHistoryPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
delete_comment_perms = IsProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter()
|
||||
delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
|
||||
|
||||
|
||||
class WikiHistoryPermission(TaigaResourcePermission):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
delete_comment_perms = IsProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsProjectOwner() | IsCommentDeleter()
|
||||
delete_comment_perms = IsCommentProjectOwner() | IsCommentOwner()
|
||||
undelete_comment_perms = IsCommentProjectOwner() | IsCommentDeleter()
|
||||
|
|
|
@ -81,6 +81,14 @@ def get_model_from_key(key:str) -> object:
|
|||
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):
|
||||
"""
|
||||
Register values implementation for specified typename.
|
||||
|
@ -243,6 +251,24 @@ def get_last_snapshot_for_key(key:str) -> FrozenObj:
|
|||
|
||||
# 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
|
||||
def take_snapshot(obj:object, *, comment:str="", user=None, delete:bool=False):
|
||||
"""
|
||||
|
|
|
@ -2,7 +2,11 @@
|
|||
"description",
|
||||
"description_html",
|
||||
"content",
|
||||
"content_html"
|
||||
"content_html",
|
||||
"backlog_order",
|
||||
"kanban_order",
|
||||
"taskboard_order",
|
||||
"us_order"
|
||||
] %}
|
||||
|
||||
<dl>
|
||||
|
|
|
@ -2,7 +2,11 @@
|
|||
"description_diff",
|
||||
"description_html",
|
||||
"content_diff",
|
||||
"content_html"
|
||||
"content_html",
|
||||
"backlog_order",
|
||||
"kanban_order",
|
||||
"taskboard_order",
|
||||
"us_order"
|
||||
] %}
|
||||
{% for field_name, values in changed_fields.items() %}
|
||||
{% if field_name not in excluded_fields %}
|
||||
|
|
|
@ -79,7 +79,7 @@ class IssuesFilter(filters.FilterBackend):
|
|||
filterdata = self._prepare_filters_data(request)
|
||||
|
||||
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()):
|
||||
if None in value:
|
||||
|
|
|
@ -223,6 +223,13 @@ class Command(BaseCommand):
|
|||
comment=self.sd.paragraph(),
|
||||
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
|
||||
|
||||
def create_bug(self, project):
|
||||
|
@ -253,6 +260,13 @@ class Command(BaseCommand):
|
|||
comment=self.sd.paragraph(),
|
||||
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
|
||||
|
||||
def create_task(self, project, milestone, us, min_date, max_date, closed=False):
|
||||
|
@ -284,6 +298,13 @@ class Command(BaseCommand):
|
|||
comment=self.sd.paragraph(),
|
||||
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
|
||||
|
||||
def create_us(self, project, milestone=None, computable_project_roles=[]):
|
||||
|
@ -318,6 +339,13 @@ class Command(BaseCommand):
|
|||
comment=self.sd.paragraph(),
|
||||
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
|
||||
|
||||
def create_milestone(self, project, start_date, end_date):
|
||||
|
|
|
@ -31,12 +31,10 @@ from djorm_pgarray.fields import TextArrayField
|
|||
from taiga.permissions.permissions import ANON_PERMISSIONS, USER_PERMISSIONS
|
||||
|
||||
from taiga.base.tags import TaggedMixin
|
||||
from taiga.users.models import Role
|
||||
from taiga.base.utils.slug import slugify_uniquely
|
||||
from taiga.base.utils.dicts import dict_sum
|
||||
from taiga.base.utils.sequence import arithmetic_progression
|
||||
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
|
||||
|
||||
|
@ -598,6 +596,7 @@ class ProjectTemplate(models.Model):
|
|||
for us_status in project.us_statuses.all():
|
||||
self.us_statuses.append({
|
||||
"name": us_status.name,
|
||||
"slug": us_status.slug,
|
||||
"is_closed": us_status.is_closed,
|
||||
"color": us_status.color,
|
||||
"wip_limit": us_status.wip_limit,
|
||||
|
@ -616,6 +615,7 @@ class ProjectTemplate(models.Model):
|
|||
for task_status in project.task_statuses.all():
|
||||
self.task_statuses.append({
|
||||
"name": task_status.name,
|
||||
"slug": task_status.slug,
|
||||
"is_closed": task_status.is_closed,
|
||||
"color": task_status.color,
|
||||
"order": task_status.order,
|
||||
|
@ -625,6 +625,7 @@ class ProjectTemplate(models.Model):
|
|||
for issue_status in project.issue_statuses.all():
|
||||
self.issue_statuses.append({
|
||||
"name": issue_status.name,
|
||||
"slug": issue_status.slug,
|
||||
"is_closed": issue_status.is_closed,
|
||||
"color": issue_status.color,
|
||||
"order": issue_status.order,
|
||||
|
@ -671,6 +672,8 @@ class ProjectTemplate(models.Model):
|
|||
self.default_owner_role = self.roles[0].get("slug", None)
|
||||
|
||||
def apply_to_project(self, project):
|
||||
Role = apps.get_model("users", "Role")
|
||||
|
||||
if project.id is None:
|
||||
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:
|
||||
UserStoryStatus.objects.create(
|
||||
name=us_status["name"],
|
||||
slug=us_status["slug"],
|
||||
is_closed=us_status["is_closed"],
|
||||
color=us_status["color"],
|
||||
wip_limit=us_status["wip_limit"],
|
||||
|
@ -703,6 +707,7 @@ class ProjectTemplate(models.Model):
|
|||
for task_status in self.task_statuses:
|
||||
TaskStatus.objects.create(
|
||||
name=task_status["name"],
|
||||
slug=task_status["slug"],
|
||||
is_closed=task_status["is_closed"],
|
||||
color=task_status["color"],
|
||||
order=task_status["order"],
|
||||
|
@ -712,6 +717,7 @@ class ProjectTemplate(models.Model):
|
|||
for issue_status in self.issue_statuses:
|
||||
IssueStatus.objects.create(
|
||||
name=issue_status["name"],
|
||||
slug=issue_status["slug"],
|
||||
is_closed=issue_status["is_closed"],
|
||||
color=issue_status["color"],
|
||||
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)
|
||||
|
||||
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)
|
||||
|
|
|
@ -32,6 +32,7 @@ from taiga.projects.history.choices import HistoryType
|
|||
from taiga.projects.history.services import (make_key_from_model_object,
|
||||
get_last_snapshot_for_key,
|
||||
get_model_from_key)
|
||||
from taiga.permissions.service import user_has_perm
|
||||
from taiga.users.models import User
|
||||
|
||||
from .models import HistoryChangeNotification
|
||||
|
@ -121,6 +122,23 @@ def analize_object_for_watchers(obj:object, history:object):
|
|||
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:
|
||||
"""
|
||||
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:
|
||||
candidates = candidates - set(discard_users)
|
||||
|
||||
candidates = filter(partial(_filter_by_permissions, obj), candidates)
|
||||
|
||||
return frozenset(candidates)
|
||||
|
||||
|
||||
|
@ -187,6 +207,9 @@ def _make_template_mail(name:str):
|
|||
|
||||
@transaction.atomic
|
||||
def send_notifications(obj, *, history):
|
||||
if history.is_hidden:
|
||||
return None
|
||||
|
||||
key = make_key_from_model_object(obj)
|
||||
owner = User.objects.get(pk=history.user["pk"])
|
||||
notification, created = (HistoryChangeNotification.objects.select_for_update()
|
||||
|
|
|
@ -19,23 +19,62 @@ from django.utils.translation import ugettext_lazy as _
|
|||
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base.utils import db
|
||||
|
||||
from taiga.projects.history.services import get_modified_fields
|
||||
|
||||
class OCCResourceMixin(object):
|
||||
"""
|
||||
Rest Framework resource mixin for resources that need to have concurrent
|
||||
accesses and editions controlled.
|
||||
"""
|
||||
def pre_save(self, obj):
|
||||
current_version = obj.version
|
||||
def _extract_param_version(self):
|
||||
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"})
|
||||
|
||||
if obj.id:
|
||||
obj.version = models.F('version') + 1
|
||||
|
||||
def pre_save(self, obj):
|
||||
self._validate_and_update_version(obj)
|
||||
super().pre_save(obj)
|
||||
|
||||
def post_save(self, obj, created=False):
|
||||
|
|
|
@ -13,20 +13,41 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from taiga.base.api.permissions import (TaigaResourcePermission, HasProjectPerm,
|
||||
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):
|
||||
retrieve_perms = HasProjectPerm('view_project')
|
||||
create_perms = IsAuthenticated()
|
||||
update_perms = IsProjectOwner()
|
||||
partial_update_perms = IsProjectOwner()
|
||||
destroy_perms = IsProjectOwner()
|
||||
modules_perms = IsProjectOwner()
|
||||
list_perms = AllowAny()
|
||||
stats_perms = AllowAny()
|
||||
member_stats_perms = HasProjectPerm('view_project')
|
||||
star_perms = IsAuthenticated()
|
||||
unstar_perms = IsAuthenticated()
|
||||
issues_stats_perms = AllowAny()
|
||||
|
@ -35,6 +56,7 @@ class ProjectPermission(TaigaResourcePermission):
|
|||
tags_colors_perms = HasProjectPerm('view_project')
|
||||
fans_perms = HasProjectPerm('view_project')
|
||||
create_template_perms = IsSuperUser()
|
||||
leave_perms = CanLeaveProject()
|
||||
|
||||
|
||||
class MembershipPermission(TaigaResourcePermission):
|
||||
|
|
|
@ -30,6 +30,7 @@ from taiga.users.validators import RoleExistsValidator
|
|||
from taiga.permissions.service import get_user_project_permissions, is_project_owner
|
||||
|
||||
from . import models
|
||||
from . import services
|
||||
from . validators import ProjectExistsValidator
|
||||
|
||||
|
||||
|
@ -202,6 +203,17 @@ class MembershipSerializer(ModelSerializer):
|
|||
|
||||
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):
|
||||
role_name = serializers.CharField(source='role.name', required=False)
|
||||
|
@ -310,6 +322,7 @@ class ProjectTemplateSerializer(ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.ProjectTemplate
|
||||
read_only_fields = ("created_date", "modified_date")
|
||||
|
||||
|
||||
class StarredSerializer(ModelSerializer):
|
||||
|
|
|
@ -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
|
||||
from .stats import get_member_stats_for_project
|
||||
|
||||
from .members import create_members_in_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 find_invited_user
|
||||
|
|
|
@ -2,7 +2,6 @@ from taiga.base.utils import db, text
|
|||
|
||||
from .. import models
|
||||
|
||||
|
||||
def get_members_from_bulk(bulk_data, **additional_fields):
|
||||
"""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)
|
||||
db.save_in_bulk(members, callback, precall)
|
||||
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
|
||||
|
|
|
@ -18,6 +18,8 @@ from django.db.models import Q, Count
|
|||
import datetime
|
||||
import copy
|
||||
|
||||
from taiga.projects.history.models import HistoryEntry
|
||||
|
||||
|
||||
def _get_milestones_stats_for_backlog(project):
|
||||
"""
|
||||
|
@ -212,3 +214,77 @@ def get_stats_for_project(project):
|
|||
'speed': speed,
|
||||
}
|
||||
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
|
||||
|
|
|
@ -14,7 +14,11 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.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.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):
|
||||
remove_unused_tags(instance.project)
|
||||
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)
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from taiga.base import filters, response
|
||||
from taiga.base import exceptions as exc
|
||||
|
@ -79,3 +80,27 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
|
|||
return response.Ok(tasks_serialized.data)
|
||||
|
||||
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)
|
||||
|
|
|
@ -27,3 +27,4 @@ class TaskPermission(TaigaResourcePermission):
|
|||
destroy_perms = HasProjectPerm('delete_task')
|
||||
list_perms = AllowAny()
|
||||
bulk_create_perms = HasProjectPerm('add_task')
|
||||
bulk_update_order_perms = HasProjectPerm('modify_task')
|
||||
|
|
|
@ -18,9 +18,9 @@ from rest_framework import serializers
|
|||
|
||||
from taiga.base.serializers import Serializer, PickleField, NeighborsSerializerMixin, PgArrayField
|
||||
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.userstories.validators import UserStoryExistsValidator
|
||||
from taiga.projects.tasks.validators import TaskExistsValidator
|
||||
from taiga.projects.notifications.validators import WatchersValidator
|
||||
|
||||
from . import models
|
||||
|
@ -70,10 +70,21 @@ class NeighborTaskSerializer(serializers.ModelSerializer):
|
|||
depth = 0
|
||||
|
||||
|
||||
class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator, TaskStatusExistsValidator,
|
||||
UserStoryExistsValidator, Serializer):
|
||||
class TasksBulkSerializer(ProjectExistsValidator, SprintExistsValidator,
|
||||
TaskExistsValidator, Serializer):
|
||||
project_id = serializers.IntegerField()
|
||||
sprint_id = serializers.IntegerField()
|
||||
status_id = serializers.IntegerField(required=False)
|
||||
us_id = serializers.IntegerField(required=False)
|
||||
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)
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from taiga.base.utils import db, text
|
||||
from taiga.projects.history.services import take_snapshot
|
||||
from taiga.events import events
|
||||
|
||||
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)
|
||||
db.save_in_bulk(tasks, callback, precall)
|
||||
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
|
||||
|
|
|
@ -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
|
|
@ -110,8 +110,7 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
|
|||
return response.Ok(user_stories_serialized.data)
|
||||
return response.BadRequest(serializer.errors)
|
||||
|
||||
@list_route(methods=["POST"])
|
||||
def bulk_update_backlog_order(self, request, **kwargs):
|
||||
def _bulk_update_order(self, order_field, request, **kwargs):
|
||||
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA)
|
||||
if not serializer.is_valid():
|
||||
return response.BadRequest(serializer.errors)
|
||||
|
@ -122,42 +121,22 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
|
|||
self.check_permissions(request, "bulk_update_order", project)
|
||||
services.update_userstories_order_in_bulk(data["bulk_stories"],
|
||||
project=project,
|
||||
field="backlog_order")
|
||||
field=order_field)
|
||||
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
|
||||
|
||||
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"])
|
||||
def bulk_update_sprint_order(self, request, **kwargs):
|
||||
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(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_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()
|
||||
return self._bulk_update_order("sprint_order", request, **kwargs)
|
||||
|
||||
@list_route(methods=["POST"])
|
||||
def bulk_update_kanban_order(self, request, **kwargs):
|
||||
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(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_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()
|
||||
return self._bulk_update_order("kanban_order", request, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, *args, **kwargs):
|
||||
|
|
|
@ -132,8 +132,16 @@ from taiga.projects.notifications.api import NotifyPolicyViewSet
|
|||
router.register(r"notify-policies", NotifyPolicyViewSet, base_name="notifications")
|
||||
|
||||
# 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")
|
||||
|
||||
# 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
|
||||
# - see taiga.feedback.routers and taiga.feedback.apps
|
||||
|
|
|
@ -40,6 +40,7 @@ from taiga.base.api import ModelCrudViewSet
|
|||
from taiga.base.utils.slug import slugify_uniquely
|
||||
from taiga.projects.votes import services as votes_service
|
||||
from taiga.projects.serializers import StarredSerializer
|
||||
from taiga.permissions.service import is_project_owner
|
||||
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
@ -53,8 +54,8 @@ class MembersFilterBackend(BaseFilterBackend):
|
|||
if project_id:
|
||||
Project = apps.get_model('projects', 'Project')
|
||||
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):
|
||||
return queryset.filter(Q(memberships__project=project) | Q(id=project.owner.id)).distinct()
|
||||
if request.user.is_authenticated() and project.memberships.filter(user=request.user).exists():
|
||||
return queryset.filter(memberships__project=project).distinct()
|
||||
else:
|
||||
raise exc.PermissionDenied(_("You don't have permisions to see this project users."))
|
||||
|
||||
|
|
|
@ -400,6 +400,13 @@ class AttachmentFactory(Factory):
|
|||
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):
|
||||
"Create an issue and along with its dependencies."
|
||||
owner = kwargs.pop("owner", None)
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
import mock
|
||||
from unittest import mock
|
||||
import functools
|
||||
|
||||
|
||||
|
|
|
@ -69,6 +69,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
@ -56,10 +56,23 @@ def data():
|
|||
user=m.project_member_with_perms,
|
||||
role__project=m.private_project2,
|
||||
role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
|
||||
|
||||
f.MembershipFactory(project=m.private_project2,
|
||||
user=m.project_member_without_perms,
|
||||
role__project=m.private_project2,
|
||||
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
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
@ -7,6 +7,9 @@ from taiga.base.utils import json
|
|||
from tests import factories as f
|
||||
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
|
||||
from taiga.projects.votes.services import add_vote
|
||||
from taiga.projects.occ import OCCResourceMixin
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
@ -64,6 +67,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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,
|
||||
status__project=m.public_project,
|
||||
severity__project=m.public_project,
|
||||
|
@ -120,6 +135,7 @@ def test_issue_update(client, data):
|
|||
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["subject"] = "test"
|
||||
issue_data = json.dumps(issue_data)
|
||||
|
@ -150,6 +166,7 @@ def test_issue_delete(client, data):
|
|||
data.project_member_without_perms,
|
||||
data.project_member_with_perms,
|
||||
]
|
||||
|
||||
results = helper_test_http_method(client, 'delete', public_url, None, users)
|
||||
assert results == [401, 403, 403, 204]
|
||||
results = helper_test_http_method(client, 'delete', private_url1, None, users)
|
||||
|
@ -249,6 +266,7 @@ def test_issue_patch(client, data):
|
|||
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})
|
||||
results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
|
||||
assert results == [401, 403, 403, 200, 200]
|
||||
|
@ -291,7 +309,6 @@ def test_issue_bulk_create(client, data):
|
|||
data.project_owner
|
||||
]
|
||||
|
||||
|
||||
bulk_data = json.dumps({"bulk_issues": "test1\ntest2",
|
||||
"project_id": data.public_issue.project.pk})
|
||||
results = helper_test_http_method(client, 'post', url, bulk_data, users)
|
||||
|
|
|
@ -64,6 +64,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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.private_milestone1 = f.MilestoneFactory(project=m.private_project1)
|
||||
m.private_milestone2 = f.MilestoneFactory(project=m.private_project2)
|
||||
|
|
|
@ -44,6 +44,7 @@ def data():
|
|||
email=m.project_member_with_perms.email,
|
||||
role__project=m.private_project1,
|
||||
role__permissions=list(map(lambda x: x[0], MEMBERS_PERMISSIONS)))
|
||||
|
||||
f.MembershipFactory(project=m.private_project1,
|
||||
user=m.project_member_without_perms,
|
||||
email=m.project_member_without_perms.email,
|
||||
|
@ -60,6 +61,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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.private_points1 = f.PointsFactory(project=m.private_project1)
|
||||
m.private_points2 = f.PointsFactory(project=m.private_project2)
|
||||
|
@ -1427,31 +1440,31 @@ def test_membership_list(client, data):
|
|||
|
||||
response = client.get(url)
|
||||
projects_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(projects_data) == 3
|
||||
assert len(projects_data) == 5
|
||||
assert response.status_code == 200
|
||||
|
||||
client.login(data.registered_user)
|
||||
response = client.get(url)
|
||||
projects_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(projects_data) == 3
|
||||
assert len(projects_data) == 5
|
||||
assert response.status_code == 200
|
||||
|
||||
client.login(data.project_member_without_perms)
|
||||
response = client.get(url)
|
||||
projects_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(projects_data) == 3
|
||||
assert len(projects_data) == 5
|
||||
assert response.status_code == 200
|
||||
|
||||
client.login(data.project_member_with_perms)
|
||||
response = client.get(url)
|
||||
projects_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(projects_data) == 5
|
||||
assert len(projects_data) == 8
|
||||
assert response.status_code == 200
|
||||
|
||||
client.login(data.project_owner)
|
||||
response = client.get(url)
|
||||
projects_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(projects_data) == 5
|
||||
assert len(projects_data) == 8
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
|
|
@ -53,6 +53,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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")
|
||||
Project = apps.get_model("projects", "Project")
|
||||
|
||||
|
|
|
@ -65,6 +65,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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,
|
||||
user=m.other_user,
|
||||
role__project=m.private_project2,
|
||||
|
|
|
@ -62,6 +62,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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,
|
||||
status__project=m.public_project,
|
||||
severity__project=m.public_project,
|
||||
|
|
|
@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse
|
|||
from taiga.base.utils import json
|
||||
from taiga.projects.tasks.serializers import TaskSerializer
|
||||
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.utils import helper_test_http_method, disconnect_signals, reconnect_signals
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -63,6 +66,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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,
|
||||
status__project=m.public_project,
|
||||
milestone__project=m.public_project,
|
||||
|
@ -120,6 +135,7 @@ def test_task_update(client, data):
|
|||
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["subject"] = "test"
|
||||
task_data = json.dumps(task_data)
|
||||
|
@ -240,6 +256,7 @@ def test_task_patch(client, data):
|
|||
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})
|
||||
results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
|
||||
assert results == [401, 403, 403, 200, 200]
|
||||
|
|
|
@ -61,6 +61,17 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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
|
||||
|
||||
|
||||
|
|
|
@ -103,7 +103,7 @@ def test_user_list(client, data):
|
|||
|
||||
response = client.get(url)
|
||||
users_data = json.loads(response.content.decode('utf-8'))
|
||||
assert len(users_data) == 4
|
||||
assert len(users_data) == 6
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
|
|
|
@ -3,10 +3,13 @@ from django.core.urlresolvers import reverse
|
|||
from taiga.base.utils import json
|
||||
from taiga.projects.userstories.serializers import UserStorySerializer
|
||||
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.utils import helper_test_http_method, disconnect_signals, reconnect_signals
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -63,6 +66,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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.private_points1 = f.PointsFactory(project=m.private_project1)
|
||||
m.private_points2 = f.PointsFactory(project=m.private_project2)
|
||||
|
@ -118,6 +133,7 @@ def test_user_story_update(client, data):
|
|||
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["subject"] = "test"
|
||||
user_story_data = json.dumps(user_story_data)
|
||||
|
@ -223,6 +239,7 @@ def test_user_story_patch(client, data):
|
|||
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})
|
||||
results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
|
||||
assert results == [401, 403, 403, 200, 200]
|
||||
|
|
|
@ -4,10 +4,13 @@ from taiga.base.utils import json
|
|||
from taiga.projects.wiki.serializers import WikiPageSerializer, WikiLinkSerializer
|
||||
from taiga.projects.wiki.models import WikiPage, WikiLink
|
||||
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.utils import helper_test_http_method, disconnect_signals, reconnect_signals
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -64,6 +67,18 @@ def data():
|
|||
role__project=m.private_project2,
|
||||
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.private_wiki_page1 = f.WikiPageFactory(project=m.private_project1)
|
||||
m.private_wiki_page2 = f.WikiPageFactory(project=m.private_project2)
|
||||
|
@ -109,6 +124,7 @@ def test_wiki_page_update(client, data):
|
|||
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["content"] = "test"
|
||||
wiki_page_data = json.dumps(wiki_page_data)
|
||||
|
@ -226,6 +242,7 @@ def test_wiki_page_patch(client, data):
|
|||
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})
|
||||
results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
|
||||
assert results == [401, 200, 200, 200, 200]
|
||||
|
@ -288,6 +305,7 @@ def test_wiki_link_update(client, data):
|
|||
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["title"] = "test"
|
||||
wiki_link_data = json.dumps(wiki_link_data)
|
||||
|
@ -405,6 +423,7 @@ def test_wiki_link_patch(client, data):
|
|||
data.project_owner
|
||||
]
|
||||
|
||||
with mock.patch.object(OCCResourceMixin, "_validate_and_update_version") as _validate_and_update_version_mock:
|
||||
patch_data = json.dumps({"title": "test"})
|
||||
results = helper_test_http_method(client, 'patch', public_url, patch_data, users)
|
||||
assert results == [401, 200, 200, 200, 200]
|
||||
|
|
|
@ -15,6 +15,7 @@ def test_create_user_story_attachment_without_file(client):
|
|||
Bug test "Don't create attachments without attached_file"
|
||||
"""
|
||||
us = f.UserStoryFactory.create()
|
||||
membership = f.MembershipFactory(project=us.project, user=us.owner, is_owner=True)
|
||||
attachment_data = {
|
||||
"description": "test",
|
||||
"attached_file": None,
|
||||
|
@ -31,6 +32,7 @@ def test_create_user_story_attachment_without_file(client):
|
|||
def test_create_attachment_on_wrong_project(client):
|
||||
issue1 = f.create_issue()
|
||||
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.project.owner == issue2.project.owner
|
||||
|
|
|
@ -26,7 +26,7 @@ from .. import factories as f
|
|||
from taiga.projects.history import services
|
||||
from taiga.projects.history.models import HistoryEntry
|
||||
from taiga.projects.history.choices import HistoryType
|
||||
|
||||
from taiga.projects.history.services import make_key_from_model_object
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -143,7 +143,7 @@ def test_issue_resource_history_test(client):
|
|||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
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)
|
||||
|
||||
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):
|
||||
project = f.create_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_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])
|
||||
data = json.dumps({"comment": "test comment", "version": us.version})
|
||||
|
||||
print(url, data)
|
||||
client.login(project.owner)
|
||||
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_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
|
||||
|
|
|
@ -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"
|
|
@ -6,9 +6,9 @@ from unittest import mock
|
|||
from django.core.urlresolvers import reverse
|
||||
from django.core import mail
|
||||
|
||||
from taiga.github_hook.api import GitHubViewSet
|
||||
from taiga.github_hook import event_hooks
|
||||
from taiga.github_hook.exceptions import ActionSyntaxException
|
||||
from taiga.hooks.github import event_hooks
|
||||
from taiga.hooks.github.api import GitHubViewSet
|
||||
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
|
||||
|
@ -75,8 +75,10 @@ def test_push_event_detected(client):
|
|||
|
||||
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)
|
||||
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
|
||||
|
@ -93,8 +95,10 @@ def test_push_event_issue_processing(client):
|
|||
|
||||
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)
|
||||
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
|
||||
|
@ -111,8 +115,10 @@ def test_push_event_task_processing(client):
|
|||
|
||||
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)
|
||||
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
|
||||
|
@ -130,8 +136,10 @@ def test_push_event_user_story_processing(client):
|
|||
|
||||
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)
|
||||
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
|
||||
|
@ -291,12 +299,17 @@ def test_issues_event_bad_issue(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"])
|
||||
take_snapshot(issue, user=issue.owner)
|
||||
task = f.TaskFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"])
|
||||
take_snapshot(task, user=task.owner)
|
||||
us = f.UserStoryFactory.create(project=issue.project, external_reference=["github", "http://github.com/test/project/issues/11"])
|
||||
take_snapshot(us, user=us.owner)
|
||||
project = f.ProjectFactory()
|
||||
role = f.RoleFactory(project=project, permissions=["view_tasks", "view_issues", "view_us"])
|
||||
membership = f.MembershipFactory(project=project, role=role, user=project.owner)
|
||||
user = f.UserFactory()
|
||||
|
||||
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 = {
|
||||
"action": "created",
|
||||
|
@ -399,6 +412,7 @@ def test_issues_event_bad_comment(client):
|
|||
|
||||
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,))
|
||||
|
||||
|
@ -413,6 +427,7 @@ def test_api_get_project_modules(client):
|
|||
|
||||
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,))
|
||||
|
|
@ -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"
|
|
@ -168,6 +168,7 @@ def test_invalid_project_import_with_extra_data(client):
|
|||
def test_invalid_issue_import(client):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
url = reverse("importer-issue", args=[project.pk])
|
||||
|
@ -179,6 +180,7 @@ def test_invalid_issue_import(client):
|
|||
def test_valid_user_story_import(client):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
client.login(user)
|
||||
|
@ -199,6 +201,7 @@ def test_valid_user_story_import(client):
|
|||
def test_valid_issue_import_without_extra_data(client):
|
||||
user = f.UserFactory.create()
|
||||
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_status = f.IssueStatusFactory.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):
|
||||
user = f.UserFactory.create()
|
||||
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_status = f.IssueStatusFactory.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):
|
||||
user = f.UserFactory.create()
|
||||
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_status = f.IssueStatusFactory.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):
|
||||
user = f.UserFactory.create()
|
||||
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_status = f.IssueStatusFactory.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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
client.login(user)
|
||||
|
@ -428,6 +439,7 @@ def test_invalid_us_import_with_bad_choices(client):
|
|||
def test_invalid_task_import(client):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
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.save()
|
||||
client.login(user)
|
||||
|
@ -523,6 +539,7 @@ def test_invalid_task_import_with_bad_choices(client):
|
|||
def test_valid_task_with_user_story(client):
|
||||
user = f.UserFactory.create()
|
||||
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)
|
||||
us = f.UserStoryFactory.create(project=project)
|
||||
project.save()
|
||||
|
@ -542,6 +559,7 @@ def test_valid_task_with_user_story(client):
|
|||
def test_invalid_wiki_page_import(client):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
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):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
url = reverse("importer-milestone", args=[project.pk])
|
||||
|
@ -647,6 +671,7 @@ def test_invalid_milestone_import(client):
|
|||
def test_valid_milestone_import(client):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
url = reverse("importer-milestone", args=[project.pk])
|
||||
|
@ -663,6 +688,7 @@ def test_valid_milestone_import(client):
|
|||
def test_milestone_import_duplicated_milestone(client):
|
||||
user = f.UserFactory.create()
|
||||
project = f.ProjectFactory.create(owner=user)
|
||||
membership = f.MembershipFactory(project=project, user=user, is_owner=True)
|
||||
client.login(user)
|
||||
|
||||
url = reverse("importer-milestone", args=[project.pk])
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue