PK!ACCdjango_pgschemas/__init__.pydefault_app_config = "django_pgschemas.apps.DjangoPGSchemasConfig" PK!wBdjango_pgschemas/apps.pyfrom django.apps import AppConfig from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.db import connection from .utils import get_tenant_model class DjangoPGSchemasConfig(AppConfig): name = "django_pgschemas" verbose_name = "Django Postgres Schemas" example_config = """ TENANTS = { 'public': { 'APPS': ['django.contrib.contenttypes', 'core'], 'TENANT_MODEL': 'core.Tenant', 'DOMAIN_MODEL': 'core.Domain', }, 'www': { 'APPS': ['www'], 'URLCONF': 'www.urls', 'DOMAINS': ['mydomain.com', 'www.mydomain.com'], }, 'help': { 'APPS': ['help'], 'URLCONF': 'help.urls', 'DOMAINS': ['help.mydomain.com'], }, 'default': { 'APPS': ['tenants'], 'URLCONF': 'tenants.urls', } } """ def ready(self): user_app = get_user_model()._meta.app_config.name if not isinstance(getattr(settings, "TENANTS", None), dict): raise ImproperlyConfigured("TENANTS dict setting not set.") # Public schema if not isinstance(settings.TENANTS.get("public"), dict): raise ImproperlyConfigured("TENANTS must contain a 'public' dict.") if "URLCONF" in settings.TENANTS["public"]: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'URLCONF' key.") if "WS_URLCONF" in settings.TENANTS["public"]: raise ImproperlyConfigured("TENANTS['public'] cannot contain a 'WS_URLCONF' key.") # Default schemas if not isinstance(settings.TENANTS.get("default"), dict): raise ImproperlyConfigured("TENANTS must contain a 'default' dict.") if "URLCONF" not in settings.TENANTS["default"]: raise ImproperlyConfigured("TENANTS['default'] must contain a 'URLCONF' key.") if "TENANT_MODEL" not in settings.TENANTS["public"]: raise ImproperlyConfigured("TENANTS['default'] must contain a 'TENANT_MODEL' key.") if "DOMAIN_MODEL" not in settings.TENANTS["public"]: raise ImproperlyConfigured("TENANTS['default'] must contain a 'DOMAIN_MODEL' key.") if "DOMAINS" in settings.TENANTS["default"]: raise ImproperlyConfigured("TENANTS['default'] cannot contain a 'DOMAINS' key.") if "django.contrib.contenttypes" in settings.TENANTS["default"].get("APPS", []): raise ImproperlyConfigured("'django.contrib.contenttypes' must be on 'public' schema.") # Custom schemas for schema in settings.TENANTS: schema_apps = settings.TENANTS[schema].get("APPS", []) if ("django.contrib.sessions" in schema_apps and user_app not in schema_apps) or ( user_app in schema_apps and "django.contrib.sessions" not in schema_apps ): raise ImproperlyConfigured( "'django.contrib.sessions' must be on schemas that also have '%s'." % user_app ) if schema not in ["public", "default"]: if not isinstance(settings.TENANTS[schema].get("DOMAINS"), list): raise ImproperlyConfigured("TENANTS['%s'] must contain a 'DOMAINS' list." % schema) if "django.contrib.contenttypes" in schema_apps: raise ImproperlyConfigured("'django.contrib.contenttypes' must be on 'public' schema.") # Other checks if "django_pgschemas.routers.SyncRouter" not in settings.DATABASE_ROUTERS: raise ImproperlyConfigured("DATABASE_ROUTERS setting must contain 'django_pgschemas.routers.SyncRouter'.") if settings.ROOT_URLCONF != settings.TENANTS["default"]["URLCONF"]: raise ImproperlyConfigured("ROOT_URLCONF must be equal to TENANTS['default']['URLCONF'] for consistency") # Consistency of PGSCHEMAS_EXTRA_SEARCH_PATHS if hasattr(settings, "PGSCHEMAS_EXTRA_SEARCH_PATHS"): if "public" in settings.PGSCHEMAS_EXTRA_SEARCH_PATHS: raise ImproperlyConfigured("'public' cannot be included on PGSCHEMAS_EXTRA_SEARCH_PATHS.") if "default" in settings.PGSCHEMAS_EXTRA_SEARCH_PATHS: raise ImproperlyConfigured("'default' cannot be included on PGSCHEMAS_EXTRA_SEARCH_PATHS.") TenantModel = get_tenant_model() cursor = connection.cursor() cursor.execute( "SELECT 1 FROM information_schema.tables WHERE table_name = %s;", [TenantModel._meta.db_table] ) if cursor.fetchone(): invalid_schemas = set(settings.PGSCHEMAS_EXTRA_SEARCH_PATHS).intersection( TenantModel.objects.all().values_list("schema_name", flat=True) ) if invalid_schemas: raise ImproperlyConfigured( "Do not include schemas (%s) on PGSCHEMAS_EXTRA_SEARCH_PATHS." % list(invalid_schemas) ) PK!ɕdjango_pgschemas/cache.pyfrom django.db import connection def make_key(key, key_prefix, version): """ Tenant aware function to generate a cache key. Constructs the key used by all other methods. Prepends the tenant `schema_name` and `key_prefix'. """ return "%s:%s:%s:%s" % (connection.schema_name, key_prefix, version, key) def reverse_key(key): """ Tenant aware function to reverse a cache key. Required for django-redis REVERSE_KEY_FUNCTION setting. """ return key.split(":", 3)[3] PK!X3UpRR%django_pgschemas/channels/__init__.pyfrom .router import TenantProtocolRouter application = TenantProtocolRouter() PK!,]Jll!django_pgschemas/channels/auth.pyfrom django.conf import settings from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, load_backend from django.contrib.auth.models import AnonymousUser from django.utils.crypto import constant_time_compare from channels.auth import login, logout, CookieMiddleware, SessionMiddleware, AuthMiddleware, _get_user_session_key from channels.db import database_sync_to_async @database_sync_to_async def get_user(scope): """ Return the user model instance associated with the given scope. If no user is retrieved, return an instance of `AnonymousUser`. """ if "session" not in scope: raise ValueError("Cannot find session in scope. You should wrap your consumer in SessionMiddleware.") user = None session = scope["session"] with scope["tenant"]: try: user_id = _get_user_session_key(session) backend_path = session[BACKEND_SESSION_KEY] except KeyError: pass else: if backend_path in settings.AUTHENTICATION_BACKENDS: backend = load_backend(backend_path) user = backend.get_user(user_id) # Verify the session if hasattr(user, "get_session_auth_hash"): session_hash = session.get(HASH_SESSION_KEY) session_hash_verified = session_hash and constant_time_compare( session_hash, user.get_session_auth_hash() ) if not session_hash_verified: session.flush() user = None return user or AnonymousUser() class TenantAuthMiddleware(AuthMiddleware): async def resolve_scope(self, scope): scope["user"]._wrapped = await get_user(scope) def TenantAuthMiddlewareStack(inner): return CookieMiddleware(SessionMiddleware(TenantAuthMiddleware(inner))) PK!*CI I #django_pgschemas/channels/router.pyfrom django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import Http404 from django.utils.encoding import force_text from django.utils.module_loading import import_string from channels.routing import ProtocolTypeRouter, URLRouter from ..utils import remove_www, get_domain_model from ..volatile import VolatileTenant from .auth import TenantAuthMiddlewareStack class TenantProtocolRouter: """ ProtocolRouter that handles multi-tenancy. """ TENANT_NOT_FOUND_EXCEPTION = Http404 def __init__(self): self.root_ws_urlconf = settings.TENANTS["default"].get("WS_URLCONF") if self.root_ws_urlconf is None: raise ImproperlyConfigured( "TENANTS['default'] must contain a 'WS_URLCONF' key in order to use TenantProtocolRouter." ) def get_tenant_scope(self, scope): """ Get tenant and websockets urlconf based on scope host. """ hostname = force_text(dict(scope["headers"]).get(b"host", b"")) hostname = remove_www(hostname.split(":")[0]) tenant = None ws_urlconf = self.root_ws_urlconf # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data["DOMAINS"]: tenant = VolatileTenant.create(schema_name=schema, domain_url=hostname) if "WS_URLCONF" in data: ws_urlconf = data["WS_URLCONF"] return tenant, import_string(ws_urlconf + ".urlpatterns") # Checking for dynamic tenants else: DomainModel = get_domain_model() try: domain = DomainModel.objects.select_related("tenant").get(domain=hostname) tenant = domain.tenant except DomainModel.DoesNotExist: raise self.TENANT_NOT_FOUND_EXCEPTION("No tenant for hostname '%s'" % hostname) tenant.domain_url = hostname ws_urlconf = settings.TENANTS["default"]["WS_URLCONF"] return tenant, import_string(ws_urlconf + ".urlpatterns") def get_protocol_type_router(self, ws_urlconf): """ Subclasses can override this to include more protocols. """ return ProtocolTypeRouter({"websocket": TenantAuthMiddlewareStack(URLRouter(ws_urlconf))}) def __call__(self, scope): tenant, ws_urlconf = self.get_tenant_scope(scope) scope.update({"tenant": tenant}) return self.get_protocol_type_router(ws_urlconf)(scope) PK!NNdjango_pgschemas/log.pyimport logging from django.db import connection class SchemaContextFilter(logging.Filter): """ Add the current ``schema_name`` and ``domain_url`` to log records. """ def filter(self, record): record.schema_name = connection.schema_name record.domain_url = connection.domain_url return True PK!'django_pgschemas/management/__init__.pyPK!,b--0django_pgschemas/management/commands/__init__.pyfrom enum import Flag from django.conf import settings from django.core.management.base import BaseCommand, CommandError from ._executors import sequential, parallel from ...utils import get_tenant_model, create_schema, get_clone_reference from ...volatile import VolatileTenant WILDCARD_ALL = ":all:" WILDCARD_STATIC = ":static:" WILDCARD_DYNAMIC = ":dynamic:" EXECUTORS = {"sequential": sequential, "parallel": parallel} class SchemaScope(Flag): STATIC = 1 DYNAMIC = 2 ALL = 3 class WrappedSchemaOption(object): scope = SchemaScope.ALL specific_schemas = None allow_interactive = True allow_wildcards = True def add_arguments(self, parser): if self.allow_interactive: parser.add_argument( "--noinput", "--no-input", action="store_false", dest="interactive", help="Tells Django to NOT prompt the user for input of any kind.", ) parser.add_argument("-s", "--schema", dest="schema", help="Schema to execute the current command") parser.add_argument( "--executor", dest="executor", default="sequential", choices=EXECUTORS, help="Executor to be used for running command on schemas", ) parser.add_argument( "--no-create-schemas", dest="skip_schema_creation", action="store_true", help="Skip automatic creation of non-existing schemas", ) def get_schemas_from_options(self, **options): skip_schema_creation = options.get("skip_schema_creation", False) schemas = self._get_schemas_from_options(**options) if self.specific_schemas is not None: schemas = [x for x in schemas if x in self.specific_schemas] if not schemas: raise CommandError("This command can only run in %s" % self.specific_schemas) if not skip_schema_creation: for schema in schemas: create_schema(schema, check_if_exists=True, sync_schema=False, verbosity=0) return schemas def get_executor_from_options(self, **options): return EXECUTORS[options.get("executor")] def get_scope_display(self): return "|".join(self.specific_schemas or []) or self.scope.name.lower() def _get_schemas_from_options(self, **options): schema = options.get("schema", "") allow_static = self.scope & SchemaScope.STATIC allow_dynamic = self.scope & SchemaScope.DYNAMIC clone_reference = get_clone_reference() if not schema: if not self.allow_interactive: schema = WILDCARD_ALL elif options.get("interactive", True): schema = input( "Enter schema to run command (leave blank for running on '%s' schemas): " % self.get_scope_display() ).strip() if not schema: schema = WILDCARD_ALL else: raise CommandError("No schema provided") TenantModel = get_tenant_model() static_schemas = [x for x in settings.TENANTS.keys() if x != "default"] if allow_static else [] dynamic_schemas = TenantModel.objects.values_list("schema_name", flat=True) if allow_dynamic else [] if clone_reference and allow_static: static_schemas.append(clone_reference) if schema == WILDCARD_ALL: if not self.allow_wildcards or (not allow_static and not allow_dynamic): raise CommandError("Schema wildcard %s is now allowed" % WILDCARD_ALL) return static_schemas + list(dynamic_schemas) elif schema == WILDCARD_STATIC: if not self.allow_wildcards or not allow_static: raise CommandError("Schema wildcard %s is now allowed" % WILDCARD_STATIC) return static_schemas elif schema == WILDCARD_DYNAMIC: if not self.allow_wildcards or not allow_dynamic: raise CommandError("Schema wildcard %s is now allowed" % WILDCARD_DYNAMIC) return list(dynamic_schemas) elif schema in settings.TENANTS and schema != "default" and allow_static: return [schema] elif schema == clone_reference: return [schema] elif TenantModel.objects.filter(schema_name=schema).exists() and allow_dynamic: return [schema] domain_matching_schemas = [] if allow_static: domain_matching_schemas += [ schema_name for schema_name, data in settings.TENANTS.items() if schema_name not in ["public", "default"] and any([x for x in data["DOMAINS"] if x.startswith(schema)]) ] if allow_dynamic: domain_matching_schemas += ( TenantModel.objects.filter(domains__domain__istartswith=schema) .distinct() .values_list("schema_name", flat=True) ) if not domain_matching_schemas: raise CommandError("No schema found for '%s'" % schema) if len(domain_matching_schemas) > 1: raise CommandError( "More than one tenant found for schema '%s' by domain, please, narrow down the filter" % schema ) return domain_matching_schemas class TenantCommand(WrappedSchemaOption, BaseCommand): def handle(self, *args, **options): schemas = self.get_schemas_from_options(**options) executor = self.get_executor_from_options(**options) executor(schemas, type(self), "_raw_handle_tenant", args, options, pass_schema_in_kwargs=True) def _raw_handle_tenant(self, *args, **kwargs): schema_name = kwargs.pop("schema_name") if schema_name in settings.TENANTS: domains = settings.TENANTS[schema_name].get("DOMAINS", []) tenant = VolatileTenant.create(schema_name=schema_name, domain_url=domains[0] if domains else None) self.handle_tenant(tenant, *args, **kwargs) elif schema_name == get_clone_reference(): tenant = VolatileTenant.create(schema_name=schema_name) self.handle_tenant(tenant, *args, **kwargs) else: TenantModel = get_tenant_model() tenant = TenantModel.objects.get(schema_name=schema_name) self.handle_tenant(tenant, *args, **kwargs) def handle_tenant(self, tenant, *args, **options): pass class StaticTenantCommand(TenantCommand): scope = SchemaScope.STATIC class DynamicTenantCommand(TenantCommand): scope = SchemaScope.DYNAMIC PK!lvv2django_pgschemas/management/commands/_executors.pyimport functools import multiprocessing import sys from django.conf import settings from django.core.management import call_command, color from django.core.management.base import OutputWrapper, CommandError from django.db import connection, transaction, connections def run_on_schema( schema_name, executor_codename, command_class, function_name=None, args=[], kwargs={}, pass_schema_in_kwargs=False ): style = color.color_style() stdout = OutputWrapper(sys.stdout) stderr = OutputWrapper(sys.stderr) stdout.style_func = stderr.style_func = lambda message: "[%s:%s] %s" % ( style.NOTICE(executor_codename), style.NOTICE(schema_name), message, ) command = command_class(stdout=stdout, stderr=stderr) connections.close_all() connection.set_schema(schema_name) if pass_schema_in_kwargs: kwargs.update({"schema_name": schema_name}) if function_name == "special:call_command": call_command(command, *args, **kwargs) elif function_name == "special:run_from_argv": command.run_from_argv(args) else: getattr(command, function_name)(*args, **kwargs) transaction.commit() connection.close() def sequential(schemas, command_class, function_name, args=[], kwargs={}, pass_schema_in_kwargs=False): runner = functools.partial( run_on_schema, executor_codename="sequential", command_class=command_class, function_name=function_name, args=args, kwargs=kwargs, pass_schema_in_kwargs=pass_schema_in_kwargs, ) for schema in schemas: runner(schema) def parallel(schemas, command_class, function_name, args=[], kwargs={}, pass_schema_in_kwargs=False): processes = getattr(settings, "PGSCHEMAS_MULTIPROCESSING_MAX_PROCESSES", None) pool = multiprocessing.Pool(processes=processes) runner = functools.partial( run_on_schema, executor_codename="parallel", command_class=command_class, function_name=function_name, args=args, kwargs=kwargs, pass_schema_in_kwargs=pass_schema_in_kwargs, ) pool.map(runner, schemas) PK!x= 1: self.stdout.write(self.style.WARNING("Looks good! Let's get to it!")) return tenant, domain def handle(self, *args, **options): tenant = None domain = None dry_run = options.get("dry_run") if options.get("interactive", True): TenantModel = get_tenant_model() if TenantModel.objects.filter(schema_name=options["source"]).exists(): tenant, domain = self.get_dynamic_tenant(**options) try: clone_schema(options["source"], options["destination"], dry_run) if tenant and domain: if options["verbosity"] >= 1: self.stdout.write("Schema cloned.") if not dry_run: tenant.save() domain.tenant = tenant if not dry_run: domain.save() if options["verbosity"] >= 1: self.stdout.write("Tenant and domain successfully saved.") if options["verbosity"] >= 1: self.stdout.write("All done!") except Exception as e: raise CommandError(e) PK!^ݜ7django_pgschemas/management/commands/createrefschema.pyfrom django.core.management.base import BaseCommand, CommandError from ...utils import get_clone_reference, create_schema, drop_schema class Command(BaseCommand): help = "Creates the reference schema for faster dynamic tenant creation" def add_arguments(self, parser): super().add_arguments(parser) parser.add_argument("--recreate", action="store_true", dest="recreate", help="Recreate reference schema.") def handle(self, *args, **options): clone_reference = get_clone_reference() if not clone_reference: raise CommandError("There is no reference schema configured.") if options.get("recreate", False): drop_schema(clone_reference, check_if_exists=True, verbosity=options["verbosity"]) if options["verbosity"] >= 1: self.stdout.write("Destroyed existing reference schema.") created = create_schema(clone_reference, check_if_exists=True, verbosity=options["verbosity"]) if options["verbosity"] >= 1: if created: self.stdout.write("Reference schema successfully created!") else: self.stdout.write("Reference schema already exists.") self.stdout.write( self.style.WARNING( "Run this command again with --recreate if you want to recreate the reference schema." ) ) PK!M}SS/django_pgschemas/management/commands/migrate.pyfrom .migrateschema import MigrateSchemaCommand Command = MigrateSchemaCommand PK!}JJ5django_pgschemas/management/commands/migrateschema.pyfrom django.core import management from django.core.management.base import BaseCommand from . import WrappedSchemaOption from .runschema import Command as RunSchemaCommand class NonInteractiveRunSchemaCommand(RunSchemaCommand): allow_interactive = False class MigrateSchemaCommand(WrappedSchemaOption, BaseCommand): allow_interactive = False def handle(self, *args, **options): runschema = NonInteractiveRunSchemaCommand() management.call_command(runschema, "django.core.migrate", *args, **options) Command = MigrateSchemaCommand PK!]\ \ 1django_pgschemas/management/commands/runschema.pyimport argparse import sys from django.core.management import call_command, get_commands, load_command_class from django.core.management.base import BaseCommand, CommandError, SystemCheckError from . import WrappedSchemaOption class Command(WrappedSchemaOption, BaseCommand): help = "Wrapper around django commands for use with an individual schema" def add_arguments(self, parser): super().add_arguments(parser) parser.add_argument("command_name", help="The command name you want to run") def get_command_from_arg(self, arg): *chunks, command = arg.split(".") path = ".".join(chunks) if not path: path = get_commands().get(command) try: cmd = load_command_class(path, command) except Exception: raise CommandError("Unknown command: %s" % arg) if isinstance(cmd, WrappedSchemaOption): raise CommandError("Command '%s' cannot be used in runschema" % arg) return cmd def run_from_argv(self, argv): """ Changes the option_list to use the options from the wrapped command. Adds schema parameter to specify which schema will be used when executing the wrapped command. """ try: # load the command object. if len(argv) <= 2: return target_class = self.get_command_from_arg(argv[2]) # Ugly, but works. Delete command_name from the argv, parse the schema manually # and forward the rest of the arguments to the actual command being wrapped. del argv[1] schema_parser = argparse.ArgumentParser() super().add_arguments(schema_parser) schema_ns, args = schema_parser.parse_known_args(argv) schemas = self.get_schemas_from_options(schema=schema_ns.schema) executor = self.get_executor_from_options(executor=schema_ns.executor) except Exception as e: if not isinstance(e, CommandError): raise # SystemCheckError takes care of its own formatting. if isinstance(e, SystemCheckError): self.stderr.write(str(e), lambda x: x) else: self.stderr.write("%s: %s" % (e.__class__.__name__, e)) sys.exit(1) executor(schemas, type(target_class), "special:run_from_argv", args) def handle(self, *args, **options): target = self.get_command_from_arg(options.pop("command_name")) schemas = self.get_schemas_from_options(**options) executor = self.get_executor_from_options(**options) options.pop("schema") options.pop("executor") options.pop("skip_schema_creation") if self.allow_interactive: options.pop("interactive") executor(schemas, type(target), "special:call_command", args, options) PK!AX~~django_pgschemas/middleware.pyfrom django.conf import settings from django.db import connection from django.http import Http404 from .utils import remove_www, get_domain_model from .volatile import VolatileTenant class TenantMiddleware: """ This middleware should be placed at the very top of the middleware stack. Selects the proper static/dynamic schema using the request host. Can fail in various ways which is better than corrupting or revealing data. """ TENANT_NOT_FOUND_EXCEPTION = Http404 def __init__(self, get_response): self.get_response = get_response def __call__(self, request): hostname = remove_www(request.get_host().split(":")[0]) connection.set_schema_to_public() # Checking for static tenants for schema, data in settings.TENANTS.items(): if schema in ["public", "default"]: continue if hostname in data["DOMAINS"]: tenant = VolatileTenant.create(schema_name=schema, domain_url=hostname) request.tenant = tenant if "URLCONF" in data: request.urlconf = data["URLCONF"] connection.set_schema(schema, hostname) return self.get_response(request) # Checking for dynamic tenants else: DomainModel = get_domain_model() try: domain = DomainModel.objects.select_related("tenant").get(domain=hostname) tenant = domain.tenant except DomainModel.DoesNotExist: raise self.TENANT_NOT_FOUND_EXCEPTION("No tenant for hostname '%s'" % hostname) tenant.domain_url = hostname request.tenant = tenant request.urlconf = settings.TENANTS["default"]["URLCONF"] connection.set_schema(request.tenant.schema_name, request.tenant.domain_url) return self.get_response(request) PK!uPJJdjango_pgschemas/models.pyfrom django.conf import settings from django.db import models, transaction from .postgresql_backend.base import check_schema_name from .signals import schema_post_sync, schema_needs_sync, schema_pre_drop from .utils import schema_exists, create_or_clone_schema, drop_schema, get_domain_model from .volatile import VolatileTenant class TenantMixin(VolatileTenant, models.Model): """ All tenant models must inherit this class. """ auto_create_schema = True """ Set this flag to false on a parent class if you don't want the schema to be automatically created upon save. """ auto_drop_schema = False """ USE THIS WITH CAUTION! Set this flag to true on a parent class if you want the schema to be automatically deleted if the tenant row gets deleted. """ schema_name = models.CharField(max_length=63, unique=True, validators=[check_schema_name]) domain_url = None """ Leave this as None. Stores the effective domain url. """ is_dynamic = True """ Leave this as None. Denotes it's a database controlled tenant. """ class Meta: abstract = True def save(self, verbosity=1, *args, **kwargs): is_new = self.pk is None super().save(*args, **kwargs) if is_new and self.auto_create_schema: try: self.create_schema(verbosity=verbosity) schema_post_sync.send(sender=TenantMixin, tenant=self.serializable_fields()) except Exception: # We failed creating the tenant, delete what we created and re-raise the exception self.delete(force_drop=True) raise elif is_new: # Although we are not using the schema functions directly, the signal might be registered by a listener schema_needs_sync.send(sender=TenantMixin, tenant=self.serializable_fields()) elif not is_new and self.auto_create_schema and not schema_exists(self.schema_name): # Create schemas for existing models, deleting only the schema on failure try: self.create_schema(verbosity=verbosity) schema_post_sync.send(sender=TenantMixin, tenant=self.serializable_fields()) except Exception: # We failed creating the schema, delete what we created and re-raise the exception self.drop_schema() raise def delete(self, force_drop=False, *args, **kwargs): """ Deletes this row. Drops the tenant's schema if the attribute auto_drop_schema is True. """ if force_drop or self.auto_drop_schema: schema_pre_drop.send(sender=TenantMixin, tenant=self.serializable_fields()) self.drop_schema() super().delete(*args, **kwargs) def serializable_fields(self): """ In certain cases the user model isn't serializable so you may want to only send the id. """ return self def create_schema(self, sync_schema=True, verbosity=1): """ Creates or clones the schema 'schema_name' for this tenant. """ return create_or_clone_schema(self.schema_name, sync_schema, verbosity) def drop_schema(self): """ Drops the schema. """ return drop_schema(self.schema_name) def get_primary_domain(self): """ Returns the primary domain of the tenant. """ try: domain = self.domains.get(is_primary=True) return domain except get_domain_model().DoesNotExist: return None class DomainMixin(models.Model): """ All models that store the domains must inherit this class. """ domain = models.CharField(max_length=253, unique=True, db_index=True) tenant = models.ForeignKey( settings.TENANTS["public"]["TENANT_MODEL"], db_index=True, related_name="domains", on_delete=models.CASCADE ) is_primary = models.BooleanField(default=True) @transaction.atomic def save(self, *args, **kwargs): domain_list = self.__class__.objects.filter(tenant=self.tenant, is_primary=True).exclude(pk=self.pk) self.is_primary = self.is_primary or (not domain_list.exists()) if self.is_primary: domain_list.update(is_primary=False) super().save(*args, **kwargs) class Meta: abstract = True PK!/django_pgschemas/postgresql_backend/__init__.pyPK!C>883django_pgschemas/postgresql_backend/_constraints.pyfrom django.db.models.indexes import Index def get_constraints(self, cursor, table_name): """ Retrieve any constraints or keys (unique, pk, fk, check, index) across one or more columns. Also retrieve the definition of expression-based indexes. """ constraints = {} # Loop over the key table, collecting things as constraints. The column # array must return column names in the same order in which they were # created. # The subquery containing generate_series can be replaced with # "WITH ORDINALITY" when support for PostgreSQL 9.3 is dropped. cursor.execute( """ SELECT c.conname, array( SELECT attname FROM ( SELECT unnest(c.conkey) AS colid, generate_series(1, array_length(c.conkey, 1)) AS arridx ) AS cols JOIN pg_attribute AS ca ON cols.colid = ca.attnum WHERE ca.attrelid = c.conrelid ORDER BY cols.arridx ), c.contype, (SELECT fkc.relname || '.' || fka.attname FROM pg_attribute AS fka JOIN pg_class AS fkc ON fka.attrelid = fkc.oid WHERE fka.attrelid = c.confrelid AND fka.attnum = c.confkey[1]), cl.reloptions FROM pg_constraint AS c JOIN pg_class AS cl ON c.conrelid = cl.oid JOIN pg_namespace AS ns ON cl.relnamespace = ns.oid WHERE ns.nspname = %s AND cl.relname = %s """, [self.connection.schema_name, table_name], ) for constraint, columns, kind, used_cols, options in cursor.fetchall(): constraints[constraint] = { "columns": columns, "primary_key": kind == "p", "unique": kind in ["p", "u"], "foreign_key": tuple(used_cols.split(".", 1)) if kind == "f" else None, "check": kind == "c", "index": False, "definition": None, "options": options, } # Now get indexes # The row_number() function for ordering the index fields can be # replaced by WITH ORDINALITY in the unnest() functions when support # for PostgreSQL 9.3 is dropped. cursor.execute( """ SELECT indexname, array_agg(attname ORDER BY rnum), indisunique, indisprimary, array_agg(ordering ORDER BY rnum), amname, exprdef, s2.attoptions FROM ( SELECT row_number() OVER () as rnum, c2.relname as indexname, idx.*, attr.attname, am.amname, CASE WHEN idx.indexprs IS NOT NULL THEN pg_get_indexdef(idx.indexrelid) END AS exprdef, CASE am.amname WHEN 'btree' THEN CASE (option & 1) WHEN 1 THEN 'DESC' ELSE 'ASC' END END as ordering, c2.reloptions as attoptions FROM ( SELECT *, unnest(i.indkey) as key, unnest(i.indoption) as option FROM pg_index i ) idx LEFT JOIN pg_class c ON idx.indrelid = c.oid LEFT JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_class c2 ON idx.indexrelid = c2.oid LEFT JOIN pg_am am ON c2.relam = am.oid LEFT JOIN pg_attribute attr ON attr.attrelid = c.oid AND attr.attnum = idx.key WHERE c.relname = %s and n.nspname = %s ) s2 GROUP BY indexname, indisunique, indisprimary, amname, exprdef, attoptions; """, [table_name, self.connection.schema_name], ) for index, columns, unique, primary, orders, type_, definition, options in cursor.fetchall(): if index not in constraints: constraints[index] = { "columns": columns if columns != [None] else [], "orders": orders if orders != [None] else [], "primary_key": primary, "unique": unique, "foreign_key": None, "check": False, "index": True, "type": Index.suffix if type_ == "btree" else type_, "definition": definition, "options": options, } return constraints PK!WO+django_pgschemas/postgresql_backend/base.pyfrom importlib import import_module import psycopg2 from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db.utils import DatabaseError from ..utils import get_limit_set_calls, check_schema_name from .introspection import DatabaseSchemaIntrospection ORIGINAL_BACKEND = getattr(settings, "PGSCHEMAS_ORIGINAL_BACKEND", "django.db.backends.postgresql_psycopg2") EXTRA_SEARCH_PATHS = getattr(settings, "PGSCHEMAS_EXTRA_SEARCH_PATHS", []) original_backend = import_module(ORIGINAL_BACKEND + ".base") IntegrityError = psycopg2.IntegrityError class DatabaseWrapper(original_backend.DatabaseWrapper): """ Adds the capability to manipulate the search_path using set_schema """ include_public_schema = True def __init__(self, *args, **kwargs): self.search_path_set = None self.schema_name = None super().__init__(*args, **kwargs) # Use a patched version of the DatabaseIntrospection that only returns the table list for the # currently selected schema. self.introspection = DatabaseSchemaIntrospection(self) self.set_schema_to_public() def close(self): self.search_path_set = False super().close() def set_schema(self, schema_name, domain_url=None, include_public=True): """ Main API method to current database schema, but it does not actually modify the db connection. """ self.schema_name = schema_name self.domain_url = domain_url self.include_public_schema = include_public self.search_path_set = False def set_schema_to_public(self): """ Instructs to stay in the common 'public' schema. """ self.set_schema("public", include_public=False) def _cursor(self): """ Here it happens. We hope every Django db operation using PostgreSQL must go through this to get the cursor handle. We change the path. """ cursor = super()._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 schemas 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()?") check_schema_name(self.schema_name) search_paths = [] if self.schema_name == "public": search_paths = ["public"] elif self.include_public_schema: search_paths = [self.schema_name, "public"] else: search_paths = [self.schema_name] search_paths.extend(EXTRA_SEARCH_PATHS) # 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.execute("SET search_path = {0}".format(",".join(search_paths))) except (DatabaseError, psycopg2.InternalError): self.search_path_set = False else: self.search_path_set = True return cursor PK!++4django_pgschemas/postgresql_backend/introspection.pyfrom django.db.backends.base.introspection import TableInfo, FieldInfo from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection from django.utils.encoding import force_text from . import _constraints class DatabaseSchemaIntrospection(DatabaseIntrospection): """ database schema introspection class """ _get_indexes_query = """ SELECT attr.attname, idx.indkey, idx.indisunique, idx.indisprimary FROM pg_catalog.pg_class c, INNER JOIN pg_catalog.pg_index idx ON c.oid = idx.indrelid INNER JOIN pg_catalog.pg_class c2 ON idx.indexrelid = c2.oid INNER JOIN pg_catalog.pg_attribute attr ON attr.attrelid = c.oid and attr.attnum = idx.indkey[0] INNER JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = %s AND n.nspname = %s """ def get_table_list(self, cursor): """ Returns a list of table names in the current database and schema. """ cursor.execute( """ SELECT c.relname, c.relkind FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'v', '') AND n.nspname = '%s' AND pg_catalog.pg_table_is_visible(c.oid)""" % self.connection.schema_name ) return [ TableInfo(row[0], {"r": "t", "v": "v"}.get(row[1])) for row in cursor.fetchall() if row[0] not in self.ignored_tables ] def get_table_description(self, cursor, table_name): "Returns a description of the table, with the DB-API cursor.description interface." # As cursor.description does not return reliably the nullable property, # we have to query the information_schema (#7783) cursor.execute( """ SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_schema = %s and table_name = %s""", [self.connection.schema_name, table_name], ) field_map = {line[0]: line[1:] for line in cursor.fetchall()} cursor.execute("SELECT * FROM %s LIMIT 1" % self.connection.ops.quote_name(table_name)) return [ FieldInfo( *( (force_text(line[0]),) + line[1:6] + (field_map[force_text(line[0])][0] == "YES", field_map[force_text(line[0])][1]) ) ) for line in cursor.description ] def get_indexes(self, cursor, table_name): # This query retrieves each index on the given table, including the # first associated field name cursor.execute(self._get_indexes_query, [table_name, self.connection.schema_name]) indexes = {} for row in cursor.fetchall(): # row[1] (idx.indkey) is stored in the DB as an array. It comes out as # a string of space-separated integers. This designates the field # indexes (1-based) of the fields that have indexes on the table. # Here, we skip any indexes across multiple fields. if " " in row[1]: continue if row[0] not in indexes: indexes[row[0]] = {"primary_key": False, "unique": False} # It's possible to have the unique and PK constraints in separate indexes. if row[3]: indexes[row[0]]["primary_key"] = True if row[2]: indexes[row[0]]["unique"] = True return indexes def get_relations(self, cursor, table_name): """ Returns a dictionary of {field_name: (field_name_other_table, other_table)} representing all relationships to the given table. """ cursor.execute( """ SELECT c2.relname, a1.attname, a2.attname FROM pg_constraint con LEFT JOIN pg_class c1 ON con.conrelid = c1.oid LEFT JOIN pg_namespace n ON n.oid = c1.relnamespace LEFT JOIN pg_class c2 ON con.confrelid = c2.oid LEFT JOIN pg_attribute a1 ON c1.oid = a1.attrelid AND a1.attnum = con.conkey[1] LEFT JOIN pg_attribute a2 ON c2.oid = a2.attrelid AND a2.attnum = con.confkey[1] WHERE c1.relname = %s and n.nspname = %s AND con.contype = 'f'""", [table_name, self.connection.schema_name], ) relations = {} for row in cursor.fetchall(): relations[row[1]] = (row[2], row[0]) return relations get_constraints = _constraints.get_constraints def get_key_columns(self, cursor, table_name): key_columns = [] cursor.execute( """ SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column FROM information_schema.constraint_column_usage ccu LEFT JOIN information_schema.key_column_usage kcu ON ccu.constraint_catalog = kcu.constraint_catalog AND ccu.constraint_schema = kcu.constraint_schema AND ccu.constraint_name = kcu.constraint_name LEFT JOIN information_schema.table_constraints tc ON ccu.constraint_catalog = tc.constraint_catalog AND ccu.constraint_schema = tc.constraint_schema AND ccu.constraint_name = tc.constraint_name WHERE kcu.table_name = %s AND tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s """, [table_name, self.connection.schema_name], ) key_columns.extend(cursor.fetchall()) return key_columns PK!vdjango_pgschemas/routers.pyfrom django.apps import apps from django.conf import settings from django.db import connection from .utils import get_tenant_database_alias class SyncRouter(object): """ A router to control which applications will be synced depending on the schema we're syncing. """ def app_in_list(self, app_label, app_list): app_config = apps.get_app_config(app_label) app_config_full_name = "{}.{}".format(app_config.__module__, app_config.__class__.__name__) return (app_config.name in app_list) or (app_config_full_name in app_list) def allow_migrate(self, db, app_label, model_name=None, **hints): if db != get_tenant_database_alias() or not hasattr(connection, "schema_name"): return False app_list = [] if connection.schema_name == "public": app_list = settings.TENANTS["public"]["APPS"] elif connection.schema_name in settings.TENANTS: app_list = settings.TENANTS[connection.schema_name]["APPS"] else: app_list = settings.TENANTS["default"]["APPS"] if not app_list: return None return self.app_in_list(app_label, app_list) PK!vxxdjango_pgschemas/signals.pyfrom django.db.models.signals import pre_delete from django.dispatch import Signal, receiver from .utils import get_tenant_model, schema_exists schema_post_sync = Signal(providing_args=["tenant"]) schema_post_sync.__doc__ = "Sent after a tenant has been saved, its schema created and synced" schema_needs_sync = Signal(providing_args=["tenant"]) schema_needs_sync.__doc__ = "Sent when a schema needs to be synced" schema_pre_drop = Signal(providing_args=["tenant"]) schema_pre_drop.__doc__ = "Sent when a schema is about to be dropped" @receiver(pre_delete) def tenant_delete_callback(sender, instance, **kwargs): if not isinstance(instance, get_tenant_model()): return if instance.auto_drop_schema and schema_exists(instance.schema_name): schema_pre_drop.send(sender=get_tenant_model(), tenant=instance.serializable_fields()) instance.drop_schema() PK!!django_pgschemas/test/__init__.pyPK!Iqdjango_pgschemas/test/cases.pyfrom django.conf import settings from django.core.management import call_command from django.db import connection from django.test import TestCase from ..utils import get_tenant_model, get_domain_model ALLOWED_TEST_DOMAIN = ".test.com" class TenantTestCase(TestCase): tenant = None domain = None @classmethod def setup_tenant(cls, tenant): """ Add any additional setting to the tenant before it get saved. This is required if you have required fields. :param tenant: :return: """ pass @classmethod def setup_domain(cls, domain): """ Add any additional setting to the domain before it get saved. This is required if you have required fields. :param domain: :return: """ pass @classmethod def setUpClass(cls): cls.sync_public() cls.add_allowed_test_domain() cls.tenant = get_tenant_model()(schema_name=cls.get_test_schema_name()) cls.setup_tenant(cls.tenant) cls.tenant.save(verbosity=cls.get_verbosity()) # Set up domain tenant_domain = cls.get_test_tenant_domain() cls.domain = get_domain_model()(tenant=cls.tenant, domain=tenant_domain) cls.setup_domain(cls.domain) cls.domain.save() connection.set_schema(cls.tenant.schema_name) @classmethod def tearDownClass(cls): connection.set_schema_to_public() cls.domain.delete() cls.tenant.delete(force_drop=True) cls.remove_allowed_test_domain() @classmethod def get_verbosity(cls): return 0 @classmethod def add_allowed_test_domain(cls): # ALLOWED_HOSTS is a special setting of Django setup_test_environment so we can't modify it with helpers if ALLOWED_TEST_DOMAIN not in settings.ALLOWED_HOSTS: settings.ALLOWED_HOSTS += [ALLOWED_TEST_DOMAIN] @classmethod def remove_allowed_test_domain(cls): if ALLOWED_TEST_DOMAIN in settings.ALLOWED_HOSTS: settings.ALLOWED_HOSTS.remove(ALLOWED_TEST_DOMAIN) @classmethod def sync_public(cls): call_command("migrateschema", schema="public", verbosity=0) @classmethod def get_test_tenant_domain(cls): return "tenant.test.com" @classmethod def get_test_schema_name(cls): return "test" class FastTenantTestCase(TenantTestCase): @classmethod def flush_data(cls): """ Do you want to flush the data out of the tenant database. :return: bool """ return True @classmethod def use_existing_tenant(cls): """ Gets called if a existing tenant is found in the database """ pass @classmethod def use_new_tenant(cls): """ Gets called if a new tenant is created in the database """ pass @classmethod def setup_test_tenant_and_domain(cls): cls.tenant = get_tenant_model()(schema_name=cls.get_test_schema_name()) cls.setup_tenant(cls.tenant) cls.tenant.save(verbosity=cls.get_verbosity()) # Set up domain tenant_domain = cls.get_test_tenant_domain() cls.domain = get_domain_model()(tenant=cls.tenant, domain=tenant_domain) cls.setup_domain(cls.domain) cls.domain.save() cls.use_new_tenant() @classmethod def setUpClass(cls): tenant_model = get_tenant_model() test_schema_name = cls.get_test_schema_name() if tenant_model.objects.filter(schema_name=test_schema_name).exists(): cls.tenant = tenant_model.objects.filter(schema_name=test_schema_name).first() cls.use_existing_tenant() else: cls.setup_test_tenant_and_domain() connection.set_schema(cls.tenant.schema_name) @classmethod def tearDownClass(cls): connection.set_schema_to_public() def _fixture_teardown(self): if self.flush_data(): super()._fixture_teardown() PK!: : django_pgschemas/test/client.pyfrom django.test import RequestFactory, Client from ..middleware import TenantMiddleware class TenantRequestFactory(RequestFactory): tm = TenantMiddleware() def __init__(self, tenant, **defaults): super().__init__(**defaults) self.tenant = tenant def get(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().get(path, data, **extra) def post(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().post(path, data, **extra) def patch(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().patch(path, data, **extra) def put(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().put(path, data, **extra) def delete(self, path, data="", content_type="application/octet-stream", **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().delete(path, data, **extra) class TenantClient(Client): tm = TenantMiddleware() def __init__(self, tenant, enforce_csrf_checks=False, **defaults): super().__init__(enforce_csrf_checks, **defaults) self.tenant = tenant def get(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().get(path, data, **extra) def post(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().post(path, data, **extra) def patch(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().patch(path, data, **extra) def put(self, path, data={}, **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().put(path, data, **extra) def delete(self, path, data="", content_type="application/octet-stream", **extra): if "HTTP_HOST" not in extra: extra["HTTP_HOST"] = self.tenant.get_primary_domain().domain return super().delete(path, data, **extra) PK!@))django_pgschemas/utils.pyimport re from django.apps import apps from django.conf import settings from django.core.exceptions import ValidationError from django.core.management import call_command from django.db import connection, transaction, ProgrammingError, DEFAULT_DB_ALIAS def get_tenant_model(): return apps.get_model(settings.TENANTS["public"]["TENANT_MODEL"]) def get_domain_model(): return apps.get_model(settings.TENANTS["public"]["DOMAIN_MODEL"]) def get_tenant_database_alias(): return getattr(settings, "PGSCHEMAS_TENANT_DB_ALIAS", DEFAULT_DB_ALIAS) def get_limit_set_calls(): return getattr(settings, "PGSCHEMAS_LIMIT_SET_CALLS", False) def get_clone_reference(): return settings.TENANTS["default"].get("CLONE_REFERENCE", None) def is_valid_identifier(identifier): SQL_IDENTIFIER_RE = re.compile(r"^[_a-zA-Z][_a-zA-Z0-9]{,62}$") return bool(SQL_IDENTIFIER_RE.match(identifier)) def is_valid_schema_name(name): SQL_SCHEMA_NAME_RESERVED_RE = re.compile(r"^pg_", re.IGNORECASE) return is_valid_identifier(name) and not SQL_SCHEMA_NAME_RESERVED_RE.match(name) def check_identifier(identifier): if not is_valid_identifier(identifier): raise ValidationError("Invalid string used for the identifier.") def check_schema_name(name): if not is_valid_schema_name(name): raise ValidationError("Invalid string used for the schema name.") def remove_www(hostname): """ Removes www. from the beginning of the address. Only for routing purposes. www.test.com/login/ and test.com/login/ should find the same tenant. """ if hostname.startswith("www."): return hostname[4:] return hostname def run_in_public_schema(func): def wrapper(*args, **kwargs): from .volatile import VolatileTenant with VolatileTenant.create(schema_name="public"): return func(*args, **kwargs) return wrapper def schema_exists(schema_name): cursor = connection.cursor() cursor.execute( "SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE LOWER(nspname) = LOWER(%s))", (schema_name,) ) row = cursor.fetchone() if row: exists = row[0] else: exists = False cursor.close() return exists @run_in_public_schema def create_schema(schema_name, check_if_exists=False, sync_schema=True, verbosity=1): """ Creates the schema 'schema_name'. Optionally checks if the schema already exists before creating it. Returns true if the schema was created, false otherwise. """ check_schema_name(schema_name) if check_if_exists and schema_exists(schema_name): return False cursor = connection.cursor() cursor.execute("CREATE SCHEMA %s" % schema_name) if sync_schema: call_command("migrateschema", schema=schema_name, verbosity=verbosity) return True @run_in_public_schema def drop_schema(schema_name, check_if_exists=True, verbosity=1): if check_if_exists and not schema_exists(schema_name): return False cursor = connection.cursor() cursor.execute("DROP SCHEMA %s CASCADE" % schema_name) return True # Postgres' `clone_schema` adapted to work with schema names containing # capital letters or `-` # Source: IdanDavidi, https://stackoverflow.com/a/48732283/6412017 CLONE_SCHEMA_FUNCTION = """ -- Function: clone_schema(text, text) -- DROP FUNCTION clone_schema(text, text); CREATE OR REPLACE FUNCTION clone_schema( source_schema text, dest_schema text, include_recs boolean) RETURNS void AS $BODY$ -- This function will clone all sequences, tables, data, views & functions from any existing schema to a new one -- SAMPLE CALL: -- SELECT clone_schema('public', 'new_schema', TRUE); DECLARE src_oid oid; tbl_oid oid; func_oid oid; object text; buffer text; srctbl text; default_ text; column_ text; qry text; dest_qry text; v_def text; seqval bigint; sq_last_value bigint; sq_max_value bigint; sq_start_value bigint; sq_increment_by bigint; sq_min_value bigint; sq_cache_value bigint; sq_log_cnt bigint; sq_is_called boolean; sq_is_cycled boolean; sq_cycled char(10); BEGIN -- Check that source_schema exists SELECT oid INTO src_oid FROM pg_namespace WHERE nspname = source_schema; IF NOT FOUND THEN RAISE EXCEPTION 'Source schema % does not exist.', source_schema; RETURN ; END IF; -- Check that dest_schema does not yet exist PERFORM nspname FROM pg_namespace WHERE nspname = dest_schema; IF FOUND THEN RAISE EXCEPTION 'Destination schema % already exists.', dest_schema; RETURN ; END IF; EXECUTE 'CREATE SCHEMA "' || dest_schema || '"'; -- Create sequences -- TODO: Find a way to make this sequence's owner is the correct table. FOR object IN SELECT sequence_name::text FROM information_schema.sequences WHERE sequence_schema = source_schema LOOP EXECUTE 'CREATE SEQUENCE "' || dest_schema || '".' || quote_ident(object); srctbl := '"' || source_schema || '".' || quote_ident(object); EXECUTE 'SELECT last_value, max_value, start_value, increment_by, min_value, cache_value, log_cnt, is_cycled, is_called FROM "' || source_schema || '".' || quote_ident(object) || ';' INTO sq_last_value, sq_max_value, sq_start_value, sq_increment_by, sq_min_value, sq_cache_value, sq_log_cnt, sq_is_cycled, sq_is_called ; IF sq_is_cycled THEN sq_cycled := 'CYCLE'; ELSE sq_cycled := 'NO CYCLE'; END IF; EXECUTE 'ALTER SEQUENCE "' || dest_schema || '".' || quote_ident(object) || ' INCREMENT BY ' || sq_increment_by || ' MINVALUE ' || sq_min_value || ' MAXVALUE ' || sq_max_value || ' START WITH ' || sq_start_value || ' RESTART ' || sq_min_value || ' CACHE ' || sq_cache_value || sq_cycled || ' ;' ; buffer := '"' || dest_schema || '".' || quote_ident(object); IF include_recs THEN EXECUTE 'SELECT setval( ''' || buffer || ''', ' || sq_last_value || ', ' || sq_is_called || ');' ; ELSE EXECUTE 'SELECT setval( ''' || buffer || ''', ' || sq_start_value || ', ' || sq_is_called || ');' ; END IF; END LOOP; -- Create tables FOR object IN SELECT TABLE_NAME::text FROM information_schema.tables WHERE table_schema = source_schema AND table_type = 'BASE TABLE' LOOP buffer := '"' || dest_schema || '".' || quote_ident(object); EXECUTE 'CREATE TABLE ' || buffer || ' (LIKE "' || source_schema || '".' || quote_ident(object) || ' INCLUDING ALL)'; IF include_recs THEN -- Insert records from source table EXECUTE 'INSERT INTO ' || buffer || ' SELECT * FROM "' || source_schema || '".' || quote_ident(object) || ';'; END IF; FOR column_, default_ IN SELECT column_name::text, REPLACE(column_default::text, source_schema, dest_schema) FROM information_schema.COLUMNS WHERE table_schema = dest_schema AND TABLE_NAME = object AND column_default LIKE 'nextval(%"' || source_schema || '"%::regclass)' LOOP EXECUTE 'ALTER TABLE ' || buffer || ' ALTER COLUMN ' || column_ || ' SET DEFAULT ' || default_; END LOOP; END LOOP; -- add FK constraint FOR qry IN SELECT 'ALTER TABLE "' || dest_schema || '".' || quote_ident(rn.relname) || ' ADD CONSTRAINT ' || quote_ident(ct.conname) || ' ' || pg_get_constraintdef(ct.oid) || ';' FROM pg_constraint ct JOIN pg_class rn ON rn.oid = ct.conrelid WHERE connamespace = src_oid AND rn.relkind = 'r' AND ct.contype = 'f' LOOP EXECUTE qry; END LOOP; -- Create views FOR object IN SELECT table_name::text, view_definition FROM information_schema.views WHERE table_schema = source_schema LOOP buffer := '"' || dest_schema || '".' || quote_ident(object); SELECT view_definition INTO v_def FROM information_schema.views WHERE table_schema = source_schema AND table_name = quote_ident(object); EXECUTE 'CREATE OR REPLACE VIEW ' || buffer || ' AS ' || v_def || ';' ; END LOOP; -- Create functions FOR func_oid IN SELECT oid FROM pg_proc WHERE pronamespace = src_oid LOOP SELECT pg_get_functiondef(func_oid) INTO qry; SELECT replace(qry, source_schema, dest_schema) INTO dest_qry; EXECUTE dest_qry; END LOOP; RETURN; END; $BODY$ LANGUAGE plpgsql VOLATILE COST 100; ALTER FUNCTION clone_schema(text, text, boolean) OWNER TO postgres; """ class DryRunException(Exception): pass def _create_clone_schema_function(): """ Creates a postgres function `clone_schema` that copies a schema and its contents. Will replace any existing `clone_schema` functions owned by the `postgres` superuser. """ cursor = connection.cursor() cursor.execute(CLONE_SCHEMA_FUNCTION) cursor.close() @run_in_public_schema def clone_schema(base_schema_name, new_schema_name, dry_run=False): """ Creates a new schema `new_schema_name` as a clone of an existing schema `old_schema_name`. """ cursor = connection.cursor() # check if the clone_schema function already exists in the db try: cursor.execute("SELECT 'clone_schema'::regproc") except ProgrammingError: _create_clone_schema_function() transaction.commit() try: with transaction.atomic(): sql = "SELECT clone_schema(%(base_schema)s, %(new_schema)s, TRUE)" cursor.execute(sql, {"base_schema": base_schema_name, "new_schema": new_schema_name}) cursor.close() if dry_run: raise DryRunException except DryRunException: pass def create_or_clone_schema(schema_name, sync_schema=True, verbosity=1): """ Creates the schema 'schema_name'. Optionally checks if the schema already exists before creating it. Returns true if the schema was created, false otherwise. """ check_schema_name(schema_name) if schema_exists(schema_name): return False clone_reference = get_clone_reference() if clone_reference and schema_exists(clone_reference): clone_schema(clone_reference, schema_name) return True return create_schema(schema_name, sync_schema=sync_schema, verbosity=verbosity) PK!ݨփdjango_pgschemas/volatile.pyfrom django.db import connection class VolatileTenant: is_dynamic = False @staticmethod def create(schema_name, domain_url=None): tenant = VolatileTenant() tenant.schema_name = schema_name tenant.domain_url = domain_url return tenant def __enter__(self): """ Syntax sugar which helps in celery tasks, cron jobs, and other scripts Usage: with Tenant.objects.get(schema_name='test') as tenant: # run some code in tenant test # run some code in previous tenant (public probably) """ self.previous_schema_name = connection.schema_name self.previous_domain_url = connection.domain_url self.activate() def __exit__(self, exc_type, exc_val, exc_tb): connection.set_schema(self.previous_schema_name, self.previous_domain_url) def activate(self): """ Syntax sugar that helps at django shell with fast tenant changing Usage: Tenant.objects.get(schema_name='test').activate() """ connection.set_schema(self.schema_name, self.domain_url) @classmethod def deactivate(cls): """ Syntax sugar, return to public schema Usage: test_tenant.deactivate() # or simpler Tenant.deactivate() """ connection.set_schema_to_public() PK!kE..(django_pgschemas-0.1.2.dist-info/LICENSEMIT License Copyright (c) 2019 Lorenzo Peña Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HnHTU&django_pgschemas-0.1.2.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!Hp !)django_pgschemas-0.1.2.dist-info/METADATAYrϧx4Todjk;;DBb`Z3$EYK~%3ay-$Wb_xPraȹUt]9!;2+VHLdł]*cZ\FqE 4`!ʼnҲQ Hp*Q:{7~ҩ%9 ^)Wr_hT٥Cv(~SR<QtgO19W`2_f)E:g<]AɓR e&bscC˚@:*d^3M*MɭЃ@hyza:Y߮@GbfC|v;Dqg s,JhmdZ`;KqX{:3 ϫB7'FU:fɵgp\ K.NE7VŠ^kJͭO c 8-(mU :?Q4^JxY0Ri˨zNg0gT{2[zT:8\u`F6%C\&gQwNbds#{FZVq[?Y&45Kg@~йasd `V֌gC]bZ2'/`3(0fP4f®("T~FV$ R53+oGKQ>!Vc"}*+҃Spr;g9+yQ:SFĨ2z$'4Df,-R BSA٠Q*a([(2 LqYb:-Y&4cLt0NEm#2&˴ǴE>A3:q>!U(SO B!A}N0mp2CE -ʷAxtҕx'9wo-!^y{F4u%G($vܮcYίUaӳwǧ'?NSd  a_ 4B5Q൧ *ӳ$\i_}8OE'W'W!i@+?NE Q$)_K}x?]5GY \.TP/|t>qw]|tyy:O7t)Ch9s-@}l'u78> ۮs|K]R`LB-{gw;7+dƆ7lڑ)sL .нa@Q.4-٦M;|i NeBM%pyg CqKDiFY i‡>ca\%N>4!ee$ 3xjRV| h-IB :dxH2ARI'[#d0w5i2OS~-Cg5S>本{:uaF=Wz=3Br=w鱽ՇX+.B&0ŠΫ*M4ٺpp06~uq1Өɼ2.V\ g?Ѓ߸$gTAj oVvg^hk k B.Y?X]_>ı_: L[}44.n|}#sGA9T]>LASSQ6*voB$$.DY,q ԼC)oV~N@kk U6yלB01OMJ:n?ɢ|u_ 1_7HZC lG-$a/_8hK;Ȇ]c2^u%z}lwb|o/`MJ8ԤP *Fظd KPD.8iއf:n:@"\1e0B-W!iւ_:?๒YpС /R޷ $ + 7AGGёI5)@7oxmHܣ_]j}/ʄp/t= Zҫ5 )xL9Oil-vv9ڄgٚ:(`\'Ic &׍S9@}9,\f|,{T#'%o7/qC*brG$ː&ʅNms_JIS/n↰ BX|eE@Seq[ҞFNxD0~hcy[Ƕҹ{C׵PIHVRp =Zڮ]urb "UW"~+4.ۦSLwV{D^' ;!L! v*.D~nd&]ѸN*6&$7~vhHӃ(!ta29Iw-\ؼgoI݌l}nZ<\4&-AzobUF(WW"1:vU\{MITVy?꾬WȖmYty22%) H^*$!YGѯ%>vDDxoYbUC[r|<4%Mf#Bį8W~j4%Ԭv4in^sHcPo8$;"+nLNSt i;i9g 0juTI3%X@0 ]=Qc&.#xSKbI+;]'sҫho H5SLW7VBo<$L:Vq\Ijߨ+ ]g)~"Q0,^m!g˅A{i)!>(,A5l%,D0,uMZ S%Lߓa^ 8,OfPcÐO.{AUG"A]Cհh UŜNiފgkV}6S vdyd[dW0"7)k&,AGWM i]Œ>zF3C] J>a ;CWE\.[1уysoMZeDV֧hG}N-Om*Ex` +aha٧qªX}ebWNk>/C85q Pԁ'[kl%pF#O8@2R 8W )kx~bt'{eeHg*S5$1EU|vQC/[WT;-8U`hj4p3Au'qq  DNuo}&#sN Su_kEf99 & atL {mT ~?a`Z?o"+:K(VU.W BzWgI-q%3Ft>> ~5/dڢB_k@Ui\ c _vvgFEQ`iQA9_ߗþè1"C?PK!ACCdjango_pgschemas/__init__.pyPK!wB}django_pgschemas/apps.pyPK!ɕ큰django_pgschemas/cache.pyPK!X3UpRR%django_pgschemas/channels/__init__.pyPK!,]Jll!zdjango_pgschemas/channels/auth.pyPK!*CI I #%django_pgschemas/channels/router.pyPK!NN큯)django_pgschemas/log.pyPK!'2+django_pgschemas/management/__init__.pyPK!,b--0w+django_pgschemas/management/commands/__init__.pyPK!lvv2Edjango_pgschemas/management/commands/_executors.pyPK!x883django_pgschemas/postgresql_backend/_constraints.pyPK!WO+pdjango_pgschemas/postgresql_backend/base.pyPK!++4ǯdjango_pgschemas/postgresql_backend/introspection.pyPK!vDdjango_pgschemas/routers.pyPK!vxxdjango_pgschemas/signals.pyPK!!django_pgschemas/test/__init__.pyPK!Iq django_pgschemas/test/cases.pyPK!: : django_pgschemas/test/client.pyPK!@))큆django_pgschemas/utils.pyPK!ݨփRdjango_pgschemas/volatile.pyPK!kE..(django_pgschemas-0.1.2.dist-info/LICENSEPK!HnHTU&django_pgschemas-0.1.2.dist-info/WHEELPK!Hp !)django_pgschemas-0.1.2.dist-info/METADATAPK!Hp '\+django_pgschemas-0.1.2.dist-info/RECORDPK u 31