«
Précédent
|
Suivant
»
Révision 43d6b99c
Ajouté par Thomas Noël il y a environ 9 ans
- ID 43d6b99c0f1273d62706c733a3a5e57ee30b712d
- Parent 1db4b242
entrouvert/djommon/multitenant/README | ||
---|---|---|
1 |
Multitenant |
|
2 |
----------- |
|
3 |
|
|
4 |
An application for making a Django application multitenant for Entr'ouvert |
|
5 |
customers. |
|
6 |
|
|
7 |
Based on https://django-tenant-schemas.readthedocs.org/ |
|
8 |
|
|
9 |
It is developed, tested and supported on Django 1.7, but it should work with |
|
10 |
Django 1.6 + south. |
|
11 |
|
|
12 |
|
|
13 |
Install |
|
14 |
------- |
|
15 |
|
|
16 |
See also : https://django-tenant-schemas.readthedocs.org/ |
|
17 |
|
|
18 |
Set the tenant model: |
|
19 |
|
|
20 |
TENANT_MODEL = 'multitenant.Tenant' |
|
21 |
|
|
22 |
Where are tenants: |
|
23 |
|
|
24 |
TENANT_BASE = '/var/lib/<project>/tenants' |
|
25 |
|
|
26 |
Add the middlewares for multitenant, they must be first: |
|
27 |
|
|
28 |
MIDDLEWARE_CLASSES = ( |
|
29 |
'entrouvert.djommon.multitenant.middleware.TenantMiddleware', |
|
30 |
'entrouvert.djommon.multitenant.middleware.JSONSettingsMiddleware', |
|
31 |
'entrouvert.djommon.multitenant.middleware.PythonSettingsMiddleware', |
|
32 |
) + MIDDLEWARE_CLASSES |
|
33 |
|
|
34 |
Define the shared applications: |
|
35 |
|
|
36 |
SHARED_APPS = ( |
|
37 |
'tenant_schemas', |
|
38 |
'entrouvert.djommon.multitenant', |
|
39 |
# those are needed for the public apps to work |
|
40 |
# add also any application needed by the public app |
|
41 |
'django.contrib.auth', |
|
42 |
'django.contrib.contenttypes', |
|
43 |
'django.contrib.sessions', |
|
44 |
'django.contrib.messages', |
|
45 |
'django.contrib.staticfiles', |
|
46 |
) |
|
47 |
|
|
48 |
TENANT_APPS = INSTALLED_APPS |
|
49 |
|
|
50 |
INSTALLED_APPS = ('entrouvert.djommon.multitenant', |
|
51 |
'tenant_schemas') + INSTALLED_APPS |
|
52 |
|
|
53 |
# or, with Django 1.6 or older: |
|
54 |
# INSTALLED_APPS += ('tenant_schemas', 'entrouvert.djommon.multitenant') |
|
55 |
|
|
56 |
Use multitenant database engine: |
|
57 |
|
|
58 |
DATABASES = { |
|
59 |
'default': { |
|
60 |
'ENGINE': 'tenant_schemas.postgresql_backend', |
|
61 |
'NAME': '<db_name>', |
|
62 |
}, |
|
63 |
} |
|
64 |
DATABASE_ROUTERS = ( |
|
65 |
'tenant_schemas.routers.TenantSyncRouter', |
|
66 |
) |
|
67 |
|
|
68 |
# With Django 1.6 or older, use multitenant south adapter: |
|
69 |
# SOUTH_DATABASE_ADAPTERS = {'default': 'south.db.postgresql_psycopg2'} |
|
70 |
|
|
71 |
Add the multitenant filesystem template loader and configure where the |
|
72 |
multitenant templates are located: |
|
73 |
|
|
74 |
TEMPLATE_LOADERS = ( |
|
75 |
'entrouvert.djommon.multitenant.template_loader.FilesystemLoader', |
|
76 |
) + TEMPLATE_LOADERS |
|
77 |
TENANT_TEMPLATE_DIRS = (TENANT_BASE,) |
|
78 |
|
|
79 |
TEMPLATE_CONTEXT_PROCESSORS = ( |
|
80 |
'django.core.context_processors.request', |
|
81 |
) + TEMPLATE_CONTEXT_PROCESSORS |
|
82 |
|
|
83 |
|
|
84 |
Usage |
|
85 |
----- |
|
86 |
|
|
87 |
Create a tenant: |
|
88 |
manage.py create_tenant www.example.net |
|
89 |
Migration of all tenants: |
|
90 |
manage.py migrate_schemas |
|
91 |
Add a super user to tenant: |
|
92 |
manage.py tenant_command createsuperuser --domain www.example.net |
|
93 |
|
|
94 |
Tenants are created in TENANT_BASE directory, for example : |
|
95 |
/var/lib/project/tenants/www.example.net/ |
|
96 |
templates/ <-- override project templates |
|
97 |
static/ <-- to be handled by HTTP server |
|
98 |
media/ |
|
99 |
Each tenant is a PostgreSQL schema, named www_example_net |
entrouvert/djommon/multitenant/management/commands/__init__.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
from optparse import make_option |
|
7 |
from django.conf import settings |
|
8 |
from django.core.management import call_command, get_commands, load_command_class |
|
9 |
from django.core.management.base import BaseCommand, CommandError |
|
10 |
from django.db import connection |
|
11 |
try: |
|
12 |
from django.utils.six.moves import input |
|
13 |
except ImportError: |
|
14 |
input = raw_input |
|
15 |
from tenant_schemas.utils import get_public_schema_name |
|
16 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
17 |
|
|
18 |
|
|
19 |
class BaseTenantCommand(BaseCommand): |
|
20 |
""" |
|
21 |
Generic command class useful for iterating any existing command |
|
22 |
over all schemata. The actual command name is expected in the |
|
23 |
class variable COMMAND_NAME of the subclass. |
|
24 |
""" |
|
25 |
def __new__(cls, *args, **kwargs): |
|
26 |
""" |
|
27 |
Sets option_list and help dynamically. |
|
28 |
""" |
|
29 |
obj = super(BaseTenantCommand, cls).__new__(cls, *args, **kwargs) |
|
30 |
|
|
31 |
app_name = get_commands()[obj.COMMAND_NAME] |
|
32 |
if isinstance(app_name, BaseCommand): |
|
33 |
# If the command is already loaded, use it directly. |
|
34 |
cmdclass = app_name |
|
35 |
else: |
|
36 |
cmdclass = load_command_class(app_name, obj.COMMAND_NAME) |
|
37 |
|
|
38 |
# inherit the options from the original command |
|
39 |
obj.option_list = cmdclass.option_list |
|
40 |
obj.option_list += ( |
|
41 |
make_option("-d", "--domain", dest="domain"), |
|
42 |
) |
|
43 |
obj.option_list += ( |
|
44 |
make_option("-p", "--skip-public", dest="skip_public", action="store_true", default=False), |
|
45 |
) |
|
46 |
|
|
47 |
# prepend the command's original help with the info about schemata iteration |
|
48 |
obj.help = "Calls %s for all registered schemata. You can use regular %s options. "\ |
|
49 |
"Original help for %s: %s" % (obj.COMMAND_NAME, obj.COMMAND_NAME, obj.COMMAND_NAME, |
|
50 |
getattr(cmdclass, 'help', 'none')) |
|
51 |
return obj |
|
52 |
|
|
53 |
def execute_command(self, tenant, command_name, *args, **options): |
|
54 |
verbosity = int(options.get('verbosity')) |
|
55 |
|
|
56 |
if verbosity >= 1: |
|
57 |
print() |
|
58 |
print(self.style.NOTICE("=== Switching to schema '") \ |
|
59 |
+ self.style.SQL_TABLE(tenant.schema_name)\ |
|
60 |
+ self.style.NOTICE("' then calling %s:" % command_name)) |
|
61 |
|
|
62 |
connection.set_tenant(tenant) |
|
63 |
|
|
64 |
# call the original command with the args it knows |
|
65 |
call_command(command_name, *args, **options) |
|
66 |
|
|
67 |
def handle(self, *args, **options): |
|
68 |
""" |
|
69 |
Iterates a command over all registered schemata. |
|
70 |
""" |
|
71 |
if options['domain']: |
|
72 |
# only run on a particular schema |
|
73 |
connection.set_schema_to_public() |
|
74 |
self.execute_command(TenantMiddleware.get_tenant_by_hostname(options['domain']), self.COMMAND_NAME, *args, **options) |
|
75 |
else: |
|
76 |
for tenant in TenantMiddleware.get_tenants(): |
|
77 |
if not(options['skip_public'] and tenant.schema_name == get_public_schema_name()): |
|
78 |
self.execute_command(tenant, self.COMMAND_NAME, *args, **options) |
|
79 |
|
|
80 |
|
|
81 |
class InteractiveTenantOption(object): |
|
82 |
def __init__(self, *args, **kwargs): |
|
83 |
super(InteractiveTenantOption, self).__init__(*args, **kwargs) |
|
84 |
self.option_list += ( |
|
85 |
make_option("-d", "--domain", dest="domain", help="specify tenant domain"), |
|
86 |
) |
|
87 |
|
|
88 |
def get_tenant_from_options_or_interactive(self, **options): |
|
89 |
all_tenants = list(TenantMiddleware.get_tenants()) |
|
90 |
|
|
91 |
if not all_tenants: |
|
92 |
raise CommandError("""There are no tenants in the system. |
|
93 |
To learn how create a tenant, see: |
|
94 |
https://django-tenant-schemas.readthedocs.org/en/latest/use.html#creating-a-tenant""") |
|
95 |
|
|
96 |
if options.get('domain'): |
|
97 |
domain = options['domain'] |
|
98 |
else: |
|
99 |
while True: |
|
100 |
domain = input("Enter Tenant Domain ('?' to list): ") |
|
101 |
if domain == '?': |
|
102 |
print('\n'.join(["%s (schema %s)" % (t.domain_url, t.schema_name) for t in all_tenants])) |
|
103 |
else: |
|
104 |
break |
|
105 |
|
|
106 |
if domain not in [t.domain_url for t in all_tenants]: |
|
107 |
raise CommandError("Invalid tenant, '%s'" % (domain,)) |
|
108 |
|
|
109 |
return TenantMiddleware.get_tenant_by_hostname(domain) |
|
110 |
|
|
111 |
|
|
112 |
class TenantWrappedCommand(InteractiveTenantOption, BaseCommand): |
|
113 |
""" |
|
114 |
Generic command class useful for running any existing command |
|
115 |
on a particular tenant. The actual command name is expected in the |
|
116 |
class variable COMMAND_NAME of the subclass. |
|
117 |
""" |
|
118 |
def __new__(cls, *args, **kwargs): |
|
119 |
obj = super(TenantWrappedCommand, cls).__new__(cls, *args, **kwargs) |
|
120 |
obj.command_instance = obj.COMMAND() |
|
121 |
obj.option_list = obj.command_instance.option_list |
|
122 |
return obj |
|
123 |
|
|
124 |
def handle(self, *args, **options): |
|
125 |
tenant = self.get_tenant_from_options_or_interactive(**options) |
|
126 |
connection.set_tenant(tenant) |
|
127 |
|
|
128 |
self.command_instance.execute(*args, **options) |
|
129 |
|
|
130 |
|
|
131 |
class SyncCommon(BaseCommand): |
|
132 |
option_list = ( |
|
133 |
make_option('--tenant', action='store_true', dest='tenant', default=False, |
|
134 |
help='Tells Django to populate only tenant applications.'), |
|
135 |
make_option('--shared', action='store_true', dest='shared', default=False, |
|
136 |
help='Tells Django to populate only shared applications.'), |
|
137 |
make_option("-d", "--domain", dest="domain"), |
|
138 |
) |
|
139 |
|
|
140 |
def handle(self, *args, **options): |
|
141 |
self.sync_tenant = options.get('tenant') |
|
142 |
self.sync_public = options.get('shared') |
|
143 |
self.domain = options.get('domain') |
|
144 |
self.installed_apps = settings.INSTALLED_APPS |
|
145 |
self.args = args |
|
146 |
self.options = options |
|
147 |
|
|
148 |
if self.domain: |
|
149 |
self.schema_name = TenantMiddleware.hostname2schema(domain) |
|
150 |
else: |
|
151 |
self.schema_name = options.get('schema_name') |
|
152 |
|
|
153 |
if self.schema_name: |
|
154 |
if self.sync_public: |
|
155 |
raise CommandError("domain should only be used with the --tenant switch.") |
|
156 |
elif self.schema_name == get_public_schema_name(): |
|
157 |
self.sync_public = True |
|
158 |
else: |
|
159 |
self.sync_tenant = True |
|
160 |
elif not self.sync_public and not self.sync_tenant: |
|
161 |
# no options set, sync both |
|
162 |
self.sync_tenant = True |
|
163 |
self.sync_public = True |
|
164 |
|
|
165 |
if hasattr(settings, 'TENANT_APPS'): |
|
166 |
self.tenant_apps = settings.TENANT_APPS |
|
167 |
if hasattr(settings, 'SHARED_APPS'): |
|
168 |
self.shared_apps = settings.SHARED_APPS |
|
169 |
|
|
170 |
def _notice(self, output): |
|
171 |
self.stdout.write(self.style.NOTICE(output)) |
entrouvert/djommon/multitenant/management/commands/create_schemas.py | ||
---|---|---|
1 |
from django.core.management.base import BaseCommand |
|
2 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
3 |
from django.db import connection |
|
4 |
|
|
5 |
class Command(BaseCommand): |
|
6 |
help = "Create schemas for all declared tenants" |
|
7 |
|
|
8 |
def handle(self, *args, **options): |
|
9 |
verbosity = int(options.get('verbosity')) |
|
10 |
|
|
11 |
connection.set_schema_to_public() |
|
12 |
all_tenants = TenantMiddleware.get_tenants() |
|
13 |
for tenant in all_tenants: |
|
14 |
if verbosity >= 1: |
|
15 |
|
|
16 |
print self.style.NOTICE("=== Creating schema ") \ |
|
17 |
+ self.style.SQL_TABLE(tenant.schema_name) |
|
18 |
|
|
19 |
tenant.create_schema(check_if_exists=True) |
entrouvert/djommon/multitenant/management/commands/create_tenant.py | ||
---|---|---|
1 |
import os |
|
2 |
|
|
3 |
from django.db import connection |
|
4 |
from django.core.management.base import CommandError, BaseCommand |
|
5 |
|
|
6 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
7 |
|
|
8 |
class Command(BaseCommand): |
|
9 |
help = "Create tenant(s) by hostname(s)" |
|
10 |
|
|
11 |
def handle(self, *args, **options): |
|
12 |
verbosity = int(options.get('verbosity')) |
|
13 |
if not args: |
|
14 |
raise CommandError("you must give at least one tenant hostname") |
|
15 |
|
|
16 |
for hostname in args: |
|
17 |
try: |
|
18 |
tenant_base = TenantMiddleware.base() |
|
19 |
except AttributeError: |
|
20 |
raise CommandError("you must configure TENANT_BASE in your settings") |
|
21 |
if not tenant_base: |
|
22 |
raise CommandError("you must set a value to TENANT_BASE in your settings") |
|
23 |
tenant_dir = os.path.join(tenant_base, hostname) |
|
24 |
if not os.path.exists(tenant_dir): |
|
25 |
os.mkdir(tenant_dir, 0755) |
|
26 |
for folder in ('media', 'static', 'templates'): |
|
27 |
path = os.path.join(tenant_dir, folder) |
|
28 |
if not os.path.exists(path): |
|
29 |
os.mkdir(path, 0755) |
|
30 |
connection.set_schema_to_public() |
|
31 |
tenant = TenantMiddleware.get_tenant_by_hostname(hostname) |
|
32 |
if verbosity >= 1: |
|
33 |
|
|
34 |
print self.style.NOTICE("=== Creating schema ") \ |
|
35 |
+ self.style.SQL_TABLE(tenant.schema_name) |
|
36 |
tenant.create_schema(check_if_exists=True) |
entrouvert/djommon/multitenant/management/commands/createsuperuser.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
from entrouvert.djommon.multitenant.management.commands import TenantWrappedCommand |
|
7 |
from django.contrib.auth.management.commands import createsuperuser |
|
8 |
|
|
9 |
|
|
10 |
class Command(TenantWrappedCommand): |
|
11 |
COMMAND = createsuperuser.Command |
entrouvert/djommon/multitenant/management/commands/deploy.py | ||
---|---|---|
1 |
import urllib2 |
|
2 |
import json |
|
3 |
import sys |
|
4 |
|
|
5 |
from django.core.management import call_command |
|
6 |
from django.core.management.base import BaseCommand, CommandError |
|
7 |
|
|
8 |
from tenant_schemas.utils import tenant_context |
|
9 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
10 |
|
|
11 |
class Command(BaseCommand): |
|
12 |
help = 'Deploy a tenant from hobo' |
|
13 |
|
|
14 |
def handle(self, base_url, **options): |
|
15 |
environment = json.load(sys.stdin) |
|
16 |
for service in environment['services']: |
|
17 |
if service['base_url'] == base_url: |
|
18 |
break |
|
19 |
else: |
|
20 |
raise CommandError('Service %s not found' % base_url) |
|
21 |
hostname = urllib2.urlparse.urlsplit(base_url).netloc |
|
22 |
|
|
23 |
call_command('create_tenant', hostname) |
|
24 |
|
|
25 |
tenant = TenantMiddleware.get_tenant_by_hostname(hostname) |
|
26 |
with tenant_context(tenant): |
|
27 |
self.deploy_tenant(environment, service, options) |
|
28 |
|
|
29 |
def deploy_tenant(self, environment, service, options): |
|
30 |
pass |
entrouvert/djommon/multitenant/management/commands/legacy/migrate_schemas.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
from django.conf import settings |
|
7 |
from django.db import connection |
|
8 |
from south import migration |
|
9 |
from south.migration.base import Migrations |
|
10 |
from south.management.commands.migrate import Command as MigrateCommand |
|
11 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
12 |
from entrouvert.djommon.multitenant.management.commands import SyncCommon |
|
13 |
|
|
14 |
|
|
15 |
class Command(SyncCommon): |
|
16 |
help = "Migrate schemas with South" |
|
17 |
option_list = MigrateCommand.option_list + SyncCommon.option_list |
|
18 |
|
|
19 |
def handle(self, *args, **options): |
|
20 |
super(Command, self).handle(*args, **options) |
|
21 |
|
|
22 |
if self.sync_public: |
|
23 |
self.migrate_public_apps() |
|
24 |
if self.sync_tenant: |
|
25 |
self.migrate_tenant_apps(self.domain) |
|
26 |
|
|
27 |
def _set_managed_apps(self, included_apps, excluded_apps): |
|
28 |
""" while sync_schemas works by setting which apps are managed, on south we set which apps should be ignored """ |
|
29 |
ignored_apps = [] |
|
30 |
if excluded_apps: |
|
31 |
for item in excluded_apps: |
|
32 |
if item not in included_apps: |
|
33 |
ignored_apps.append(item) |
|
34 |
|
|
35 |
for app in ignored_apps: |
|
36 |
app_label = app.split('.')[-1] |
|
37 |
settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore' |
|
38 |
|
|
39 |
def _save_south_settings(self): |
|
40 |
self._old_south_modules = None |
|
41 |
if hasattr(settings, "SOUTH_MIGRATION_MODULES") and settings.SOUTH_MIGRATION_MODULES is not None: |
|
42 |
self._old_south_modules = settings.SOUTH_MIGRATION_MODULES.copy() |
|
43 |
else: |
|
44 |
settings.SOUTH_MIGRATION_MODULES = dict() |
|
45 |
|
|
46 |
def _restore_south_settings(self): |
|
47 |
settings.SOUTH_MIGRATION_MODULES = self._old_south_modules |
|
48 |
|
|
49 |
def _clear_south_cache(self): |
|
50 |
for mig in list(migration.all_migrations()): |
|
51 |
delattr(mig._application, "migrations") |
|
52 |
Migrations._clear_cache() |
|
53 |
|
|
54 |
def _migrate_schema(self, tenant): |
|
55 |
connection.set_tenant(tenant, include_public=False) |
|
56 |
MigrateCommand().execute(*self.args, **self.options) |
|
57 |
|
|
58 |
def migrate_tenant_apps(self, schema_name=None): |
|
59 |
self._save_south_settings() |
|
60 |
|
|
61 |
apps = self.tenant_apps or self.installed_apps |
|
62 |
self._set_managed_apps(included_apps=apps, excluded_apps=self.shared_apps) |
|
63 |
|
|
64 |
if schema_name: |
|
65 |
self._notice("=== Running migrate for schema: %s" % schema_name) |
|
66 |
connection.set_schema_to_public() |
|
67 |
tenant = TenantMiddleware.get_tenant_by_hostname(schema_name) |
|
68 |
self._migrate_schema(tenant) |
|
69 |
else: |
|
70 |
all_tenants = TenantMiddleware.get_tenants() |
|
71 |
if not all_tenants: |
|
72 |
self._notice("No tenants found") |
|
73 |
|
|
74 |
for tenant in all_tenants: |
|
75 |
Migrations._dependencies_done = False # very important, the dependencies need to be purged from cache |
|
76 |
self._notice("=== Running migrate for schema %s" % tenant.schema_name) |
|
77 |
self._migrate_schema(tenant) |
|
78 |
|
|
79 |
self._restore_south_settings() |
|
80 |
|
|
81 |
def migrate_public_apps(self): |
|
82 |
self._save_south_settings() |
|
83 |
|
|
84 |
apps = self.shared_apps or self.installed_apps |
|
85 |
self._set_managed_apps(included_apps=apps, excluded_apps=self.tenant_apps) |
|
86 |
|
|
87 |
self._notice("=== Running migrate for schema public") |
|
88 |
MigrateCommand().execute(*self.args, **self.options) |
|
89 |
|
|
90 |
self._clear_south_cache() |
|
91 |
self._restore_south_settings() |
entrouvert/djommon/multitenant/management/commands/list_tenants.py | ||
---|---|---|
1 |
from django.core.management.base import BaseCommand |
|
2 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
3 |
|
|
4 |
class Command(BaseCommand): |
|
5 |
requires_model_validation = True |
|
6 |
can_import_settings = True |
|
7 |
option_list = BaseCommand.option_list |
|
8 |
|
|
9 |
def handle(self, **options): |
|
10 |
all_tenants = TenantMiddleware.get_tenants() |
|
11 |
|
|
12 |
for tenant in all_tenants: |
|
13 |
print("{0} {1}".format(tenant.schema_name, tenant.domain_url)) |
|
14 |
|
entrouvert/djommon/multitenant/management/commands/migrate.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
import django |
|
7 |
from django.conf import settings |
|
8 |
from django.core.management.base import CommandError, BaseCommand |
|
9 |
|
|
10 |
if django.VERSION < (1, 7, 0): |
|
11 |
try: |
|
12 |
from south.management.commands.migrate import Command as MigrateCommand |
|
13 |
except ImportError: |
|
14 |
MigrateCommand = BaseCommand |
|
15 |
else: |
|
16 |
MigrateCommand = BaseCommand |
|
17 |
|
|
18 |
class Command(MigrateCommand): |
|
19 |
|
|
20 |
def handle(self, *args, **options): |
|
21 |
database = options.get('database', 'default') |
|
22 |
if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' or |
|
23 |
MigrateCommand is BaseCommand): |
|
24 |
raise CommandError("migrate has been disabled, for database '{}'. Use migrate_schemas " |
|
25 |
"instead. Please read the documentation if you don't know why you " |
|
26 |
"shouldn't call migrate directly!".format(database)) |
|
27 |
super(Command, self).handle(*args, **options) |
entrouvert/djommon/multitenant/management/commands/migrate_schemas.py | ||
---|---|---|
1 |
import django |
|
2 |
from optparse import NO_DEFAULT |
|
3 |
|
|
4 |
if django.VERSION >= (1, 7, 0): |
|
5 |
from django.core.management.commands.migrate import Command as MigrateCommand |
|
6 |
from django.db.migrations.recorder import MigrationRecorder |
|
7 |
from django.db import connection |
|
8 |
from django.conf import settings |
|
9 |
|
|
10 |
from tenant_schemas.utils import get_public_schema_name |
|
11 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware, TenantNotFound |
|
12 |
from entrouvert.djommon.multitenant.management.commands import SyncCommon |
|
13 |
|
|
14 |
|
|
15 |
class MigrateSchemasCommand(SyncCommon): |
|
16 |
help = "Updates database schema. Manages both apps with migrations and those without." |
|
17 |
|
|
18 |
def run_from_argv(self, argv): |
|
19 |
""" |
|
20 |
Changes the option_list to use the options from the wrapped command. |
|
21 |
Adds schema parameter to specify which schema will be used when |
|
22 |
executing the wrapped command. |
|
23 |
""" |
|
24 |
self.option_list += MigrateCommand.option_list |
|
25 |
super(MigrateSchemasCommand, self).run_from_argv(argv) |
|
26 |
|
|
27 |
def handle(self, *args, **options): |
|
28 |
super(MigrateSchemasCommand, self).handle(*args, **options) |
|
29 |
self.PUBLIC_SCHEMA_NAME = get_public_schema_name() |
|
30 |
|
|
31 |
if self.sync_public and not self.schema_name: |
|
32 |
self.schema_name = self.PUBLIC_SCHEMA_NAME |
|
33 |
|
|
34 |
if self.sync_public: |
|
35 |
self.run_migrations(self.schema_name, settings.SHARED_APPS) |
|
36 |
if self.sync_tenant: |
|
37 |
if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: |
|
38 |
self.run_migrations(self.schema_name, settings.TENANT_APPS) |
|
39 |
else: |
|
40 |
all_tenants = TenantMiddleware.get_tenants() |
|
41 |
for tenant in all_tenants: |
|
42 |
self.run_migrations(tenant.schema_name, settings.TENANT_APPS) |
|
43 |
|
|
44 |
def run_migrations(self, schema_name, included_apps): |
|
45 |
self._notice("=== Running migrate for schema %s" % schema_name) |
|
46 |
connection.set_schema(schema_name) |
|
47 |
command = MigrateCommand() |
|
48 |
|
|
49 |
defaults = {} |
|
50 |
for opt in MigrateCommand.option_list: |
|
51 |
if opt.dest in self.options: |
|
52 |
defaults[opt.dest] = self.options[opt.dest] |
|
53 |
elif opt.default is NO_DEFAULT: |
|
54 |
defaults[opt.dest] = None |
|
55 |
else: |
|
56 |
defaults[opt.dest] = opt.default |
|
57 |
|
|
58 |
command.execute(*self.args, **defaults) |
|
59 |
connection.set_schema_to_public() |
|
60 |
|
|
61 |
def _notice(self, output): |
|
62 |
self.stdout.write(self.style.NOTICE(output)) |
|
63 |
|
|
64 |
|
|
65 |
if django.VERSION >= (1, 7, 0): |
|
66 |
Command = MigrateSchemasCommand |
|
67 |
else: |
|
68 |
from .legacy.migrate_schemas import Command |
entrouvert/djommon/multitenant/management/commands/safemigrate_schemas.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
import django |
|
7 |
|
|
8 |
if django.VERSION < (1, 7, 0): |
|
9 |
from django.conf import settings |
|
10 |
from django.db import connection |
|
11 |
from south import migration |
|
12 |
from south.migration.base import Migrations |
|
13 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
14 |
from entrouvert.djommon.management.commands.safemigrate import Command as SafeMigrateCommand |
|
15 |
from entrouvert.djommon.multitenant.management.commands.sync_schemas import Command as MTSyncCommand |
|
16 |
from entrouvert.djommon.multitenant.management.commands.migrate_schemas import Command as MTMigrateCommand |
|
17 |
from entrouvert.djommon.multitenant.management.commands import SyncCommon |
|
18 |
|
|
19 |
|
|
20 |
class SafeMigrateCommand(SyncCommon): |
|
21 |
help = "Safely migrate schemas with South" |
|
22 |
option_list = MTMigrateCommand.option_list |
|
23 |
|
|
24 |
def handle(self, *args, **options): |
|
25 |
super(Command, self).handle(*args, **options) |
|
26 |
|
|
27 |
MTSyncCommand().execute(*args, **options) |
|
28 |
connection.set_schema_to_public() |
|
29 |
if self.sync_public: |
|
30 |
self.fake_public_apps() |
|
31 |
if self.sync_tenant: |
|
32 |
self.fake_tenant_apps(self.domain) |
|
33 |
connection.set_schema_to_public() |
|
34 |
MTMigrateCommand().execute(*args, **options) |
|
35 |
|
|
36 |
def _set_managed_apps(self, included_apps, excluded_apps): |
|
37 |
""" while sync_schemas works by setting which apps are managed, on south we set which apps should be ignored """ |
|
38 |
ignored_apps = [] |
|
39 |
if excluded_apps: |
|
40 |
for item in excluded_apps: |
|
41 |
if item not in included_apps: |
|
42 |
ignored_apps.append(item) |
|
43 |
|
|
44 |
for app in ignored_apps: |
|
45 |
app_label = app.split('.')[-1] |
|
46 |
settings.SOUTH_MIGRATION_MODULES[app_label] = 'ignore' |
|
47 |
|
|
48 |
def _save_south_settings(self): |
|
49 |
self._old_south_modules = None |
|
50 |
if hasattr(settings, "SOUTH_MIGRATION_MODULES") and settings.SOUTH_MIGRATION_MODULES is not None: |
|
51 |
self._old_south_modules = settings.SOUTH_MIGRATION_MODULES.copy() |
|
52 |
else: |
|
53 |
settings.SOUTH_MIGRATION_MODULES = dict() |
|
54 |
|
|
55 |
def _restore_south_settings(self): |
|
56 |
settings.SOUTH_MIGRATION_MODULES = self._old_south_modules |
|
57 |
|
|
58 |
def _clear_south_cache(self): |
|
59 |
for mig in list(migration.all_migrations()): |
|
60 |
delattr(mig._application, "migrations") |
|
61 |
Migrations._clear_cache() |
|
62 |
|
|
63 |
def _fake_schema(self, tenant): |
|
64 |
connection.set_tenant(tenant, include_public=False) |
|
65 |
SafeMigrateCommand().fake_if_needed() |
|
66 |
|
|
67 |
def fake_tenant_apps(self, schema_name=None): |
|
68 |
self._save_south_settings() |
|
69 |
|
|
70 |
apps = self.tenant_apps or self.installed_apps |
|
71 |
self._set_managed_apps(included_apps=apps, excluded_apps=self.shared_apps) |
|
72 |
|
|
73 |
if schema_name: |
|
74 |
self._notice("=== Running fake_if_needed for schema: %s" % schema_name) |
|
75 |
connection.set_schema_to_public() |
|
76 |
tenant = TenantMiddleware.get_tenant_by_hostname(schema_name) |
|
77 |
self._fake_schema(tenant) |
|
78 |
else: |
|
79 |
all_tenants = TenantMiddleware.get_tenants() |
|
80 |
if not all_tenants: |
|
81 |
self._notice("No tenants found") |
|
82 |
|
|
83 |
for tenant in all_tenants: |
|
84 |
Migrations._dependencies_done = False # very important, the dependencies need to be purged from cache |
|
85 |
self._notice("=== Running fake_if_needed for schema %s" % tenant.schema_name) |
|
86 |
self._fake_schema(tenant) |
|
87 |
|
|
88 |
self._restore_south_settings() |
|
89 |
|
|
90 |
def fake_public_apps(self): |
|
91 |
self._save_south_settings() |
|
92 |
|
|
93 |
apps = self.shared_apps or self.installed_apps |
|
94 |
self._set_managed_apps(included_apps=apps, excluded_apps=self.tenant_apps) |
|
95 |
|
|
96 |
self._notice("=== Running fake_if_needed for schema public") |
|
97 |
SafeMigrateCommand().fake_if_needed() |
|
98 |
|
|
99 |
self._clear_south_cache() |
|
100 |
self._restore_south_settings() |
|
101 |
|
|
102 |
if django.VERSION < (1, 7, 0): |
|
103 |
Command = SafeMigrateCommand |
|
104 |
else: |
|
105 |
raise RuntimeError('Django 1.7: please use migrate_schemas') |
entrouvert/djommon/multitenant/management/commands/sync_schemas.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
import django |
|
7 |
from django.core.management.base import CommandError |
|
8 |
|
|
9 |
from django.conf import settings |
|
10 |
from django.contrib.contenttypes.models import ContentType |
|
11 |
from django.db.models import get_apps, get_models |
|
12 |
if 'south' in settings.INSTALLED_APPS: |
|
13 |
from south.management.commands.syncdb import Command as SyncdbCommand |
|
14 |
else: |
|
15 |
from django.core.management.commands.syncdb import Command as SyncdbCommand |
|
16 |
from django.db import connection |
|
17 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
18 |
from entrouvert.djommon.multitenant.management.commands import SyncCommon |
|
19 |
|
|
20 |
|
|
21 |
class Command(SyncCommon): |
|
22 |
help = "Sync schemas based on TENANT_APPS and SHARED_APPS settings" |
|
23 |
option_list = SyncdbCommand.option_list + SyncCommon.option_list |
|
24 |
|
|
25 |
def handle(self, *args, **options): |
|
26 |
if django.VERSION >= (1, 7, 0): |
|
27 |
raise CommandError('This command is only meant to be used for 1.6' |
|
28 |
' and older version of django. For 1.7, use' |
|
29 |
' `migrate_schemas` instead.') |
|
30 |
super(Command, self).handle(*args, **options) |
|
31 |
|
|
32 |
if "south" in settings.INSTALLED_APPS: |
|
33 |
self.options["migrate"] = False |
|
34 |
|
|
35 |
# save original settings |
|
36 |
for model in get_models(include_auto_created=True): |
|
37 |
setattr(model._meta, 'was_managed', model._meta.managed) |
|
38 |
|
|
39 |
ContentType.objects.clear_cache() |
|
40 |
|
|
41 |
if self.sync_public: |
|
42 |
self.sync_public_apps() |
|
43 |
if self.sync_tenant: |
|
44 |
self.sync_tenant_apps(self.domain) |
|
45 |
|
|
46 |
# restore settings |
|
47 |
for model in get_models(include_auto_created=True): |
|
48 |
model._meta.managed = model._meta.was_managed |
|
49 |
|
|
50 |
def _set_managed_apps(self, included_apps): |
|
51 |
""" sets which apps are managed by syncdb """ |
|
52 |
for model in get_models(include_auto_created=True): |
|
53 |
model._meta.managed = False |
|
54 |
|
|
55 |
verbosity = int(self.options.get('verbosity')) |
|
56 |
for app_model in get_apps(): |
|
57 |
app_name = app_model.__name__.replace('.models', '') |
|
58 |
if app_name in included_apps: |
|
59 |
for model in get_models(app_model, include_auto_created=True): |
|
60 |
model._meta.managed = model._meta.was_managed |
|
61 |
if model._meta.managed and verbosity >= 3: |
|
62 |
self._notice("=== Include Model: %s: %s" % (app_name, model.__name__)) |
|
63 |
|
|
64 |
def _sync_tenant(self, tenant): |
|
65 |
self._notice("=== Running syncdb for schema: %s" % tenant.schema_name) |
|
66 |
connection.set_tenant(tenant, include_public=False) |
|
67 |
SyncdbCommand().execute(**self.options) |
|
68 |
|
|
69 |
def sync_tenant_apps(self, schema_name=None): |
|
70 |
apps = self.tenant_apps or self.installed_apps |
|
71 |
self._set_managed_apps(apps) |
|
72 |
if schema_name: |
|
73 |
tenant = TenantMiddleware.get_tenant_by_hostname(schema_name) |
|
74 |
self._sync_tenant(tenant) |
|
75 |
else: |
|
76 |
all_tenants = TenantMiddleware.get_tenants() |
|
77 |
if not all_tenants: |
|
78 |
self._notice("No tenants found!") |
|
79 |
|
|
80 |
for tenant in all_tenants: |
|
81 |
self._sync_tenant(tenant) |
|
82 |
|
|
83 |
def sync_public_apps(self): |
|
84 |
self._notice("=== Running syncdb for schema public") |
|
85 |
apps = self.shared_apps or self.installed_apps |
|
86 |
self._set_managed_apps(apps) |
|
87 |
SyncdbCommand().execute(**self.options) |
entrouvert/djommon/multitenant/management/commands/syncdb.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
from django.core.management.base import CommandError |
|
7 |
from django.conf import settings |
|
8 |
from tenant_schemas.utils import django_is_in_test_mode |
|
9 |
|
|
10 |
if 'south' in settings.INSTALLED_APPS: |
|
11 |
from south.management.commands import syncdb |
|
12 |
else: |
|
13 |
from django.core.management.commands import syncdb |
|
14 |
|
|
15 |
|
|
16 |
class Command(syncdb.Command): |
|
17 |
|
|
18 |
def handle(self, *args, **options): |
|
19 |
database = options.get('database', 'default') |
|
20 |
if (settings.DATABASES[database]['ENGINE'] == 'tenant_schemas.postgresql_backend' and not |
|
21 |
django_is_in_test_mode()): |
|
22 |
raise CommandError("syncdb has been disabled, for database '{0}'. " |
|
23 |
"Use sync_schemas instead. Please read the " |
|
24 |
"documentation if you don't know why " |
|
25 |
"you shouldn't call syncdb directly!".format(database)) |
|
26 |
super(Command, self).handle(*args, **options) |
entrouvert/djommon/multitenant/management/commands/tenant_command.py | ||
---|---|---|
1 |
# this file derive from django-tenant-schemas |
|
2 |
# Author: Bernardo Pires Carneiro |
|
3 |
# Email: carneiro.be@gmail.com |
|
4 |
# License: MIT license |
|
5 |
# Home-page: http://github.com/bcarneiro/django-tenant-schemas |
|
6 |
from optparse import make_option |
|
7 |
from django.core.management.base import BaseCommand, CommandError |
|
8 |
from django.core.management import call_command, get_commands, load_command_class |
|
9 |
from django.db import connection |
|
10 |
from entrouvert.djommon.multitenant.management.commands import InteractiveTenantOption |
|
11 |
|
|
12 |
|
|
13 |
class Command(InteractiveTenantOption, BaseCommand): |
|
14 |
help = "Wrapper around django commands for use with an individual tenant" |
|
15 |
|
|
16 |
def run_from_argv(self, argv): |
|
17 |
""" |
|
18 |
Changes the option_list to use the options from the wrapped command. |
|
19 |
Adds schema parameter to specifiy which schema will be used when |
|
20 |
executing the wrapped command. |
|
21 |
""" |
|
22 |
# load the command object. |
|
23 |
try: |
|
24 |
app_name = get_commands()[argv[2]] |
|
25 |
except KeyError: |
|
26 |
raise CommandError("Unknown command: %r" % argv[2]) |
|
27 |
|
|
28 |
if isinstance(app_name, BaseCommand): |
|
29 |
# if the command is already loaded, use it directly. |
|
30 |
klass = app_name |
|
31 |
else: |
|
32 |
klass = load_command_class(app_name, argv[2]) |
|
33 |
|
|
34 |
super(Command, self).run_from_argv(argv) |
|
35 |
|
|
36 |
def handle(self, *args, **options): |
|
37 |
tenant = self.get_tenant_from_options_or_interactive(**options) |
|
38 |
connection.set_tenant(tenant) |
|
39 |
|
|
40 |
call_command(*args, **options) |
entrouvert/djommon/multitenant/middleware.py | ||
---|---|---|
1 |
import os |
|
2 |
import json |
|
3 |
import glob |
|
4 |
|
|
5 |
from django.conf import settings, UserSettingsHolder |
|
6 |
from django.db import connection |
|
7 |
from django.http import Http404 |
|
8 |
from django.contrib.contenttypes.models import ContentType |
|
9 |
from tenant_schemas.utils import get_tenant_model, remove_www_and_dev, get_public_schema_name |
|
10 |
|
|
11 |
SENTINEL = object() |
|
12 |
|
|
13 |
class TenantNotFound(RuntimeError): |
|
14 |
pass |
|
15 |
|
|
16 |
class TenantMiddleware(object): |
|
17 |
""" |
|
18 |
This middleware should be placed at the very top of the middleware stack. |
|
19 |
Selects the proper database schema using the request host. Can fail in |
|
20 |
various ways which is better than corrupting or revealing data... |
|
21 |
""" |
|
22 |
@classmethod |
|
23 |
def base(cls): |
|
24 |
return settings.TENANT_BASE |
|
25 |
|
|
26 |
@classmethod |
|
27 |
def hostname2schema(cls, hostname): |
|
28 |
'''Convert hostname to PostgreSQL schema name''' |
|
29 |
if hostname in getattr(settings, 'TENANT_MAPPING', {}): |
|
30 |
return settings.TENANT_MAPPING[hostname] |
|
31 |
return hostname.replace('.', '_').replace('-', '_') |
|
32 |
|
|
33 |
@classmethod |
|
34 |
def get_tenant_by_hostname(cls, hostname): |
|
35 |
'''Retrieve a tenant object for this hostname''' |
|
36 |
if not os.path.exists(os.path.join(cls.base(), hostname)): |
|
37 |
raise TenantNotFound |
|
38 |
schema = cls.hostname2schema(hostname) |
|
39 |
return get_tenant_model()(schema_name=schema, domain_url=hostname) |
|
40 |
|
|
41 |
@classmethod |
|
42 |
def get_tenants(cls): |
|
43 |
self = cls() |
|
44 |
for path in glob.glob(os.path.join(cls.base(), '*')): |
|
45 |
hostname = os.path.basename(path) |
|
46 |
yield get_tenant_model()( |
|
47 |
schema_name=self.hostname2schema(hostname), |
|
48 |
domain_url=hostname) |
|
49 |
|
|
50 |
def process_request(self, request): |
|
51 |
# connection needs first to be at the public schema, as this is where the |
|
52 |
# tenant informations are saved |
|
53 |
connection.set_schema_to_public() |
|
54 |
hostname_without_port = remove_www_and_dev(request.get_host().split(':')[0]) |
|
55 |
|
|
56 |
try: |
|
57 |
request.tenant = self.get_tenant_by_hostname(hostname_without_port) |
|
58 |
except TenantNotFound: |
|
59 |
raise Http404 |
|
60 |
connection.set_tenant(request.tenant) |
|
61 |
|
|
62 |
# content type can no longer be cached as public and tenant schemas have different |
|
63 |
# models. if someone wants to change this, the cache needs to be separated between |
|
64 |
# public and shared schemas. if this cache isn't cleared, this can cause permission |
|
65 |
# problems. for example, on public, a particular model has id 14, but on the tenants |
|
66 |
# it has the id 15. if 14 is cached instead of 15, the permissions for the wrong |
|
67 |
# model will be fetched. |
|
68 |
ContentType.objects.clear_cache() |
|
69 |
|
|
70 |
# do we have a public-specific token? |
|
71 |
if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): |
|
72 |
request.urlconf = settings.PUBLIC_SCHEMA_URLCONF |
|
73 |
|
|
74 |
|
|
75 |
|
|
76 |
|
|
77 |
class TenantSettingBaseMiddleware(object): |
|
78 |
'''Base middleware classe for loading settings based on tenants |
|
79 |
|
|
80 |
Child classes MUST override the load_tenant_settings() method. |
|
81 |
''' |
|
82 |
def __init__(self, *args, **kwargs): |
|
83 |
self.tenants_settings = {} |
|
84 |
|
|
85 |
def get_tenant_settings(self, wrapped, tenant): |
|
86 |
'''Get last loaded settings for tenant, try to update it by loading |
|
87 |
settings again is last loading time is less recent thant settings data |
|
88 |
store. Compare with last modification time is done in the |
|
89 |
load_tenant_settings() method. |
|
90 |
''' |
|
91 |
tenant_settings, last_time = self.tenants_settings.get(tenant.schema_name, (None,None)) |
|
92 |
if tenant_settings is None: |
|
93 |
tenant_settings = UserSettingsHolder(wrapped) |
|
94 |
tenant_settings, last_time = self.load_tenant_settings(wrapped, tenant, tenant_settings, last_time) |
|
95 |
self.tenants_settings[tenant.schema_name] = tenant_settings, last_time |
|
96 |
return tenant_settings |
|
97 |
|
|
98 |
def load_tenant_settings(self, wrapped, tenant, tenant_settings, last_time): |
|
99 |
'''Load tenant settings into tenant_settings object, eventually skip if |
|
100 |
last_time is more recent than last update time for settings and return |
|
101 |
the new value for tenant_settings and last_time''' |
|
102 |
raise NotImplemented |
|
103 |
|
|
104 |
def process_request(self, request): |
|
105 |
if not hasattr(request, '_old_settings_wrapped'): |
|
106 |
request._old_settings_wrapped = [] |
|
107 |
request._old_settings_wrapped.append(settings._wrapped) |
|
108 |
settings._wrapped = self.get_tenant_settings(settings._wrapped, request.tenant) |
|
109 |
|
|
110 |
def process_response(self, request, response): |
|
111 |
if hasattr(request, '_old_settings_wrapped') and request._old_settings_wrapped: |
|
112 |
settings._wrapped = request._old_settings_wrapped.pop() |
|
113 |
return response |
|
114 |
|
|
115 |
|
|
116 |
class FileBasedTenantSettingBaseMiddleware(TenantSettingBaseMiddleware): |
|
117 |
FILENAME = None |
|
118 |
|
|
119 |
def load_tenant_settings(self, wrapped, tenant, tenant_settings, last_time): |
|
120 |
path = os.path.join(settings.TENANT_BASE, tenant.domain_url, self.FILENAME) |
|
121 |
try: |
|
122 |
new_time = os.stat(path).st_mtime |
|
123 |
except OSError: |
|
124 |
# file was removed |
|
125 |
if not last_time is None: |
|
126 |
return UserSettingsHolder(wrapped), None |
|
127 |
else: |
|
128 |
if last_time is None or new_time >= last_time: |
|
129 |
# file is new |
|
130 |
tenant_settings = UserSettingsHolder(wrapped) |
|
131 |
self.load_file(tenant_settings, path) |
|
132 |
return tenant_settings, new_time |
|
133 |
# nothing has changed |
|
134 |
return tenant_settings, last_time |
|
135 |
|
|
136 |
|
|
137 |
class JSONSettingsMiddleware(FileBasedTenantSettingBaseMiddleware): |
|
138 |
'''Load settings from a JSON file whose path is given by: |
|
139 |
|
|
140 |
os.path.join(settings.TENANT_BASE % schema_name, 'settings.json') |
|
141 |
|
|
142 |
The JSON file must be a dictionnary whose key/value will override |
|
143 |
current settings. |
|
144 |
''' |
|
145 |
FILENAME = 'settings.json' |
|
146 |
|
|
147 |
def load_file(sef, tenant_settings, path): |
|
148 |
with file(path) as f: |
|
149 |
json_settings = json.load(f) |
|
150 |
for key in json_settings: |
|
151 |
setattr(tenant_settings, key, json_settings[key]) |
|
152 |
|
|
153 |
|
|
154 |
class DictAdapter(dict): |
|
155 |
'''Give dict interface to plain objects''' |
|
156 |
def __init__(self, wrapped): |
|
157 |
self.wrapped = wrapped |
|
158 |
|
|
159 |
def __setitem__(self, key, value): |
|
160 |
setattr(self.wrapped, key, value) |
|
161 |
|
|
162 |
def __getitem__(self, key): |
|
163 |
try: |
|
164 |
return getattr(self.wrapped, key) |
|
165 |
except AttributeError: |
|
166 |
raise KeyError |
|
167 |
|
|
168 |
|
|
169 |
class PythonSettingsMiddleware(FileBasedTenantSettingBaseMiddleware): |
|
170 |
'''Load settings from a file whose path is given by: |
|
171 |
|
|
172 |
os.path.join(settings.TENANT_BASE % schema_name, 'settings.py') |
|
173 |
|
|
174 |
The file is executed in the same context as the classic settings file |
|
175 |
using execfile. |
|
176 |
''' |
|
177 |
FILENAME = 'settings.py' |
|
178 |
|
|
179 |
def load_file(self, tenant_settings, path): |
|
180 |
execfile(path, DictAdapter(tenant_settings)) |
entrouvert/djommon/multitenant/models.py | ||
---|---|---|
1 |
from tenant_schemas.models import TenantMixin |
|
2 |
|
|
3 |
class Tenant(TenantMixin): |
|
4 |
# default true, schema will be automatically created and synced when it is saved |
|
5 |
auto_create_schema = False |
|
6 |
|
|
7 |
def save(self): |
|
8 |
pass |
|
9 |
|
|
10 |
def __unicode__(self): |
|
11 |
return u'%s' % self.schema_name |
entrouvert/djommon/multitenant/storage.py | ||
---|---|---|
1 |
import os |
|
2 |
|
|
3 |
from django.conf import settings |
|
4 |
from django.core.exceptions import SuspiciousOperation |
|
5 |
from django.utils._os import safe_join |
|
6 |
|
|
7 |
from django.db import connection |
|
8 |
|
|
9 |
from django.core.files.storage import FileSystemStorage |
|
10 |
|
|
11 |
__all__ = ('TenantFileSystemStorage', ) |
|
12 |
|
|
13 |
class TenantFileSystemStorage(FileSystemStorage): |
|
14 |
'''Lookup files first in $TENANT_BASE/<tenant.schema>/media/ then in default location''' |
|
15 |
def path(self, name): |
|
16 |
if connection.tenant: |
|
17 |
location = safe_join(settings.TENANT_BASE, connection.tenant.domain_url, 'media') |
|
18 |
else: |
|
19 |
location = self.location |
|
20 |
try: |
|
21 |
path = safe_join(location, name) |
|
22 |
except ValueError: |
|
23 |
raise SuspiciousOperation("Attempted access to '%s' denied." % name) |
|
24 |
return os.path.normpath(path) |
entrouvert/djommon/multitenant/template_loader.py | ||
---|---|---|
1 |
""" |
|
2 |
Wrapper class that takes a list of template loaders as an argument and attempts |
|
3 |
to load templates from them in order, caching the result. |
|
4 |
""" |
|
5 |
|
|
6 |
import hashlib |
|
7 |
from django.conf import settings |
|
8 |
from django.core.exceptions import ImproperlyConfigured |
|
9 |
from django.template.base import TemplateDoesNotExist |
|
10 |
from django.template.loader import BaseLoader, get_template_from_string, find_template_loader, make_origin |
|
11 |
from django.utils.encoding import force_bytes |
|
12 |
from django.utils._os import safe_join |
|
13 |
from django.db import connection |
|
14 |
|
|
15 |
class CachedLoader(BaseLoader): |
|
16 |
is_usable = True |
|
17 |
|
|
18 |
def __init__(self, loaders): |
|
19 |
self.template_cache = {} |
|
20 |
self._loaders = loaders |
|
21 |
self._cached_loaders = [] |
|
22 |
|
|
23 |
@property |
|
24 |
def loaders(self): |
|
25 |
# Resolve loaders on demand to avoid circular imports |
|
26 |
if not self._cached_loaders: |
|
27 |
# Set self._cached_loaders atomically. Otherwise, another thread |
|
28 |
# could see an incomplete list. See #17303. |
|
29 |
cached_loaders = [] |
|
30 |
for loader in self._loaders: |
|
31 |
cached_loaders.append(find_template_loader(loader)) |
|
32 |
self._cached_loaders = cached_loaders |
|
33 |
return self._cached_loaders |
|
34 |
|
|
35 |
def find_template(self, name, dirs=None): |
|
36 |
for loader in self.loaders: |
|
37 |
try: |
|
38 |
template, display_name = loader(name, dirs) |
|
39 |
return (template, make_origin(display_name, loader, name, dirs)) |
|
40 |
except TemplateDoesNotExist: |
|
41 |
pass |
|
42 |
raise TemplateDoesNotExist(name) |
|
43 |
|
|
44 |
def load_template(self, template_name, template_dirs=None): |
|
45 |
if connection.tenant: |
|
46 |
key = '-'.join([str(connection.tenant.pk), template_name]) |
|
47 |
else: |
|
48 |
key = template_name |
|
49 |
if template_dirs: |
|
50 |
# If template directories were specified, use a hash to differentiate |
|
51 |
if connection.tenant: |
|
52 |
key = '-'.join([str(connection.tenant.schema_name), template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) |
|
53 |
else: |
|
54 |
key = '-'.join([template_name, hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest()]) |
|
55 |
|
|
56 |
if key not in self.template_cache: |
|
57 |
template, origin = self.find_template(template_name, template_dirs) |
|
58 |
if not hasattr(template, 'render'): |
|
59 |
try: |
|
60 |
template = get_template_from_string(template, origin, template_name) |
|
61 |
except TemplateDoesNotExist: |
|
62 |
# If compiling the template we found raises TemplateDoesNotExist, |
|
63 |
# back off to returning the source and display name for the template |
|
64 |
# we were asked to load. This allows for correct identification (later) |
|
65 |
# of the actual template that does not exist. |
|
66 |
return template, origin |
|
67 |
self.template_cache[key] = template |
|
68 |
return self.template_cache[key], None |
|
69 |
|
|
70 |
def reset(self): |
|
71 |
"Empty the template cache." |
|
72 |
self.template_cache.clear() |
|
73 |
|
|
74 |
class FilesystemLoader(BaseLoader): |
|
75 |
is_usable = True |
|
76 |
|
|
77 |
def get_template_sources(self, template_name, template_dirs=None): |
|
78 |
""" |
|
79 |
Returns the absolute paths to "template_name", when appended to each |
|
80 |
directory in "template_dirs". Any paths that don't lie inside one of the |
|
81 |
template dirs are excluded from the result set, for security reasons. |
|
82 |
""" |
|
83 |
if not connection.tenant: |
|
84 |
return |
|
85 |
if not template_dirs: |
|
86 |
try: |
|
87 |
template_dirs = settings.TENANT_TEMPLATE_DIRS |
|
88 |
except AttributeError: |
|
89 |
raise ImproperlyConfigured('To use %s.%s you must define the TENANT_TEMPLATE_DIRS' % (__name__, FilesystemLoader.__name__)) |
|
90 |
for template_dir in template_dirs: |
|
91 |
try: |
|
92 |
yield safe_join(template_dir, connection.tenant.domain_url, 'templates', template_name) |
|
93 |
except UnicodeDecodeError: |
|
94 |
# The template dir name was a bytestring that wasn't valid UTF-8. |
|
95 |
raise |
|
96 |
except ValueError: |
|
97 |
# The joined path was located outside of this particular |
|
98 |
# template_dir (it might be inside another one, so this isn't |
|
99 |
# fatal). |
|
100 |
pass |
|
101 |
|
|
102 |
def load_template_source(self, template_name, template_dirs=None): |
|
103 |
tried = [] |
|
104 |
for filepath in self.get_template_sources(template_name, template_dirs): |
|
105 |
try: |
|
106 |
with open(filepath, 'rb') as fp: |
|
107 |
return (fp.read().decode(settings.FILE_CHARSET), filepath) |
|
108 |
except IOError: |
|
109 |
tried.append(filepath) |
|
110 |
if tried: |
|
111 |
error_msg = "Tried %s" % tried |
|
112 |
else: |
|
113 |
error_msg = "Your TENANT_TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." |
|
114 |
raise TemplateDoesNotExist(error_msg) |
|
115 |
load_template_source.is_usable = True |
entrouvert/djommon/multitenant/tests.py | ||
---|---|---|
1 |
""" |
|
2 |
Test multitenant framework |
|
3 |
""" |
|
4 |
|
|
5 |
import tempfile |
|
6 |
import shutil |
|
7 |
import os |
|
8 |
import json |
|
9 |
import StringIO |
|
10 |
|
|
11 |
from django.conf.urls import patterns |
|
12 |
from django.test import TestCase, Client |
|
13 |
from django.http import HttpResponse |
|
14 |
from django.template.response import TemplateResponse |
|
15 |
|
|
16 |
try: |
|
17 |
from django.test import override_settings |
|
18 |
except ImportError: # django < 1.7 |
|
19 |
from django.test.utils import override_settings |
|
20 |
|
|
21 |
|
|
22 |
def json_key(request, *args, **kwargs): |
|
23 |
from django.conf import settings |
|
24 |
return HttpResponse(settings.JSON_KEY + ' json') |
|
25 |
|
|
26 |
def python_key(request, *args, **kwargs): |
|
27 |
from django.conf import settings |
|
28 |
return HttpResponse(settings.PYTHON_KEY + ' python') |
|
29 |
|
|
30 |
def template(request, *args, **kwargs): |
|
31 |
return TemplateResponse(request, 'tenant.html') |
|
32 |
|
|
33 |
def upload(request): |
|
34 |
from django.core.files.storage import default_storage |
|
35 |
default_storage.save('upload', request.FILES['upload']) |
|
36 |
return HttpResponse('') |
|
37 |
|
|
38 |
def download(request): |
|
39 |
from django.core.files.storage import default_storage |
|
40 |
return HttpResponse(default_storage.open('upload').read()) |
|
41 |
|
|
42 |
urlpatterns = patterns('', |
|
43 |
('^json_key/$', json_key), |
|
44 |
('^python_key/$', python_key), |
|
45 |
('^template/$', template), |
|
46 |
('^upload/$', upload), |
|
47 |
('^download/$', download), |
|
48 |
) |
|
49 |
|
|
50 |
@override_settings( |
|
51 |
ROOT_URLCONF=__name__, |
|
52 |
MIDDLEWARE_CLASSES=( |
|
53 |
'entrouvert.djommon.multitenant.middleware.TenantMiddleware', |
|
54 |
'entrouvert.djommon.multitenant.middleware.JSONSettingsMiddleware', |
|
55 |
'entrouvert.djommon.multitenant.middleware.PythonSettingsMiddleware', |
|
56 |
), |
|
57 |
TEMPLATE_LOADERS = ( |
|
58 |
'entrouvert.djommon.multitenant.template_loader.FilesystemLoader', |
|
59 |
), |
|
60 |
DEFAULT_FILE_STORAGE = 'entrouvert.djommon.multitenant.storage.TenantFileSystemStorage', |
|
61 |
) |
|
62 |
class SimpleTest(TestCase): |
|
63 |
TENANTS = ['tenant1', 'tenant2'] |
|
64 |
|
|
65 |
def setUp(self): |
|
66 |
self.tenant_base = tempfile.mkdtemp() |
|
67 |
for tenant in self.TENANTS: |
|
68 |
tenant_dir = os.path.join(self.tenant_base, tenant) |
|
69 |
os.mkdir(tenant_dir) |
|
70 |
settings_py = os.path.join(tenant_dir, 'settings.json') |
|
71 |
with file(settings_py, 'w') as f: |
|
72 |
json.dump({'JSON_KEY': tenant}, f) |
|
73 |
settings_json = os.path.join(tenant_dir, 'settings.py') |
|
74 |
with file(settings_json, 'w') as f: |
|
75 |
print >>f, 'PYTHON_KEY = %r' % tenant |
|
76 |
templates_dir = os.path.join(tenant_dir, 'templates') |
|
77 |
os.mkdir(templates_dir) |
|
78 |
tenant_html = os.path.join(templates_dir, 'tenant.html') |
|
79 |
with file(tenant_html, 'w') as f: |
|
80 |
print >>f, tenant + ' template', |
|
81 |
media_dir = os.path.join(tenant_dir, 'media') |
|
82 |
os.mkdir(media_dir) |
|
83 |
|
|
84 |
def tearDown(self): |
|
85 |
shutil.rmtree(self.tenant_base, ignore_errors=True) |
|
86 |
|
|
87 |
def tenant_settings(self): |
|
88 |
return self.settings( |
|
89 |
TENANT_BASE=self.tenant_base, |
|
90 |
TENANT_TEMPLATE_DIRS=(self.tenant_base,) |
|
91 |
) |
|
92 |
|
|
93 |
def test_tenants(self): |
|
94 |
with self.tenant_settings(): |
|
95 |
for tenant in self.TENANTS: |
|
96 |
c = Client(HTTP_HOST=tenant) |
|
97 |
response = c.get('/json_key/') |
|
98 |
self.assertEqual(response.content, tenant + ' json') |
|
99 |
response = c.get('/python_key/') |
|
100 |
self.assertEqual(response.content, tenant + ' python') |
|
101 |
response = c.get('/template/') |
|
102 |
self.assertEqual(response.content, tenant + ' template') |
|
103 |
|
|
104 |
def test_list_tenants(self): |
|
105 |
from entrouvert.djommon.multitenant.middleware import TenantMiddleware |
|
106 |
from tenant_schemas.utils import get_tenant_model |
|
107 |
|
|
108 |
with self.tenant_settings(): |
|
109 |
l1 = set(map(str, TenantMiddleware.get_tenants())) |
|
110 |
l2 = set(str(get_tenant_model()(schema_name=tenant, |
|
111 |
domain_url=tenant)) for tenant in self.TENANTS) |
|
112 |
self.assertEquals(l1, l2) |
|
113 |
|
|
114 |
def test_storage(self): |
|
115 |
from django.core.files.base import ContentFile |
|
116 |
with self.tenant_settings(): |
|
117 |
for tenant in self.TENANTS: |
|
118 |
c = Client(HTTP_HOST=tenant) |
|
119 |
uploaded_file_path = os.path.join(self.tenant_base, tenant, 'media', 'upload') |
|
120 |
self.assertFalse(os.path.exists(uploaded_file_path), uploaded_file_path) |
|
121 |
response = c.post('/upload/', {'upload': ContentFile(tenant + ' upload', name='upload.txt')}) |
|
122 |
self.assertEqual(response.status_code, 200) |
|
123 |
self.assertEqual(response.content, '') |
|
124 |
self.assertTrue(os.path.exists(uploaded_file_path)) |
|
125 |
self.assertEqual(file(uploaded_file_path).read(), tenant + ' upload') |
|
126 |
response = c.get('/download/') |
|
127 |
self.assertEqual(response.status_code, 200) |
|
128 |
self.assertEqual(response.content, tenant + ' upload') |
entrouvert/djommon/multitenant/views.py | ||
---|---|---|
1 |
# Create your views here. |
Formats disponibles : Unified diff
remove multitenant, now in hobo (#6491)