multitenenant extension
This commit is contained in:
parent
6caa277f2e
commit
4126c1f6f6
|
@ -0,0 +1,55 @@
|
|||
Multitenant
|
||||
-----------
|
||||
|
||||
An application for making a Django application multitenant for Entr'ouvert
|
||||
custsomers.
|
||||
|
||||
Install
|
||||
-------
|
||||
|
||||
Set the tenant model:
|
||||
|
||||
TENANT_MODEL = 'multitenant.Tenant'
|
||||
|
||||
Add the middlewares for multitenant, they must be first:
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'tenant_schemas.middleware.TenantMiddleware',
|
||||
'entrouvert.djommon.multitenant.middleware.EOTenantMiddleware',
|
||||
...
|
||||
)
|
||||
|
||||
Add the multitenant filesystem template loader:
|
||||
|
||||
TEMPLATE_LOADERS = (
|
||||
'entrouvert.djommon.multitenant.template_loader.MultitenantFilesystemLoader'
|
||||
....
|
||||
)
|
||||
|
||||
Configure where the multitenant templates are located:
|
||||
|
||||
|
||||
MULTITENANT_TEMPLATE_DIRS = ('/var/lib/my_project/templates/',)
|
||||
|
||||
Define the shared application:
|
||||
|
||||
|
||||
SHARED_APPS = (
|
||||
'tenant_schemas',
|
||||
'entrouvert.djommon.multitenant',
|
||||
)
|
||||
|
||||
Use multitenant database engine:
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'tenant_schemas.postgresql_backend',
|
||||
'NAME': '<db_name>',
|
||||
},
|
||||
}
|
||||
|
||||
Use multitenant south adapter:
|
||||
|
||||
SOUTH_DATABASE_ADAPTERS = {
|
||||
'default': 'south.db.postgresql_psycopg2',
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import sys
|
||||
|
||||
|
||||
class AppSettings:
|
||||
@property
|
||||
def settings(self):
|
||||
if not hasattr(self, '_settings'):
|
||||
from django.conf import settings
|
||||
self._settings = settings
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def MULTITENANT_TEMPLATE_DIRS(self):
|
||||
return self.settings.MULTITENANT_TEMPLATE_DIRS
|
||||
|
||||
|
||||
app_settings = AppSettings()
|
||||
app_settings.__name__ = __name__
|
||||
sys.modules[__name__] = app_settings
|
|
@ -0,0 +1,78 @@
|
|||
import re
|
||||
import json
|
||||
import sys
|
||||
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
from ...models import ClientSetting
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
requires_model_validation = True
|
||||
can_import_settings = True
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--list-settings',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='List settings'),
|
||||
make_option('--verbose',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='List settings'),
|
||||
make_option('--set-setting',
|
||||
action='append',
|
||||
default=[],
|
||||
help='Set setting, use key=value format'),
|
||||
)
|
||||
|
||||
@transaction.commit_on_success
|
||||
def handle(self, domain_url, schema_name=None, **options):
|
||||
import tenant_schemas.utils
|
||||
|
||||
tenant_model = tenant_schemas.utils.get_tenant_model()
|
||||
if schema_name is None:
|
||||
schema_name = slugify(domain_url)
|
||||
tenant, created = tenant_model.objects.get_or_create(domain_url=domain_url,
|
||||
defaults={'schema_name': schema_name})
|
||||
if created:
|
||||
print 'Created new tenant for', domain_url, 'and schema name', schema_name
|
||||
tenant.create_schema(True)
|
||||
if options['list_settings']:
|
||||
if tenant.clientsetting_set.exists():
|
||||
for client_setting in tenant.clientsetting_set.all():
|
||||
print '{0.name}: {0.value}'.format(client_setting)
|
||||
for key_value in options['set_setting']:
|
||||
if '=' not in key_value:
|
||||
raise CommandError('invalid --set-settings %r', key_value)
|
||||
key, value = key_value.split('=', 1)
|
||||
if not re.match(ClientSetting.NAME_RE, key):
|
||||
raise CommandError('invalid --set-settings key %r', key)
|
||||
if value:
|
||||
try:
|
||||
json.loads(value)
|
||||
except ValueError:
|
||||
raise CommandError('invalid --set-settings value JSON %r', value)
|
||||
try:
|
||||
client_setting = tenant.clientsetting_set.get(name=key)
|
||||
client_setting.value = value
|
||||
client_setting.save()
|
||||
if options['verbose']:
|
||||
print >>sys.stderr, 'Modified setting'
|
||||
except ClientSetting.DoesNotExist:
|
||||
tenant.clientsetting_set.create(name=key, value=value)
|
||||
if options['verbose']:
|
||||
print >>sys.stderr, 'Created setting'
|
||||
else:
|
||||
qs = tenant.clientsetting_set.filter(name=key)
|
||||
count = qs.count()
|
||||
qs.delete()
|
||||
if options['verbose']:
|
||||
if count:
|
||||
print >>sys.stderr, 'Deleted settings'
|
||||
else:
|
||||
print >>sys.stderr, 'No setting found'
|
|
@ -0,0 +1,20 @@
|
|||
from django.conf import settings, UserSettingsHolder
|
||||
|
||||
from tenant_schemas.middleware import TenantMiddleware
|
||||
|
||||
SENTINEL = object()
|
||||
|
||||
class EOTenantMiddleware(TenantMiddleware):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.wrapped = settings._wrapped
|
||||
|
||||
def process_request(self, request):
|
||||
super(EOTenantMiddleware, self).process_request(request)
|
||||
override = UserSettingsHolder(self.wrapped)
|
||||
for client_settings in request.tenant.clientsetting_set.all():
|
||||
setattr(override, client_settings.name, client_settings.json)
|
||||
settings._wrapped = override
|
||||
|
||||
def process_response(self, request, response):
|
||||
settings._wrapped = self.wrapped
|
||||
return response
|
|
@ -0,0 +1,46 @@
|
|||
import re
|
||||
import json
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.db import models
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from tenant_schemas.models import TenantMixin
|
||||
|
||||
from . import app_settings
|
||||
|
||||
class Tenant(TenantMixin):
|
||||
name = models.CharField(max_length=100, verbose_name=_('name'))
|
||||
created_on = models.DateField(auto_now_add=True, verbose_name=_('created on'))
|
||||
is_active = models.BooleanField(default=True, blank=True, verbose_name=_('active'))
|
||||
|
||||
|
||||
# default true, schema will be automatically created and synced when it is saved
|
||||
auto_create_schema = False
|
||||
|
||||
class ClientSetting(models.Model):
|
||||
NAME_RE = re.compile(r'^[_A-Z][_A-Z0-9]*$')
|
||||
|
||||
tenant = models.ForeignKey('Tenant', verbose_name=_('tenant'))
|
||||
name = models.CharField(max_length=100, verbose_name=_('name'))
|
||||
value = models.TextField(verbose_name=_('value'), help_text=_('JSON'))
|
||||
|
||||
def get_json(self):
|
||||
return json.loads(self.value)
|
||||
|
||||
def set_json(self, json):
|
||||
self.value = json.dumps(json)
|
||||
|
||||
json = property(get_json, set_json)
|
||||
|
||||
def clean(self):
|
||||
if not re.match(self.NAME_RE, self.name):
|
||||
raise models.ValidationError('name must be an uppercase variable '
|
||||
'name')
|
||||
try:
|
||||
json.loads(self.value)
|
||||
except ValueError:
|
||||
raise models.ValidationError('invalid JSON document')
|
||||
if self.name in dir(app_settings):
|
||||
raise ImproperlyConfigured('The setting %r cannot be overridden by tenants' % self.name)
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"""
|
||||
Wrapper class that takes a list of template loaders as an argument and attempts
|
||||
to load templates from them in order, caching the result.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.base import TemplateDoesNotExist
|
||||
from django.template.loader import BaseLoader, get_template_from_string, find_template_loader, make_origin
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils._os import safe_join
|
||||
from django.db import connection
|
||||
|
||||
import tenant_schemas.utils
|
||||
|
||||
from . import app_settings
|
||||
|
||||
class CachedLoader(BaseLoader):
|
||||
is_usable = True
|
||||
|
||||
def __init__(self, loaders):
|
||||
self.template_cache = {}
|
||||
self._loaders = loaders
|
||||
self._cached_loaders = []
|
||||
|
||||
@property
|
||||
def loaders(self):
|
||||
# Resolve loaders on demand to avoid circular imports
|
||||
if not self._cached_loaders:
|
||||
# Set self._cached_loaders atomically. Otherwise, another thread
|
||||
# could see an incomplete list. See #17303.
|
||||
cached_loaders = []
|
||||
for loader in self._loaders:
|
||||
cached_loaders.append(find_template_loader(loader))
|
||||
self._cached_loaders = cached_loaders
|
||||
return self._cached_loaders
|
||||
|
||||
def find_template(self, name, dirs=None):
|
||||
for loader in self.loaders:
|
||||
try:
|
||||
template, display_name = loader(name, dirs)
|
||||
return (template, make_origin(display_name, loader, name, dirs))
|
||||
except TemplateDoesNotExist:
|
||||
pass
|
||||
raise TemplateDoesNotExist(name)
|
||||
|
||||
def load_template(self, template_name, template_dirs=None):
|
||||
if connection.tenant:
|
||||
key = '-'.join([str(connection.tenant.pk), template_name])
|
||||
else:
|
||||
key = template_name
|
||||
if template_dirs:
|
||||
# If template directories were specified, use a hash to differentiate
|
||||
if connection.tenant:
|
||||
key = '-'.join([str(connection.tenant.pk), template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
|
||||
else:
|
||||
key = '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()])
|
||||
|
||||
if key not in self.template_cache:
|
||||
template, origin = self.find_template(template_name, template_dirs)
|
||||
if not hasattr(template, 'render'):
|
||||
try:
|
||||
template = get_template_from_string(template, origin, template_name)
|
||||
except TemplateDoesNotExist:
|
||||
# If compiling the template we found raises TemplateDoesNotExist,
|
||||
# back off to returning the source and display name for the template
|
||||
# we were asked to load. This allows for correct identification (later)
|
||||
# of the actual template that does not exist.
|
||||
return template, origin
|
||||
self.template_cache[key] = template
|
||||
return self.template_cache[key], None
|
||||
|
||||
def reset(self):
|
||||
"Empty the template cache."
|
||||
self.template_cache.clear()
|
||||
|
||||
class FilesystemLoader(BaseLoader):
|
||||
is_usable = True
|
||||
|
||||
def get_template_sources(self, template_name, template_dirs=None):
|
||||
"""
|
||||
Returns the absolute paths to "template_name", when appended to each
|
||||
directory in "template_dirs". Any paths that don't lie inside one of the
|
||||
template dirs are excluded from the result set, for security reasons.
|
||||
"""
|
||||
if not connection.tenant:
|
||||
return
|
||||
if not template_dirs:
|
||||
try:
|
||||
template_dirs = app_settings.MULTITENANT_TEMPLATE_DIRS
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured('To use %s.%s you must define the MULTITENANT_TEMPLATE_DIRS' % (__name__, FilesystemLoader.__name__))
|
||||
for template_dir in template_dirs:
|
||||
try:
|
||||
yield safe_join(template_dir, connection.tenant.domain_url, template_name)
|
||||
except UnicodeDecodeError:
|
||||
# The template dir name was a bytestring that wasn't valid UTF-8.
|
||||
raise
|
||||
except ValueError:
|
||||
# The joined path was located outside of this particular
|
||||
# template_dir (it might be inside another one, so this isn't
|
||||
# fatal).
|
||||
pass
|
||||
|
||||
def load_template_source(self, template_name, template_dirs=None):
|
||||
tried = []
|
||||
for filepath in self.get_template_sources(template_name, template_dirs):
|
||||
try:
|
||||
with open(filepath, 'rb') as fp:
|
||||
return (fp.read().decode(settings.FILE_CHARSET), filepath)
|
||||
except IOError:
|
||||
tried.append(filepath)
|
||||
if tried:
|
||||
error_msg = "Tried %s" % tried
|
||||
else:
|
||||
error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory."
|
||||
raise TemplateDoesNotExist(error_msg)
|
||||
load_template_source.is_usable = True
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
This file demonstrates writing tests using the unittest module. These will pass
|
||||
when you run "manage.py test".
|
||||
|
||||
Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class SimpleTest(TestCase):
|
||||
def test_basic_addition(self):
|
||||
"""
|
||||
Tests that 1 + 1 always equals 2.
|
||||
"""
|
||||
self.assertEqual(1 + 1, 2)
|
|
@ -0,0 +1 @@
|
|||
# Create your views here.
|
Loading…
Reference in New Issue