├── .gitignore ├── LICENSE ├── README.rst ├── appschema ├── __init__.py ├── db.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── createschema.py │ │ ├── loadschemadata.py │ │ ├── migrate.py │ │ ├── schema_exec.py │ │ ├── schematemplate.py │ │ └── syncdb.py ├── middleware.py ├── models.py ├── schema.py ├── south_utils.py ├── utils.py └── version.py ├── contrib └── clone_schema.sql └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /*.egg-info 3 | /build/ 4 | /dist/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Olivier Meunier 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Django appschema 3 | ================ 4 | 5 | What is it? 6 | =========== 7 | 8 | Django Appschema is born from the idea proposed by *tidbids* on `SaaS with 9 | Django and PostgreSQL 10 | `_ and `SaaS 11 | with Django and PostgreSQL Schemas 12 | `_. 13 | 14 | The main idea is to isolate customer's data in different schema. One customer 15 | = one schema. There are very good explanations on why schemas and what it 16 | could bring to your SaaS application on `this thread on Hacker News 17 | `_. 18 | 19 | How it works? 20 | ============= 21 | 22 | On the running side, schema isolation is quite easy, set up a middleware that 23 | make dispatch, a signal and *voilà* (almost). 24 | 25 | Django Appschema creates a table containing a ``public_name`` (could be FQDN 26 | or whatever your middleware decides) and a associated schema. This table 27 | should be shared by all schemas you'll create. 28 | 29 | Now the complicated part. Performing syncdb on multiple schemas could be a bit 30 | difficult, specially if you want to share some apps and isolate some others 31 | (or both). 32 | 33 | On your configuration file, you'll add ``APPSCHEMA_SHARED_APPS`` which is a 34 | list containing the apps you want to share. Basically, shared apps tables are 35 | created in ``public`` schema. 36 | 37 | Appschema will alway be a shared app. If installed, South will be both shared 38 | and isolated. 39 | 40 | Appschema comes with modified versions of ``syncdb`` and ``migrate`` that will 41 | perform required operations on schemas. 42 | 43 | Another management command called ``createschema`` allows you to create a new 44 | schema and performs syncdb (and migrate if available) on the new created 45 | schema. 46 | 47 | Schema creation from your web app 48 | --------------------------------- 49 | 50 | If you look at the code, you'll find a function called ``new_schema``. You 51 | could be tempted to use it directly in your web app (for registration 52 | purpose). DON'T. NEVER. SERIOUSLY. In order to run properly, commands modify 53 | INSTALLED_APPS and the Django apps cache during execution. What is not an 54 | issue during a management command could become a nightmare in a runing Django 55 | process. 56 | 57 | A first solution comes with ``schematemplate`` management command. This 58 | command creates a temporary schema, executes ``pg_dump`` on it and prints the 59 | result on standard output. It replaces the temporary schema name by a 60 | substitution pattern named ``%(schema_name)``. You can store this result for 61 | later use. Runing ``schematemplate`` command on each deployment is a good 62 | idea. 63 | 64 | New: A function called ``new_schema_from_template`` (in ``appschema.models``) 65 | performs the schema creation based on this template file. 66 | 67 | Alternative: clone_schema stored procedure 68 | ------------------------------------------ 69 | 70 | Appschema provides a PostgreSQL (version 8.4 only) stored procedure called 71 | ``clone_schema(source, destination)`` (In contrib directory). It makes a copy 72 | of ``source`` schema on ``destination``. Create a master schema and you could 73 | use it as a source for ``clone_schema``. As this procedure is still a work in 74 | progress, you may prefer using the ``schematemplate`` way. 75 | 76 | Please note 77 | =========== 78 | 79 | This **highly experimental** app works **only for PostgreSQL**. 80 | 81 | You'll find a FqdnMiddleware which switchs schema based on the host name of 82 | the HTTP request. Feel free to make your own based on your needs. 83 | 84 | If you find bugs (and I'm sure you will), please report them. 85 | 86 | It wasn't test with multiple databases support and I'm not sure it works in 87 | such case. 88 | 89 | Be careful with foreign keys. As you can make any foreign key you want in 90 | isolated app models referencing shared one, the opposite is not true. 91 | 92 | License 93 | ======= 94 | 95 | Django appschema is released under the MIT license. See the LICENSE 96 | file for the complete license. 97 | -------------------------------------------------------------------------------- /appschema/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from django.conf import settings 6 | 7 | from .version import __version__ 8 | 9 | if not hasattr(settings, 'APPSCHEMA_SHARED_APPS'): 10 | settings.APPSCHEMA_SHARED_APPS = () 11 | 12 | if not hasattr(settings, 'APPSCHEMA_DEFAULT_PATH'): 13 | settings.APPSCHEMA_DEFAULT_PATH = ['public'] 14 | 15 | if not hasattr(settings, 'APPSCHEMA_BOTH_APPS'): 16 | settings.APPSCHEMA_BOTH_APPS = () 17 | 18 | 19 | def syncdb(): 20 | """ Returns good syncdb module based on installed apps """ 21 | if 'south' in settings.INSTALLED_APPS: 22 | module = 'south.management.commands' 23 | else: 24 | module = 'django.core.management.commands' 25 | 26 | module = __import__(module + '.syncdb', {}, {}, ['']) 27 | 28 | return module 29 | 30 | 31 | def migrate(): 32 | """ Returns South migrate command if South is installed """ 33 | if not 'south' in settings.INSTALLED_APPS: 34 | return None 35 | 36 | module = __import__('south.management.commands', globals(), locals(), ['migrate'], -1) 37 | return module.migrate 38 | -------------------------------------------------------------------------------- /appschema/db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from multiprocessing import Process 6 | 7 | from django import db 8 | 9 | import appschema 10 | syncdb = appschema.syncdb() 11 | migrate = appschema.migrate() 12 | 13 | from appschema.schema import schema_store 14 | from appschema.utils import get_apps, load_post_syncdb_signals, run_with_apps 15 | 16 | 17 | def _syncdb_apps(apps, schema=None, force_close=True, **options): 18 | """ 19 | This function simply call syncdb command (Django or South one) for 20 | select apps only. 21 | """ 22 | def wrapper(_apps, *args, **kwargs): 23 | load_post_syncdb_signals() 24 | return syncdb.Command().execute(**kwargs) 25 | 26 | # Force connection close 27 | if force_close: 28 | db.connection.close() 29 | db.connection.connection = None 30 | 31 | # Force default DB if not specified 32 | options['database'] = options.get('database', db.DEFAULT_DB_ALIAS) 33 | 34 | # Syncdb without schema (on public) 35 | if not schema: 36 | schema_store.reset_path() 37 | return run_with_apps(apps, wrapper, **options) 38 | 39 | # Syncdb with schema 40 | # 41 | # We first handle the case of apps that are both shared and isolated. 42 | # As this tables are already present in public schema, we can't sync 43 | # with a search_path ,public 44 | 45 | shared_apps, _ = get_apps() 46 | both_apps = [x for x in apps if x in shared_apps] 47 | shared_apps = [x for x in apps if x not in both_apps] 48 | 49 | schema_store.schema = schema 50 | 51 | if 'south' in apps: 52 | try: 53 | schema_store.force_path() 54 | run_with_apps(both_apps, wrapper, **options) 55 | except ValueError: 56 | pass 57 | 58 | try: 59 | # For other apps, we work with seach_path ,public to 60 | # properly handle cross schema foreign keys. 61 | schema_store.set_path() 62 | run_with_apps(shared_apps, wrapper, **options) 63 | finally: 64 | schema_store.clear() 65 | 66 | 67 | def _migrate_apps(apps, schema=None, force_close=True, **options): 68 | def wrapper(_apps, *args, **kwargs): 69 | load_post_syncdb_signals() 70 | for _app in _apps: 71 | migrate.Command().execute(_app, **kwargs) 72 | 73 | # Force connection close 74 | if force_close: 75 | db.connection.close() 76 | db.connection.connection = None 77 | 78 | # Force default DB if not specified 79 | options['database'] = options.get('database', db.DEFAULT_DB_ALIAS) 80 | 81 | # Migrate without schema (on public) 82 | if not schema: 83 | schema_store.reset_path() 84 | run_with_apps(apps, wrapper, **options) 85 | return 86 | 87 | # Migrate with schema 88 | schema_store.schema = schema 89 | 90 | if len(db.connections.databases) > 1: 91 | raise Exception('Appschema doest not support multi databases (yet?)') 92 | 93 | try: 94 | # For other apps, we work with seach_path ,public to 95 | # properly handle cross schema foreign keys. 96 | schema_store.set_path() 97 | 98 | # South sometimes needs a schema settings to be set and take it from 99 | # Django db settings SCHEMA 100 | db.connection.settings_dict['SCHEMA'] = schema 101 | 102 | run_with_apps(apps, wrapper, **options) 103 | finally: 104 | schema_store.clear() 105 | if 'SCHEMA' in db.connection.settings_dict: 106 | del db.connection.settings_dict['SCHEMA'] 107 | 108 | 109 | def syncdb_apps(apps, schema=None, force_close=True, **options): 110 | p = Process(target=_syncdb_apps, args=(apps, schema, force_close), kwargs=options) 111 | p.start() 112 | p.join() 113 | p.terminate() 114 | if p.exitcode != 0: 115 | raise RuntimeError('Unexpected end of subprocess') 116 | 117 | 118 | def migrate_apps(apps, schema=None, force_close=True, **options): 119 | p = Process(target=_migrate_apps, args=(apps, schema, force_close), kwargs=options) 120 | p.start() 121 | p.join() 122 | if p.exitcode != 0: 123 | raise RuntimeError('Unexpected end of subprocess') 124 | -------------------------------------------------------------------------------- /appschema/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivier-m/django-appschema/0b9ab348d8b3af2ec253fcd43efbd7b0441a672b/appschema/management/__init__.py -------------------------------------------------------------------------------- /appschema/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivier-m/django-appschema/0b9ab348d8b3af2ec253fcd43efbd7b0441a672b/appschema/management/commands/__init__.py -------------------------------------------------------------------------------- /appschema/management/commands/createschema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from optparse import make_option 6 | 7 | from django.core.management.base import BaseCommand, CommandError 8 | from django.db import DEFAULT_DB_ALIAS 9 | 10 | from appschema.models import new_schema 11 | 12 | 13 | class Command(BaseCommand): 14 | help = 'Creates a new active schema' 15 | option_list = BaseCommand.option_list + ( 16 | make_option('--database', action='store', dest='database', 17 | default=DEFAULT_DB_ALIAS, help='Nominates a database to create schema on. ' 18 | 'Defaults to the "default" database.'), 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | if len(args) < 2: 23 | raise CommandError('You should specify a name and a public name') 24 | 25 | name, public_name = args[0:2] 26 | 27 | try: 28 | new_schema(name, public_name, True, **options) 29 | except Exception, e: 30 | raise 31 | raise CommandError(str(e)) 32 | -------------------------------------------------------------------------------- /appschema/management/commands/loadschemadata.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from optparse import make_option 6 | 7 | from django.core.management.base import BaseCommand, CommandError 8 | from django.core.management.commands import loaddata 9 | 10 | from appschema.models import Schema 11 | from appschema.schema import schema_store 12 | 13 | 14 | class Command(BaseCommand): 15 | option_list = loaddata.Command.option_list + ( 16 | make_option('--schema', action='store', dest='schema', 17 | default=None, help='Nominates a specific schema to load ' 18 | 'fixtures into. Defaults to all schemas.'), 19 | ) 20 | help = "Fixture loader with schema support" 21 | args = loaddata.Command.args 22 | 23 | def handle(self, *fixture_labels, **options): 24 | verbosity = int(options.get('verbosity', 0)) 25 | schema = options.get('schema', None) 26 | 27 | if schema: 28 | try: 29 | schema_list = [Schema.objects.get(name=schema)] 30 | except Schema.DoesNotExist: 31 | raise CommandError('Schema "%s" does not exist.' % schema) 32 | else: 33 | schema_list = Schema.objects.active() 34 | 35 | for _s in schema_list: 36 | if verbosity: 37 | print "---------------------------" 38 | print "Loading fixtures in schema: %s" % _s.name 39 | print "---------------------------\n" 40 | 41 | schema_store.schema = _s.name 42 | schema_store.set_path() 43 | loaddata.Command().execute(*fixture_labels, **options) 44 | -------------------------------------------------------------------------------- /appschema/management/commands/migrate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from django.conf import settings 7 | from django.core.management.base import BaseCommand, CommandError 8 | from django.db import DEFAULT_DB_ALIAS 9 | 10 | import appschema 11 | migrate = appschema.migrate() 12 | 13 | from appschema.db import migrate_apps 14 | from appschema.models import Schema 15 | from appschema.south_utils import get_migration_candidates 16 | from appschema.utils import get_apps, load_post_syncdb_signals 17 | 18 | 19 | class Command(BaseCommand): 20 | option_list = migrate.Command.option_list 21 | help = migrate.Command.help 22 | args = migrate.Command.args 23 | 24 | def handle(self, app=None, target=None, skip=False, merge=False, backwards=False, fake=False, db_dry_run=False, show_list=False, show_changes=False, database=DEFAULT_DB_ALIAS, delete_ghosts=False, ignore_ghosts=False, **options): 25 | if not 'south' in settings.INSTALLED_APPS: 26 | raise CommandError('South is not installed.') 27 | 28 | verbosity = int(options.get('verbosity', 0)) 29 | shared, isolated = get_apps() 30 | 31 | if options.get('all_apps', False): 32 | target = app 33 | app = None 34 | 35 | # Migrate each app 36 | if app: 37 | apps = [app] 38 | else: 39 | apps = settings.INSTALLED_APPS 40 | 41 | options.update({ 42 | 'target': target, 43 | 'skip': skip, 44 | 'merge': merge, 45 | 'backwards': backwards, 46 | 'fake': fake, 47 | 'db_dry_run': db_dry_run, 48 | 'show_list': show_list, 49 | 'database': database, 50 | 'delete_ghosts': delete_ghosts, 51 | 'ignore_ghosts': ignore_ghosts 52 | }) 53 | 54 | shared_apps = [x for x in get_migration_candidates(shared) if x in apps] 55 | isolated_apps = [x for x in get_migration_candidates(isolated) if x in apps] 56 | 57 | try: 58 | if len(shared_apps) > 0: 59 | if verbosity: 60 | print "---------------------" 61 | print "SHARED APPS migration" 62 | print "---------------------\n" 63 | 64 | migrate_apps(shared_apps, schema=None, **options) 65 | 66 | if len(isolated_apps) == 0: 67 | return 68 | 69 | schema_list = [x.name for x in Schema.objects.active()] 70 | for schema in schema_list: 71 | if verbosity: 72 | print "\n----------------------------------" 73 | print "ISOLATED APPS migration on schema: %s" % schema 74 | print "----------------------------------\n" 75 | migrate_apps(isolated_apps, schema=schema, **options) 76 | finally: 77 | load_post_syncdb_signals() 78 | -------------------------------------------------------------------------------- /appschema/management/commands/schema_exec.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | import sys 4 | 5 | from django.db import connections, DEFAULT_DB_ALIAS 6 | 7 | from appschema.models import Schema 8 | from appschema.schema import schema_store 9 | 10 | from django.core.management.base import BaseCommand, CommandError 11 | 12 | 13 | class Command(BaseCommand): 14 | help = 'Execute given SQL file on every schema' 15 | args = '' 16 | 17 | def handle(self, *args, **kwargs): 18 | if len(args) == 0: 19 | raise CommandError('No SQL file specified') 20 | 21 | filename = args[0] 22 | if not os.path.exists(filename): 23 | raise CommandError('File "%s" does not exist.' % filename) 24 | 25 | with open(filename, 'rb') as fp: 26 | sql = fp.read() 27 | 28 | for instance in Schema.objects.all(): 29 | schema_store.reset_path() 30 | schema_store.schema = instance.name 31 | schema_store.force_path() 32 | 33 | sys.stdout.write('%s ... ' % instance.name) 34 | try: 35 | cur = connections[DEFAULT_DB_ALIAS].cursor() 36 | cur.execute(sql) 37 | cur.close() 38 | sys.stdout.write(self.style.SQL_COLTYPE('✓')) 39 | sys.stdout.write('\n') 40 | except: 41 | sys.stdout.write(self.style.NOTICE('✗')) 42 | sys.stdout.write('\n') 43 | raise 44 | -------------------------------------------------------------------------------- /appschema/management/commands/schematemplate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from optparse import make_option 6 | import os 7 | import re 8 | from subprocess import Popen, PIPE 9 | 10 | from django.conf import settings 11 | from django.core.management.base import BaseCommand 12 | 13 | import appschema 14 | from appschema.models import create_schema, drop_schema 15 | 16 | syncdb = appschema.syncdb() 17 | 18 | 19 | class Command(BaseCommand): 20 | option_list = BaseCommand.option_list + ( 21 | make_option('--pgdump', action='store', dest='pgdump', 22 | default='pg_dump', help='Path to pg_dump command'), 23 | make_option('--rawsql', action='store_true', dest='raw', 24 | default=False, help='Output non formatted schema dump'), 25 | make_option('--aspython', action='store_true', dest='as_python', 26 | default=False, help='Output dump in a python string.'), 27 | make_option('--tempname', action='store', dest='schema_name', 28 | default='__master__', help='Name of temporary schema') 29 | ) 30 | 31 | option_list += tuple([x for x in syncdb.Command.option_list if x.dest == "database"]) 32 | 33 | help = "Dumps the whole base schema creation and gives result on stdout." 34 | 35 | def handle(self, *fixture_labels, **options): 36 | pg_dump = options.get('pgdump') 37 | raw = options.get('raw', False) 38 | as_python = options.get('as_python', False) 39 | schema_name = options.get('schema_name', '__master__') 40 | 41 | create_schema(schema_name, **{ 42 | 'verbosity': 0, 43 | 'database': options.get("database") 44 | }) 45 | 46 | try: 47 | cmd = [pg_dump, 48 | '-n', schema_name, 49 | '--no-owner', 50 | '--inserts' 51 | ] 52 | 53 | if settings.DATABASES['default']['USER']: 54 | cmd.extend(['-U', settings.DATABASES['default']['USER']]) 55 | 56 | if settings.DATABASES['default']['HOST']: 57 | cmd.extend(['-h', settings.DATABASES['default']['HOST']]) 58 | 59 | cmd.append(settings.DATABASES['default']['NAME']) 60 | env = dict(os.environ) 61 | env["PGPASSWORD"] = settings.DATABASES['default']['PASSWORD'] 62 | 63 | pf = Popen(cmd, env=env, stdout=PIPE) 64 | dump = pf.communicate()[0] 65 | finally: 66 | drop_schema(schema_name) 67 | 68 | re_comments = re.compile(r'^--.*\n', re.M) 69 | re_duplines = re.compile(r'^\n\n+', re.M) 70 | 71 | # Adding string template schema_name 72 | if not raw or as_python: 73 | dump = dump.replace('%', '%%') 74 | dump = dump.replace(schema_name, '%(schema_name)s') 75 | 76 | # A bit nicer to read 77 | dump = re_comments.sub('', dump) 78 | dump = re_duplines.sub('\n', dump) 79 | 80 | if as_python: 81 | dump = '# -*- coding: utf-8 -*-\nschema_sql = """%s"""' % dump 82 | 83 | print dump 84 | -------------------------------------------------------------------------------- /appschema/management/commands/syncdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from django.core import management 7 | from django.core.management.base import NoArgsCommand 8 | from django import db 9 | 10 | import appschema 11 | syncdb = appschema.syncdb() 12 | 13 | from appschema.db import syncdb_apps 14 | from appschema.models import Schema 15 | from appschema.utils import get_apps, load_post_syncdb_signals 16 | 17 | 18 | class Command(NoArgsCommand): 19 | option_list = syncdb.Command.option_list 20 | help = syncdb.Command.help 21 | 22 | def handle_noargs(self, **options): 23 | verbosity = int(options.get('verbosity', 0)) 24 | migrate = options.get('migrate', False) 25 | options['migrate'] = False 26 | 27 | shared_apps, isolated_apps = get_apps() 28 | 29 | try: 30 | if len(shared_apps) > 0: 31 | if verbosity: 32 | print "------------------" 33 | print "SHARED APPS syncdb" 34 | print "------------------\n" 35 | 36 | syncdb_apps(shared_apps, schema=None, **options) 37 | 38 | if len(isolated_apps) == 0: 39 | return 40 | 41 | schema_list = [x.name for x in Schema.objects.active()] 42 | for schema in schema_list: 43 | if verbosity: 44 | print "\n-------------------------------" 45 | print "ISOLATED APPS syncdb on schema: %s" % schema 46 | print "-------------------------------\n" 47 | 48 | syncdb_apps(isolated_apps, schema=schema, **options) 49 | finally: 50 | load_post_syncdb_signals() 51 | 52 | if migrate: 53 | db.connection.close() 54 | db.connection.connection = None 55 | management.call_command("migrate") 56 | -------------------------------------------------------------------------------- /appschema/middleware.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from django.conf import settings 7 | from django.core.exceptions import SuspiciousOperation 8 | from django.http import Http404, HttpResponseRedirect 9 | 10 | from appschema.schema import schema_store 11 | from appschema.models import Schema 12 | 13 | 14 | class NoSchemaError(Http404): 15 | pass 16 | 17 | 18 | class FqdnMiddleware(object): 19 | """ 20 | This Middleware sets schema based on FQDN. 21 | FQDN should be the public_name in your schema table. 22 | """ 23 | def should_process(self, request): 24 | if not settings.DEBUG: 25 | return True 26 | 27 | if getattr(settings, 'MEDIA_URL') and request.path.startswith(settings.MEDIA_URL): 28 | return False 29 | 30 | if request.path == '/favicon.ico': 31 | return False 32 | 33 | return True 34 | 35 | def get_schema_name(self, fqdn): 36 | try: 37 | schema = Schema.objects.get(public_name=fqdn, is_active=True) 38 | schema_store.schema = schema.name 39 | schema_store.set_path() 40 | return True 41 | except Schema.DoesNotExist: 42 | raise NoSchemaError() 43 | 44 | def process_request(self, request): 45 | if self.should_process(request): 46 | try: 47 | fqdn = request.get_host().split(':')[0] 48 | self.get_schema_name(fqdn) 49 | except (NoSchemaError, SuspiciousOperation): 50 | if hasattr(settings, 'APPSCHEMA_SCHEMA_REDIRECT'): 51 | return HttpResponseRedirect(settings.APPSCHEMA_SCHEMA_REDIRECT) 52 | raise 53 | 54 | def process_response(self, request, response): 55 | if self.should_process(request): 56 | schema_store.clear() 57 | 58 | return response 59 | 60 | def process_exception(self, request, exception): 61 | if self.should_process(request): 62 | schema_store.clear() 63 | -------------------------------------------------------------------------------- /appschema/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from datetime import datetime 7 | import imp 8 | 9 | from django.db import connection, models, transaction 10 | from django.db.utils import DatabaseError, IntegrityError 11 | 12 | from appschema.db import _syncdb_apps, _migrate_apps 13 | from appschema.schema import schema_store 14 | from appschema.south_utils import get_migration_candidates 15 | from appschema.utils import get_apps, escape_schema_name 16 | 17 | 18 | def new_schema(name, public_name, is_active=True, **options): 19 | """ 20 | This function adds a schema in schema model and creates physical schema. 21 | """ 22 | 23 | try: 24 | schema = Schema(name=name, public_name=public_name, is_active=is_active) 25 | schema.save() 26 | except IntegrityError: 27 | raise Exception('Schema already exists.') 28 | 29 | create_schema(name, **options) 30 | return schema 31 | 32 | 33 | def create_schema(name, **options): 34 | """ 35 | This function creates a schema and perform a syncdb on it. 36 | As we call some syncdb and migrate management commands, we can't rely on 37 | transaction support. 38 | We are going to catch any exception (including SystemExit). 39 | """ 40 | try: 41 | cursor = connection.cursor() 42 | # We can't use params with system names 43 | cursor.execute('CREATE SCHEMA "%s"' % escape_schema_name(name)) 44 | transaction.commit_unless_managed() 45 | except BaseException: 46 | transaction.rollback_unless_managed() 47 | raise 48 | 49 | try: 50 | defaults = { 51 | 'verbosity': 0, 52 | 'traceback': None, 53 | 'noinput': True 54 | } 55 | defaults.update(options) 56 | 57 | sync_options = options 58 | # We never want migration to launch with syncdb call 59 | sync_options['migrate'] = False 60 | 61 | _, isolated_apps = get_apps() 62 | 63 | _syncdb_apps(isolated_apps, schema=name, force_close=False, **sync_options) 64 | _migrate_apps(get_migration_candidates(isolated_apps), schema=name, force_close=False, **options) 65 | schema_store.reset_path() 66 | except BaseException: 67 | transaction.rollback_unless_managed() 68 | drop_schema(name) 69 | raise 70 | 71 | 72 | def new_schema_from_template(name, public_name, template_file, is_active=True): 73 | try: 74 | schema = Schema(name=name, public_name=public_name, is_active=is_active) 75 | schema.save() 76 | except IntegrityError: 77 | raise Exception('Schema already exists.') 78 | 79 | create_schema_from_template(name, template_file) 80 | 81 | 82 | @transaction.commit_manually 83 | def create_schema_from_template(name, template_file): 84 | """ 85 | This function creates a new schema based on a template file. This template 86 | should be generated by ``schematemplate`` command (with --aspython option) 87 | """ 88 | sql = imp.load_source('schema_module', template_file).schema_sql 89 | sql = sql % {'schema_name': '"%s"' % escape_schema_name(name)} 90 | 91 | cursor = connection.cursor() 92 | try: 93 | # Systems behave differently with cursor.execute() 94 | try: 95 | cursor.execute(sql) 96 | except IndexError: 97 | cursor.execute(sql.replace('%', '%%')) 98 | transaction.commit() 99 | except: 100 | transaction.rollback() 101 | raise 102 | 103 | 104 | @transaction.commit_manually 105 | def drop_schema(name): 106 | Schema.objects.filter(name=name).delete() 107 | 108 | cursor = connection.cursor() 109 | try: 110 | # We can't use params with system names 111 | cursor.execute('DROP SCHEMA "%s" CASCADE' % escape_schema_name(name)) 112 | transaction.commit() 113 | except DatabaseError: 114 | transaction.rollback() 115 | except: 116 | transaction.rollback() 117 | raise 118 | 119 | 120 | class SchemaManager(models.Manager): 121 | def active(self): 122 | return self.filter(is_active=True) 123 | 124 | 125 | class Schema(models.Model): 126 | created = models.DateTimeField(default=datetime.now) 127 | name = models.CharField(max_length=64) 128 | public_name = models.CharField(max_length=255, db_index=True) 129 | is_active = models.BooleanField(default=True, db_index=True) 130 | 131 | objects = SchemaManager() 132 | 133 | class Meta: 134 | unique_together = ('name', 'public_name') 135 | 136 | def __unicode__(self): 137 | return u'%s (%s)' % (self.name, self.public_name) 138 | -------------------------------------------------------------------------------- /appschema/schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from threading import local 7 | 8 | from django.conf import settings 9 | from django.db import connection 10 | from django.db.backends import signals 11 | 12 | __all__ = ('schema_store',) 13 | 14 | 15 | def get_path(*args): 16 | return list(args) + list(settings.APPSCHEMA_DEFAULT_PATH) 17 | 18 | 19 | class SchemaStore(local): 20 | """ 21 | A simple thread safe store that will set search_path when asked for. 22 | """ 23 | def __init__(self): 24 | self.clear() 25 | 26 | def clear(self): 27 | self._schema = None 28 | 29 | def get_schema(self): 30 | return self._schema 31 | 32 | def set_schema(self, value): 33 | self._schema = value 34 | 35 | schema = property(get_schema, set_schema) 36 | 37 | def set_path(self, cursor=None): 38 | cursor = cursor or connection.cursor() 39 | args = self.schema and [self.schema] or [] 40 | path = get_path(*args) 41 | pattern = ','.join(['%s'] * len(path)) 42 | 43 | cursor.execute('SET search_path = %s' % pattern, path) 44 | 45 | def force_path(self, cursor=None): 46 | cursor = cursor or connection.cursor() 47 | cursor.execute('SET search_path = %s', [self.schema]) 48 | 49 | def reset_path(self, cursor=None): 50 | self.clear() 51 | self.set_path(cursor) 52 | 53 | 54 | schema_store = SchemaStore() 55 | 56 | 57 | # On every connection, we set the schema in store 58 | def set_schema(sender, **kwargs): 59 | schema_store.set_path() 60 | 61 | signals.connection_created.connect(set_schema) 62 | -------------------------------------------------------------------------------- /appschema/south_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | from django.core.exceptions import ImproperlyConfigured 7 | 8 | try: 9 | from south.exceptions import NoMigrations 10 | from south.migration import Migrations 11 | south_ok = True 12 | except ImportError: 13 | south_ok = False 14 | 15 | 16 | def get_migration_candidates(apps): 17 | """ 18 | This function returns only apps that could be migrated. 19 | """ 20 | res = [] 21 | if not south_ok: 22 | return res 23 | 24 | for app in apps: 25 | try: 26 | Migrations(app) 27 | res.append(app) 28 | except (NoMigrations, ImproperlyConfigured): 29 | pass 30 | 31 | return res 32 | -------------------------------------------------------------------------------- /appschema/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | 6 | import sys 7 | 8 | from django.conf import settings 9 | from django.db.models.loading import cache 10 | from django.db.models.signals import post_syncdb 11 | from django.utils.datastructures import SortedDict 12 | 13 | 14 | def escape_schema_name(name): 15 | """ Escape system names for PostgreSQL. Should do the trick. """ 16 | return name.replace('"', '""') 17 | 18 | 19 | def get_apps(): 20 | """ 21 | Returns a tupple of shared and isolated apps. If south is in 22 | INSTALLED_APPS, if will always be in both lists. 23 | 24 | Obviously, appschema cannot be in isolated apps. 25 | """ 26 | all_apps = list(settings.INSTALLED_APPS) 27 | shared_apps = list(settings.APPSCHEMA_SHARED_APPS) 28 | both_apps = list(settings.APPSCHEMA_BOTH_APPS) 29 | 30 | # South models should be shared and isolated 31 | if 'south' in all_apps and 'south' not in both_apps: 32 | both_apps.append('south') 33 | 34 | # Appschema cannot be isolated 35 | if 'appschema' not in shared_apps: 36 | shared_apps.append('appschema') 37 | 38 | #isolated = [app for app in all_apps if app not in shared_apps] 39 | 40 | isolated = [x for x in all_apps if x not in shared_apps or x in both_apps] 41 | shared = [x for x in all_apps if x in shared_apps or x in both_apps] 42 | 43 | return shared, isolated 44 | 45 | 46 | def load_post_syncdb_signals(): 47 | """ 48 | This is a duplicate from Django syncdb management command. 49 | 50 | This code imports any module named 'management' in INSTALLED_APPS. 51 | The 'management' module is the preferred way of listening to post_syncdb 52 | signals. 53 | """ 54 | unload_post_syncdb_signals() 55 | 56 | # If south is available, we should reload it's post_migrate signal. 57 | try: 58 | import south.signals 59 | south.signals.post_migrate.receivers = [] 60 | #reload(south.signals) 61 | except ImportError: 62 | pass 63 | 64 | for app_name in settings.INSTALLED_APPS: 65 | try: 66 | module = app_name + '.management' 67 | 68 | # As we first unload signals, we need to reload module 69 | # if present in modules cache. That will reload signals. 70 | if sys.modules.get(module): 71 | reload(sys.modules[module]) 72 | else: 73 | __import__(module, {}, {}, ['']) 74 | 75 | except ImportError, exc: 76 | msg = exc.args[0] 77 | if not msg.startswith('No module named') or 'management' not in msg: 78 | raise 79 | 80 | 81 | def unload_post_syncdb_signals(): 82 | """ 83 | This function disconnects ALL post_syncdb signals. This is needed by 84 | some tricky migration and syncdb behaviors and when you isolate apps 85 | like contenttypes or auth (which is often the case). 86 | """ 87 | post_syncdb.receivers = [] 88 | 89 | 90 | def get_app_label(app): 91 | """ Returns app label as Django make it. """ 92 | return '.'.join(app.__name__.split('.')[0:-1]) 93 | 94 | 95 | def run_with_apps(apps, func, *args, **kwargs): 96 | """ 97 | This function is a wrapper to any function that will change INSTALLED_APPS 98 | and Django cache.app_store. 99 | Both variables are reset after function execution. 100 | """ 101 | old_installed, settings.INSTALLED_APPS = settings.INSTALLED_APPS, apps 102 | 103 | old_app_store, cache.app_store = cache.app_store, SortedDict([ 104 | (k, v) for (k, v) in cache.app_store.items() 105 | if get_app_label(k) in apps 106 | ]) 107 | 108 | try: 109 | return func(apps, *args, **kwargs) 110 | finally: 111 | settings.INSTALLED_APPS = old_installed 112 | cache.app_store = old_app_store 113 | -------------------------------------------------------------------------------- /appschema/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | __version__ = '0.6.2' 6 | -------------------------------------------------------------------------------- /contrib/clone_schema.sql: -------------------------------------------------------------------------------- 1 | -- Create plpgsql language if not exists 2 | -- See http://wiki.postgresql.org/wiki/CREATE_OR_REPLACE_LANGUAGE 3 | CREATE OR REPLACE FUNCTION make_plpgsql() RETURNS VOID AS $body$ 4 | CREATE LANGUAGE plpgsql; 5 | $body$ LANGUAGE SQL; 6 | 7 | SELECT 8 | CASE 9 | WHEN EXISTS( 10 | SELECT 1 11 | FROM pg_catalog.pg_language 12 | WHERE lanname='plpgsql' 13 | ) 14 | THEN NULL 15 | ELSE make_plpgsql() 16 | END; 17 | 18 | DROP FUNCTION make_plpgsql(); 19 | 20 | -- 21 | -- Function to copy a schema to another 22 | -- 23 | CREATE OR REPLACE FUNCTION public.clone_schema(text, text) RETURNS integer AS $body$ 24 | DECLARE 25 | i_src ALIAS FOR $1; 26 | i_dst ALIAS FOR $2; 27 | v_table information_schema.tables.table_type%TYPE; 28 | v_fk record; 29 | v_seq record; 30 | v_seq_field record; 31 | v_func text; 32 | v_view record; 33 | 34 | v_sql text; 35 | BEGIN 36 | -- Creatin schema 37 | EXECUTE('CREATE SCHEMA "' || i_dst || '";'); 38 | 39 | -- Copying tables (with data) 40 | FOR v_table IN SELECT table_name 41 | FROM information_schema.tables 42 | WHERE table_type = 'BASE TABLE' 43 | AND table_schema = i_src 44 | ORDER BY table_name 45 | LOOP 46 | EXECUTE( 47 | 'CREATE TABLE ' || i_dst || '.' || v_table 48 | || '( LIKE ' || i_src || '.' || v_table 49 | || ' INCLUDING DEFAULTS ' 50 | || ' INCLUDING CONSTRAINTS' 51 | || ' INCLUDING INDEXES' 52 | || ')' 53 | ); 54 | EXECUTE( 55 | 'INSERT INTO ' || i_dst || '.' || v_table 56 | || ' (SELECT * FROM '|| i_src || '.' || v_table || ')' 57 | ); 58 | END LOOP; 59 | 60 | -- Copying sequences 61 | FOR v_seq IN SELECT c.oid, c.relname 62 | FROM pg_catalog.pg_class c 63 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace 64 | WHERE n.nspname = 'local' 65 | AND c.relkind = 'S' 66 | LOOP 67 | -- There is not way to obtain sequence definition then we simply 68 | -- create a new one. That's a bit lame. 69 | EXECUTE('CREATE SEQUENCE ' || i_dst || '.' || v_seq.relname); 70 | 71 | -- We need to alter every field that uses source schema sequences 72 | -- to use newly created ones 73 | FOR v_seq_field IN SELECT DISTINCT cl.relname, att.attname 74 | FROM pg_depend dep 75 | LEFT JOIN pg_class cl ON dep.refobjid=cl.oid 76 | LEFT JOIN pg_attribute att ON dep.refobjid=att.attrelid AND dep.refobjsubid=att.attnum 77 | WHERE dep.objid=v_seq.oid 78 | AND cl.relkind = 'r' 79 | LOOP 80 | EXECUTE('ALTER TABLE ' || i_dst || '.' || v_seq_field.relname 81 | || ' ALTER COLUMN ' || v_seq_field.attname 82 | || ' SET DEFAULT nextval(''' || i_dst || '.' || v_seq.relname || ''')'); 83 | EXECUTE('ALTER SEQUENCE ' || i_dst || '.' || v_seq.relname 84 | || ' OWNED BY ' || i_dst || '.' || v_seq_field.relname || '.' || v_seq_field.attname); 85 | END LOOP; 86 | 87 | END LOOP; 88 | 89 | -- Copying foreign keys 90 | FOR v_fk IN SELECT c.relname as tablename, conname, 91 | pg_catalog.pg_get_constraintdef(r.oid, true) as condef 92 | FROM pg_catalog.pg_constraint r 93 | INNER JOIN pg_catalog.pg_namespace n ON n.oid = r.connamespace 94 | INNER JOIN pg_catalog.pg_class c ON c.oid = r.conrelid 95 | WHERE r.contype = 'f' 96 | AND n.nspname = i_src 97 | LOOP 98 | v_fk.condef := replace(v_fk.condef, i_src || '.', i_dst || '.'); 99 | EXECUTE( 100 | 'ALTER TABLE ' || i_dst || '.' || v_fk.tablename 101 | || ' ADD CONSTRAINT ' || v_fk.conname || ' ' || v_fk.condef 102 | ); 103 | END LOOP; 104 | 105 | -- Copying functions 106 | FOR v_func IN SELECT pg_catalog.pg_get_functiondef(p.oid) 107 | FROM pg_catalog.pg_proc p 108 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace 109 | WHERE n.nspname = i_src 110 | LOOP 111 | v_func := replace(v_func, i_src || '.', i_dst || '.'); 112 | EXECUTE(v_func); 113 | END LOOP; 114 | 115 | -- Copying views 116 | -- TODO: handle custom column names in views 117 | FOR v_view IN SELECT c.relname, pg_catalog.pg_get_viewdef(c.oid) as viewdef 118 | FROM pg_catalog.pg_class c 119 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace 120 | WHERE n.nspname = 'local' 121 | AND c.relkind IN ('v', 's') 122 | LOOP 123 | v_view.viewdef := replace(v_view.viewdef, i_src || '.', i_dst || '.'); 124 | EXECUTE('CREATE VIEW ' || i_dst || '.' || v_view.relname || ' AS ' || v_view.viewdef); 125 | END LOOP; 126 | 127 | RETURN 1; 128 | END; 129 | $body$ LANGUAGE 'plpgsql'; 130 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # This file is part of Django appschema released under the MIT license. 4 | # See the LICENSE for more information. 5 | from setuptools import setup, find_packages 6 | 7 | execfile('appschema/version.py') 8 | 9 | packages = find_packages(exclude=['*.tests']) 10 | 11 | def readme(): 12 | with open('README.rst', 'r') as fp: 13 | return fp.read() 14 | 15 | 16 | setup( 17 | name='appschema', 18 | version=__version__, 19 | description='SaaS helper that isolates django apps in schemas.', 20 | long_description=readme(), 21 | author='Olivier Meunier', 22 | author_email='olivier@neokraft.net', 23 | url='https://github.com/olivier-m/django-appschema', 24 | license='MIT License', 25 | keywords='django database schema postgresql', 26 | install_requires=[ 27 | 'django>=1.4', 28 | ], 29 | packages=packages, 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Environment :: Web Environment', 33 | 'Framework :: Django', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: MIT License', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Topic :: Utilities' 39 | ], 40 | ) 41 | --------------------------------------------------------------------------------