multitenenant extension

This commit is contained in:
Benjamin Dauvergne 2014-02-04 15:14:44 +01:00 committed by Frédéric Péters
parent 6caa277f2e
commit 4126c1f6f6
11 changed files with 354 additions and 0 deletions

55
hobo/multitenant/README Normal file
View File

@ -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',
}

View File

View File

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

View File

View File

@ -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'

View File

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

View File

@ -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)

View File

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

16
hobo/multitenant/tests.py Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
# Create your views here.