Merge pull request #32 from taigaio/domains-alias

Domains alias
remotes/origin/enhancement/email-actions
David Barragán Merino 2014-03-22 19:36:28 +01:00
commit 686e79ccb5
28 changed files with 408 additions and 92 deletions

View File

@ -116,7 +116,7 @@ TEMPLATE_LOADERS = [
MIDDLEWARE_CLASSES = [
"taiga.base.middleware.cors.CoorsMiddleware",
"taiga.base.domains.middleware.DomainsMiddleware",
"taiga.domains.middleware.DomainsMiddleware",
# Common middlewares
"django.middleware.common.CommonMiddleware",
@ -154,10 +154,10 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"taiga.base.users",
"taiga.base.domains",
"taiga.base.notifications",
"taiga.base.searches",
"taiga.base",
"taiga.domains",
"taiga.projects",
"taiga.projects.mixins.blocked",
"taiga.projects.milestones",

View File

@ -11,8 +11,8 @@ from rest_framework.permissions import AllowAny
from rest_framework import status, viewsets
from taiga.base.decorators import list_route
from taiga.base.domains.models import DomainMember
from taiga.base.domains import get_active_domain
from taiga.domains.models import DomainMember
from taiga.domains import get_active_domain
from taiga.base.users.models import User, Role
from taiga.base.users.serializers import UserSerializer
from taiga.base import exceptions as exc

View File

@ -18,7 +18,7 @@ from taiga.base import auth
from taiga.base.users.tests import create_user, create_domain
from taiga.projects.tests import create_project
from taiga.base.domains.models import Domain, DomainMember
from taiga.domains.models import Domain, DomainMember
from taiga.projects.models import Membership

View File

@ -1,13 +0,0 @@
from django.contrib import admin
from .models import Domain, DomainMember
class DomainMemberInline(admin.TabularInline):
model = DomainMember
class DomainAdmin(admin.ModelAdmin):
list_display = ('domain', 'name')
search_fields = ('domain', 'name')
inlines = [ DomainMemberInline, ]
admin.site.register(Domain, DomainAdmin)

View File

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
import json
from django import http
from taiga.base import domains
from taiga.base.exceptions import format_exception
class DomainsMiddleware(object):
def process_request(self, request):
domain = request.META.get("HTTP_X_HOST", None)
if domain is not None:
try:
domain = domains.get_domain_for_domain_name(domain)
except domains.DomainNotFound as e:
detail = format_exception(e)
return http.HttpResponseBadRequest(json.dumps(detail))
else:
domain = domains.get_default_domain()
request.domain = domain
domains.activate(domain)
def process_response(self, request, response):
domains.deactivate()
if hasattr(request, "domain"):
response["X-Site-Host"] = request.domain.domain
return response

27
taiga/domains/__init__.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .base import get_default_domain
from .base import get_domain_for_domain_name
from .base import activate
from .base import deactivate
from .base import get_active_domain
from .base import DomainNotFound
__all__ = ["get_default_domain",
"get_domain_for_domain_name",
"activate",
"deactivate",
"get_active_domain",
"DomainNotFound"]

27
taiga/domains/admin.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django.contrib import admin
from .models import Domain, DomainMember
class DomainMemberInline(admin.TabularInline):
model = DomainMember
class DomainAdmin(admin.ModelAdmin):
list_display = ('domain', 'name')
search_fields = ('domain', 'name')
inlines = [ DomainMemberInline, ]
admin.site.register(Domain, DomainAdmin)

View File

@ -1,4 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from rest_framework import viewsets
from rest_framework.response import Response
@ -7,8 +19,8 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from django.http import Http404
from taiga.base.api import ModelCrudViewSet, UpdateModelMixin
from taiga.base.domains import get_active_domain
from .base import get_active_domain
from .serializers import DomainSerializer, DomainMemberSerializer
from .permissions import DomainMembersPermission, DomainPermission
from .models import DomainMember, Domain

View File

@ -1,49 +1,42 @@
# -*- coding: utf-8 -*-
import logging
from threading import local
import functools
import threading
from django.db.models import get_model
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import ugettext_lazy as _
from .. import exceptions as exc
_local = local()
from taiga.base import exceptions as exc
log = logging.getLogger("taiga.domains")
_local = threading.local()
class DomainNotFound(exc.BaseException):
pass
@functools.lru_cache(maxsize=1)
def get_default_domain():
from django.conf import settings
try:
sid = settings.SITE_ID
sid = settings.DOMAIN_ID
except AttributeError:
raise ImproperlyConfigured("You're using the \"domains framework\" without having "
"set the DOMAIN_ID setting. Create a domain in your database "
"and set the DOMAIN_ID setting to fix this error.")
model_cls = get_model("domains", "Domain")
cached = getattr(_local, "default_domain", None)
if cached is None:
try:
cached = _local.default_domain = model_cls.objects.get(pk=sid)
except model_cls.DoesNotExist:
raise ImproperlyConfigured("default domain not found on database.")
try:
return model_cls.objects.get(pk=sid)
except model_cls.DoesNotExist:
raise ImproperlyConfigured("default domain not found on database.")
return cached
def get_domain_for_domain_name(domain):
@functools.lru_cache(maxsize=100, typed=True)
def get_domain_for_domain_name(domain:str, follow_alias:bool=True):
log.debug("Trying activate domain for domain name: {}".format(domain))
cache = getattr(_local, "cache", {})
if domain in cache:
return cache[domain]
model_cls = get_model("domains", "Domain")
@ -52,11 +45,12 @@ def get_domain_for_domain_name(domain):
except model_cls.DoesNotExist:
log.warning("Domain does not exist for domain: {}".format(domain))
raise DomainNotFound(_("domain not found"))
else:
cache[domain] = domain
return domain
# Use `alias_of_id` instead of simple `alias_of` for performace reasons.
if domain.alias_of_id is None or not follow_alias:
return domain
return domain.alias_of
def activate(domain):
log.debug("Activating domain: {}".format(domain))
@ -75,9 +69,7 @@ def get_active_domain():
return get_default_domain()
return active_domain
def clear_domain_cache(**kwargs):
if hasattr(_local, "default_domain"):
del _local.default_domain
if hasattr(_local, "cache"):
del _local.cache
def clear_domain_cache(**kwargs):
get_default_domain.cache_clear()
get_domain_for_domain_name.cache_clear()

View File

@ -0,0 +1,54 @@
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from django import http
from taiga.base.exceptions import format_exception
from .base import get_domain_for_domain_name
from .base import activate as activate_domain
from .base import deactivate as deactivate_domain
from .base import get_default_domain
from .base import DomainNotFound
class DomainsMiddleware(object):
"""
Domain middlewate: process request and try resolve domain
from HTTP_X_HOST header. If no header is specified, one
default is used.
"""
def process_request(self, request):
domain = request.META.get("HTTP_X_HOST", None)
if domain is not None:
try:
domain = get_domain_for_domain_name(domain, follow_alias=True)
except DomainNotFound as e:
detail = format_exception(e)
return http.HttpResponseBadRequest(json.dumps(detail))
else:
domain = get_default_domain()
request.domain = domain
activate_domain(domain)
def process_response(self, request, response):
deactivate_domain()
if hasattr(request, "domain"):
response["X-Site-Host"] = request.domain.domain
return response

View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'Domain.alias_of'
db.add_column('domains_domain', 'alias_of',
self.gf('django.db.models.fields.related.ForeignKey')(to=orm['domains.Domain'], default=None, blank=True, null=True, related_name='+'),
keep_default=False)
def backwards(self, orm):
# Deleting field 'Domain.alias_of'
db.delete_column('domains_domain', 'alias_of_id')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'object_name': 'Permission', 'unique_together': "(('content_type', 'codename'),)", 'ordering': "('content_type__app_label', 'content_type__model', 'codename')"},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'contenttypes.contenttype': {
'Meta': {'object_name': 'ContentType', 'unique_together': "(('app_label', 'model'),)", 'ordering': "('name',)", 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'domains.domain': {
'Meta': {'object_name': 'Domain', 'ordering': "('domain',)"},
'alias_of': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['domains.Domain']", 'default': 'None', 'blank': 'True', 'null': 'True', 'related_name': "'+'"}),
'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'public_register': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'scheme': ('django.db.models.fields.CharField', [], {'default': 'None', 'null': 'True', 'max_length': '60'})
},
'domains.domainmember': {
'Meta': {'object_name': 'DomainMember', 'unique_together': "(('domain', 'user'),)", 'ordering': "['email']"},
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['domains.Domain']", 'null': 'True', 'related_name': "'members'"}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '255'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_owner': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['users.User']", 'null': 'True', 'related_name': "'+'"})
},
'users.user': {
'Meta': {'object_name': 'User', 'ordering': "['username']"},
'color': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "'#28261c'", 'max_length': '9'}),
'colorize_tags': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'default_language': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'default_timezone': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': "''", 'max_length': '20'}),
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'email': ('django.db.models.fields.EmailField', [], {'blank': 'True', 'max_length': '75'}),
'first_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'blank': 'True', 'symmetrical': 'False', 'related_name': "'user_set'"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'blank': 'True', 'max_length': '30'}),
'notify_changes_by_me': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'notify_level': ('django.db.models.fields.CharField', [], {'default': "'all_owned_projects'", 'max_length': '32'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'photo': ('django.db.models.fields.files.FileField', [], {'blank': 'True', 'null': 'True', 'max_length': '500'}),
'token': ('django.db.models.fields.CharField', [], {'blank': 'True', 'default': 'None', 'null': 'True', 'max_length': '200'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True', 'symmetrical': 'False', 'related_name': "'user_set'"}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
}
}
complete_apps = ['domains']

View File

@ -1,4 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import string
@ -8,7 +20,7 @@ from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
from . import clear_domain_cache
from .base import clear_domain_cache
def _simple_domain_name_validator(value):
@ -38,6 +50,9 @@ class Domain(models.Model):
default_language = models.CharField(max_length=20, null=False, blank=True, default="",
verbose_name=_("default language"))
alias_of = models.ForeignKey("self", null=True, default=None, blank=True,
verbose_name=_("Mark as alias of"), related_name="+")
class Meta:
verbose_name = _('domain')
verbose_name_plural = _('domain')

View File

@ -1,7 +1,21 @@
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from rest_framework import permissions
from taiga.base.domains.models import DomainMember
from taiga.base.domains import get_active_domain
from .models import DomainMember
from .base import get_active_domain
class DomainPermission(permissions.BasePermission):

View File

@ -1,4 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from rest_framework import serializers
from taiga.base.users.serializers import UserSerializer

118
taiga/domains/tests.py Normal file
View File

@ -0,0 +1,118 @@
# Copyright 2014 Andrey Antukh <niwi@niwi.be>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# y ou may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from django import test
from django.test.utils import override_settings
from django.core.exceptions import ImproperlyConfigured
from django.db.models import get_model
from django.http import HttpResponse
from . import base
from .models import Domain
from .middleware import DomainsMiddleware
class DomainCoreTests(test.TestCase):
fixtures = ["initial_domains.json"]
def setUp(self):
base.clear_domain_cache()
@override_settings(DOMAIN_ID=1)
def test_get_default_domain(self):
default_domain = base.get_default_domain()
self.assertEqual(default_domain.domain, "localhost")
@override_settings(DOMAIN_ID=2)
def test_get_wrong_default_domain(self):
with self.assertRaises(ImproperlyConfigured):
default_domain = base.get_default_domain()
def test_get_domain_by_name(self):
domain = base.get_domain_for_domain_name("localhost")
self.assertEqual(domain.id, 1)
self.assertEqual(domain.domain, "localhost")
def test_get_domain_by_name_aliased(self):
main_domain = base.get_default_domain()
aliased_domain = Domain.objects.create(domain="beta.localhost", scheme="http",
alias_of=main_domain)
resolved_domain = base.get_domain_for_domain_name("beta.localhost", follow_alias=False)
self.assertEqual(resolved_domain.domain, "beta.localhost")
resolved_domain = base.get_domain_for_domain_name("beta.localhost", follow_alias=True)
self.assertEqual(resolved_domain.domain, "localhost")
def test_lru_cache_for_get_default_domain(self):
with self.assertNumQueries(1):
base.get_default_domain()
base.get_default_domain()
def test_lru_cache_for_get_domain_for_domain_name(self):
with self.assertNumQueries(2):
base.get_domain_for_domain_name("localhost", follow_alias=True)
base.get_domain_for_domain_name("localhost", follow_alias=True)
base.get_domain_for_domain_name("localhost", follow_alias=False)
base.get_domain_for_domain_name("localhost", follow_alias=False)
def test_activate_deactivate_domain(self):
main_domain = base.get_default_domain()
aliased_domain = Domain.objects.create(domain="beta.localhost", scheme="http",
alias_of=main_domain)
self.assertEqual(base.get_active_domain(), main_domain)
base.activate(aliased_domain)
self.assertEqual(base.get_active_domain(), aliased_domain)
base.deactivate()
self.assertEqual(base.get_active_domain(), main_domain)
from django.test.client import RequestFactory
class DomainMiddlewareTests(test.TestCase):
fixtures = ["initial_domains.json"]
def setUp(self):
self.main_domain = base.get_default_domain()
self.aliased_domain = Domain.objects.create(domain="beta.localhost", scheme="http",
alias_of=self.main_domain)
self.factory = RequestFactory()
def test_process_request(self):
request = self.factory.get("/", HTTP_X_HOST="beta.localhost")
middleware = DomainsMiddleware()
ret = middleware.process_request(request)
self.assertEqual(request.domain, self.main_domain)
self.assertEqual(ret, None)
def test_process_request_with_wrong_domain(self):
request = self.factory.get("/", HTTP_X_HOST="beta2.localhost")
middleware = DomainsMiddleware()
ret = middleware.process_request(request)
self.assertFalse(hasattr(request, "domain"))
self.assertNotEqual(ret, None)
self.assertIsInstance(ret, HttpResponse)
def test_process_request_without_host_header(self):
request = self.factory.get("/")
middleware = DomainsMiddleware()
ret = middleware.process_request(request)
self.assertEqual(request.domain, self.main_domain)
self.assertEqual(ret, None)

View File

@ -3,8 +3,7 @@
from django.conf import settings
from django_jinja import library
from taiga.base import domains
from taiga import domains
URLS = {
"home": "/",

View File

@ -13,12 +13,12 @@ from rest_framework import status
from djmail.template_mail import MagicMailBuilder
from taiga.domains import get_active_domain
from taiga.base import filters
from taiga.base import exceptions as exc
from taiga.base.decorators import list_route, detail_route
from taiga.base.permissions import has_project_perm
from taiga.base.api import ModelCrudViewSet, ModelListViewSet, RetrieveModelMixin
from taiga.base.domains import get_active_domain
from taiga.base.users.models import Role
from taiga.base.notifications.api import NotificationSenderMixin
from taiga.projects.aggregates.tags import get_all_tags

View File

@ -19,11 +19,12 @@ from django.utils import timezone
from picklefield.fields import PickledObjectField
import reversion
from taiga.domains.models import DomainMember
from taiga.projects.userstories.models import UserStory
from taiga.base.utils.slug import slugify_uniquely
from taiga.base.utils.dicts import dict_sum
from taiga.base.domains.models import DomainMember
from taiga.base.users.models import Role
from taiga.projects.userstories.models import UserStory
from . import choices

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from taiga.base.permissions import BasePermission
from taiga.base.domains import get_active_domain
from taiga.domains import get_active_domain
class ProjectPermission(BasePermission):

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from django.db.models.loading import get_model
from taiga.base.domains import get_active_domain
from taiga.domains import get_active_domain
def create_project(id, owner, save=True):

View File

@ -4,12 +4,12 @@ from taiga.base import routers
from taiga.base.auth.api import AuthViewSet
from taiga.base.users.api import UsersViewSet, PermissionsViewSet
from taiga.base.searches.api import SearchViewSet
from taiga.base.domains.api import DomainViewSet, DomainMembersViewSet
from taiga.base.resolver.api import ResolverViewSet
from taiga.projects.api import (ProjectViewSet, MembershipViewSet, InvitationViewSet,
UserStoryStatusViewSet, PointsViewSet, TaskStatusViewSet,
IssueStatusViewSet, IssueTypeViewSet, PriorityViewSet,
SeverityViewSet, ProjectAdminViewSet, RolesViewSet) #, QuestionStatusViewSet)
from taiga.domains.api import DomainViewSet, DomainMembersViewSet
from taiga.projects.milestones.api import MilestoneViewSet
from taiga.projects.userstories.api import UserStoryViewSet, UserStoryAttachmentViewSet
from taiga.projects.tasks.api import TaskViewSet, TaskAttachmentViewSet