def setUpClass(cls): super(SharedAuthTest, cls).setUpClass() settings.SHARED_APPS = ( 'wagtailtenant', 'django.contrib.auth', 'django.contrib.contenttypes', ) settings.TENANT_APPS = ('dts_test_app', ) settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS cls.sync_shared() Tenant(domain_url='test.com', schema_name=get_public_schema_name()).save( verbosity=cls.get_verbosity()) # Create a tenant cls.tenant = Tenant(domain_url='tenant.test.com', schema_name='tenant') cls.tenant.save(verbosity=cls.get_verbosity()) # Create some users with schema_context(get_public_schema_name( )): # this could actually also be executed inside a tenant cls.user1 = User(username='******', email="*****@*****.**") cls.user1.save() cls.user2 = User(username='******', email="*****@*****.**") cls.user2.save() # Create instances on the tenant that point to the users on public with tenant_context(cls.tenant): cls.d1 = ModelWithFkToPublicUser(user=cls.user1) cls.d1.save() cls.d2 = ModelWithFkToPublicUser(user=cls.user2) cls.d2.save()
def set_schema_to_public(self): """ Instructs to stay in the common 'public' schema. """ self.tenant = FakeTenant(schema_name=get_public_schema_name()) self.schema_name = get_public_schema_name() self.set_settings_schema(self.schema_name) self.search_path_set = False
def handle(self, *args, **options): self.sync_tenant = options.get('tenant') self.sync_public = options.get('shared') self.schema_name = options.get('schema_name') self.executor = options.get('executor') self.installed_apps = settings.INSTALLED_APPS self.args = args self.options = options if self.schema_name: if self.sync_public: raise CommandError( "schema should only be used with the --tenant switch.") elif self.schema_name == get_public_schema_name(): self.sync_public = True else: self.sync_tenant = True elif not self.sync_public and not self.sync_tenant: # no options set, sync both self.sync_tenant = True self.sync_public = True if hasattr(settings, 'TENANT_APPS'): self.tenant_apps = settings.TENANT_APPS if hasattr(settings, 'SHARED_APPS'): self.shared_apps = settings.SHARED_APPS
def test_command(self): """ Tests that tenant_command is capable of wrapping commands and its parameters. """ settings.SHARED_APPS = ( 'wagtailtenant', 'django.contrib.contenttypes', ) settings.TENANT_APPS = () settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS self.sync_shared() Tenant( domain_url='localhost', schema_name='public').save(verbosity=BaseTestCase.get_verbosity()) out = StringIO() call_command('tenant_command', args=('dumpdata', 'wagtailtenant'), natural_foreign=True, schema_name=get_public_schema_name(), stdout=out) self.assertEqual( json.loads( '[{"fields": {"domain_url": "localhost", "schema_name": "public"}, ' '"model": "wagtailtenant.tenant", "pk": 1}]'), json.loads(out.getvalue()))
def allow_migrate(self, db, app_label, model_name=None, **hints): # the imports below need to be done here else django <1.5 goes crazy # https://code.djangoproject.com/ticket/20704 from django.db import connection from wagtail.wagtailtenant.utils import get_public_schema_name, app_labels from wagtail.wagtailtenant.postgresql_backend.base import DatabaseWrapper as TenantDbWrapper db_engine = settings.DATABASES[db]['ENGINE'] if not (db_engine == 'wagtailtenant.postgresql_backend' or issubclass( getattr(load_backend(db_engine), 'DatabaseWrapper'), TenantDbWrapper)): return None if isinstance(app_label, ModelBase): # In django <1.7 the `app_label` parameter is actually `model` app_label = app_label._meta.app_label if connection.schema_name == get_public_schema_name(): if app_label not in app_labels(settings.SHARED_APPS): return False else: if app_label not in app_labels(settings.TENANT_APPS): return False return None
def test_tenant_apps_and_shared_apps_can_have_the_same_apps(self): """ Tests that both SHARED_APPS and TENANT_APPS can have apps in common. In this case they should get synced to both tenant and public schemas. """ settings.SHARED_APPS = ( 'wagtailtenant', # 2 tables 'django.contrib.auth', # 6 tables 'django.contrib.contenttypes', # 1 table 'django.contrib.sessions', ) # 1 table settings.TENANT_APPS = ('django.contrib.sessions', ) # 1 table settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS self.sync_shared() tenant = Tenant(domain_url='arbitrary.test.com', schema_name='test') tenant.save(verbosity=BaseTestCase.get_verbosity()) shared_tables = self.get_tables_list_in_schema( get_public_schema_name()) tenant_tables = self.get_tables_list_in_schema(tenant.schema_name) self.assertEqual(2 + 6 + 1 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) self.assertIn('django_session', shared_tables) self.assertEqual(1 + self.MIGRATION_TABLE_SIZE, len(tenant_tables)) self.assertIn('django_session', tenant_tables)
def run_migrations(self, tenants): public_schema_name = get_public_schema_name() if public_schema_name in tenants: run_migrations(self.args, self.options, self.codename, public_schema_name) tenants.pop(tenants.index(public_schema_name)) self.run_tenant_migrations(tenants)
def setUpClass(cls): super(RoutesTestCase, cls).setUpClass() settings.SHARED_APPS = ('wagtailtenant', ) settings.TENANT_APPS = ('dts_test_app', 'django.contrib.contenttypes', 'django.contrib.auth', ) settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS cls.sync_shared() cls.public_tenant = Tenant(domain_url='test.com', schema_name=get_public_schema_name()) cls.public_tenant.save(verbosity=BaseTestCase.get_verbosity())
def get_tenant(self, model, hostname, request): try: return super(DefaultTenantMiddleware, self).get_tenant(model, hostname, request) except model.DoesNotExist: schema_name = self.DEFAULT_SCHEMA_NAME if not schema_name: schema_name = get_public_schema_name() return model.objects.get(schema_name=schema_name)
def setUpClass(cls): settings.TENANT_MODEL = 'wagtailtenant.Tenant' settings.SHARED_APPS = ('wagtailtenant', ) settings.TENANT_APPS = ( 'dts_test_app', 'django.contrib.contenttypes', 'django.contrib.auth', ) settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS if '.test.com' not in settings.ALLOWED_HOSTS: settings.ALLOWED_HOSTS += ['.test.com'] # Django calls syncdb by default for the test database, but we want # a blank public schema for this set of tests. connection.set_schema_to_public() cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE; CREATE SCHEMA %s;' % (get_public_schema_name(), get_public_schema_name())) super(BaseTestCase, cls).setUpClass()
def _cursor(self, name=None): """ Here it happens. We hope every Django db operation using PostgreSQL must go through this to get the cursor handle. We change the path. """ if name: # Only supported and required by Django 1.11 (server-side cursor) cursor = super(DatabaseWrapper, self)._cursor(name=name) else: cursor = super(DatabaseWrapper, self)._cursor() # optionally limit the number of executions - under load, the execution # of `set search_path` can be quite time consuming if (not get_limit_set_calls()) or not self.search_path_set: # Actual search_path modification for the cursor. Database will # search schemata from left to right when looking for the object # (table, index, sequence, etc.). if not self.schema_name: raise ImproperlyConfigured("Database schema not set. Did you forget " "to call set_schema() or set_tenant()?") _check_schema_name(self.schema_name) public_schema_name = get_public_schema_name() search_paths = [] if self.schema_name == public_schema_name: search_paths = [public_schema_name] elif self.include_public_schema: search_paths = [self.schema_name, public_schema_name] else: search_paths = [self.schema_name] search_paths.extend(EXTRA_SEARCH_PATHS) if name: # Named cursor can only be used once cursor_for_search_path = self.connection.cursor() else: # Reuse cursor_for_search_path = cursor # In the event that an error already happened in this transaction and we are going # to rollback we should just ignore database error when setting the search_path # if the next instruction is not a rollback it will just fail also, so # we do not have to worry that it's not the good one try: cursor_for_search_path.execute('SET search_path = {0}'.format(','.join(search_paths))) except (django.db.utils.DatabaseError, psycopg2.InternalError): self.search_path_set = False else: self.search_path_set = True if name: cursor_for_search_path.close() return cursor
def handle(self, *args, **options): super(Command, self).handle(*args, **options) self.PUBLIC_SCHEMA_NAME = get_public_schema_name() executor = get_executor(codename=self.executor)(self.args, self.options) if self.sync_public and not self.schema_name: self.schema_name = self.PUBLIC_SCHEMA_NAME if self.sync_public: executor.run_migrations(tenants=[self.schema_name]) if self.sync_tenant: if self.schema_name and self.schema_name != self.PUBLIC_SCHEMA_NAME: if not schema_exists(self.schema_name): raise MigrationSchemaMissing( 'Schema "{}" does not exist'.format(self.schema_name)) else: tenants = [self.schema_name] else: tenants = get_tenant_model().objects.exclude( schema_name=get_public_schema_name()).values_list( 'schema_name', flat=True) executor.run_migrations(tenants=tenants) from wagtail.wagtailcustomers.models import Customer as customer if not customer.objects.filter( schema_name=get_public_schema_name()).exists(): customer(domain_url='tuiuiu.io', schema_name=get_public_schema_name(), name='tuiuiu.io', description='tuiuiu.io').save() from django.contrib.auth.models import User as user if not user.objects.filter(username='******').exists(): user.objects.create_superuser('admin', '*****@*****.**', 'admin')
def save(self, verbosity=1, *args, **kwargs): is_new = self.pk is None if is_new and connection.schema_name != get_public_schema_name(): raise Exception("Can't create tenant outside the public schema. " "Current schema is %s." % connection.schema_name) elif not is_new and connection.schema_name not in ( self.schema_name, get_public_schema_name()): raise Exception("Can't update tenant outside it's own schema or " "the public schema. Current schema is %s." % connection.schema_name) super(TenantMixin, self).save(*args, **kwargs) if is_new and self.auto_create_schema: try: self.create_schema(check_if_exists=True, verbosity=verbosity) except: # We failed creating the tenant, delete what we created and # re-raise the exception self.delete(force_drop=True) raise else: post_schema_sync.send(sender=TenantMixin, tenant=self)
def handle(self, *args, **options): """ Iterates a command over all registered schemata. """ if options['schema_name']: # only run on a particular schema connection.set_schema_to_public() self.execute_command( get_tenant_model().objects.get( schema_name=options['schema_name']), self.COMMAND_NAME, *args, **options) else: for tenant in get_tenant_model().objects.all(): if not (options['skip_public'] and tenant.schema_name == get_public_schema_name()): self.execute_command(tenant, self.COMMAND_NAME, *args, **options)
def delete(self, force_drop=False, *args, **kwargs): """ Deletes this row. Drops the tenant's schema if the attribute auto_drop_schema set to True. """ if connection.schema_name not in (self.schema_name, get_public_schema_name()): raise Exception("Can't delete tenant outside it's own schema or " "the public schema. Current schema is %s." % connection.schema_name) if schema_exists(self.schema_name) and (self.auto_drop_schema or force_drop): cursor = connection.cursor() cursor.execute('DROP SCHEMA IF EXISTS %s CASCADE' % self.schema_name) return super(TenantMixin, self).delete(*args, **kwargs)
def test_shared_apps_does_not_sync_tenant_apps(self): """ Tests that if an app is in SHARED_APPS, it does not get synced to the a tenant schema. """ settings.SHARED_APPS = ( 'wagtailtenant', # 2 tables 'django.contrib.auth', # 6 tables 'django.contrib.contenttypes', ) # 1 table settings.TENANT_APPS = ('django.contrib.sessions', ) settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS self.sync_shared() shared_tables = self.get_tables_list_in_schema( get_public_schema_name()) self.assertEqual(2 + 6 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) self.assertNotIn('django_session', shared_tables)
def test_content_types_is_not_mandatory(self): """ Tests that even if content types is in SHARED_APPS, it's not required in TENANT_APPS. """ settings.SHARED_APPS = ( 'wagtailtenant', # 2 tables 'django.contrib.contenttypes', ) # 1 table settings.TENANT_APPS = ('django.contrib.sessions', ) # 1 table settings.INSTALLED_APPS = settings.SHARED_APPS + settings.TENANT_APPS self.sync_shared() tenant = Tenant(domain_url='something.test.com', schema_name='test') tenant.save(verbosity=BaseTestCase.get_verbosity()) shared_tables = self.get_tables_list_in_schema( get_public_schema_name()) tenant_tables = self.get_tables_list_in_schema(tenant.schema_name) self.assertEqual(2 + 1 + self.MIGRATION_TABLE_SIZE, len(shared_tables)) self.assertIn('django_session', tenant_tables) self.assertEqual(1 + self.MIGRATION_TABLE_SIZE, len(tenant_tables)) self.assertIn('django_session', tenant_tables)
def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. connection.set_schema_to_public() hostname = self.hostname_from_request(request) TenantModel = get_tenant_model() try: # get_tenant must be implemented by extending this class. tenant = self.get_tenant(TenantModel, hostname, request) assert isinstance(tenant, TenantModel) except TenantModel.DoesNotExist: raise self.TENANT_NOT_FOUND_EXCEPTION('No tenant for {!r}'.format( request.get_host())) except AssertionError: raise self.TENANT_NOT_FOUND_EXCEPTION('Invalid tenant {!r}'.format( request.tenant)) request.tenant = tenant connection.set_tenant(request.tenant) # Content type can no longer be cached as public and tenant schemas # have different models. If someone wants to change this, the cache # needs to be separated between public and shared schemas. If this # cache isn't cleared, this can cause permission problems. For example, # on public, a particular model has id 14, but on the tenants it has # the id 15. if 14 is cached instead of 15, the permissions for the # wrong model will be fetched. ContentType.objects.clear_cache() # Do we have a public-specific urlconf? if hasattr( settings, 'PUBLIC_SCHEMA_URLCONF' ) and request.tenant.schema_name == get_public_schema_name(): request.urlconf = settings.PUBLIC_SCHEMA_URLCONF
def best_practice(app_configs, **kwargs): """ Test for configuration recommendations. These are best practices, they avoid hard to find bugs and unexpected behaviour. """ if app_configs is None: app_configs = apps.get_app_configs() # Take the app_configs and turn them into *old style* application names. # This is what we expect in the SHARED_APPS and TENANT_APPS settings. INSTALLED_APPS = [ config.name for config in app_configs ] if not hasattr(settings, 'TENANT_APPS'): return [Critical('TENANT_APPS setting not set')] if not hasattr(settings, 'TENANT_MODEL'): return [Critical('TENANT_MODEL setting not set')] if not hasattr(settings, 'SHARED_APPS'): return [Critical('SHARED_APPS setting not set')] if 'wagtail.wagtailtenant.routers.TenantSyncRouter' not in settings.DATABASE_ROUTERS: return [ Critical("DATABASE_ROUTERS setting must contain " "'wagtail.wagtailtenant.routers.TenantSyncRouter'.") ] errors = [] django_index = next(i for i, s in enumerate(INSTALLED_APPS) if s.startswith('django.')) if INSTALLED_APPS.index('wagtail.wagtailtenant') > django_index: errors.append( Warning("You should put 'wagtail.wagtailtenant' before any django " "core applications in INSTALLED_APPS.", obj="django.conf.settings", hint="This is necessary to overwrite built-in django " "management commands with their schema-aware " "implementations.", id="wagtailtenant.W001")) if not settings.TENANT_APPS: errors.append( Error("TENANT_APPS is empty.", hint="Maybe you don't need this app?", id="wagtailtenant.E001")) if hasattr(settings, 'PG_EXTRA_SEARCH_PATHS'): if get_public_schema_name() in settings.PG_EXTRA_SEARCH_PATHS: errors.append(Critical( "%s can not be included on PG_EXTRA_SEARCH_PATHS." % get_public_schema_name())) # make sure no tenant schema is in settings.PG_EXTRA_SEARCH_PATHS invalid_schemas = set(settings.PG_EXTRA_SEARCH_PATHS).intersection( get_tenant_model().objects.all().values_list('schema_name', flat=True)) if invalid_schemas: errors.append(Critical( "Do not include tenant schemas (%s) on PG_EXTRA_SEARCH_PATHS." % ", ".join(sorted(invalid_schemas)))) if not settings.SHARED_APPS: errors.append( Warning("SHARED_APPS is empty.", id="wagtailtenant.W002")) if not set(settings.TENANT_APPS).issubset(INSTALLED_APPS): delta = set(settings.TENANT_APPS).difference(INSTALLED_APPS) errors.append( Error("You have TENANT_APPS that are not in INSTALLED_APPS", hint=[a for a in settings.TENANT_APPS if a in delta], id="wagtailtenant.E002")) if not set(settings.SHARED_APPS).issubset(INSTALLED_APPS): delta = set(settings.SHARED_APPS).difference(INSTALLED_APPS) errors.append( Error("You have SHARED_APPS that are not in INSTALLED_APPS", hint=[a for a in settings.SHARED_APPS if a in delta], id="wagtailtenant.E003")) if not isinstance(default_storage, TenantStorageMixin): errors.append(Warning( "Your default storage engine is not tenant aware.", hint="Set settings.DEFAULT_FILE_STORAGE to " "'wagtail.wagtailtenant.storage.TenantFileSystemStorage'", id="wagtailtenant.W003" )) return errors
def sync_shared(cls): call_command('migrate_schemas', schema_name=get_public_schema_name(), interactive=False, verbosity=cls.get_verbosity(), run_syncdb=True)
def sync_shared(cls): call_command('migrate_schemas', schema_name=get_public_schema_name(), interactive=False, verbosity=0)