From 0c7e800b2596727539a10825a89575c719754129 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Fri, 22 Apr 2011 11:14:55 -0700 Subject: [PATCH 001/196] Adding a modeladmin class allowing one to restrict access to the admin per-site. --- multisite/admin.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 multisite/admin.py diff --git a/multisite/admin.py b/multisite/admin.py new file mode 100644 index 0000000..42e9a90 --- /dev/null +++ b/multisite/admin.py @@ -0,0 +1,72 @@ +from django.contrib import admin + +class MultisiteModelAdmin(admin.ModelAdmin): + """ + A very helpful modeladmin class for handling multi-site django applications. + """ + def queryset(self, request): + """ + Filters lists of items to items belonging to sites assigned to the + current member. + + Additionally, for cases where the field containing a reference to 'site' + or 'sites' isn't immediate-- one can supply the ModelAdmin class with + a list of fields to check the site of: + + - multisite_filter_fields + A list of paths to a 'site' or 'sites' field on a related model to + filter the queryset on. + + (As long as you're not a superuser) + """ + qs = super(MultisiteModelAdmin, self).queryset(request) + if(request.user.is_superuser): + return qs + user_sites = request.user.get_profile().sites.all() + if(hasattr(qs.model, "site")): + qs = qs.filter( + site__in = user_sites + ) + elif(hasattr(qs.model, "sites")): + qs = qs.filter( + sites__in = user_sites + ) + if(hasattr(self, "multisite_filter_fields")): + for field in self.multisite_filter_fields: + qkwargs = { + "{field}__in".format(field = field): user_sites + } + qs = qs.filter(**qkwargs) + return qs + + def handle_multisite_foreign_keys(self, db_field, request, **kwargs): + """ + Filters the foreignkey queryset for fields referencing other models + to those models assigned to a site belonging to the current member. + + Also prevents users from assigning objects to sites that they are not + members of. + + (As long as you're not a superuser) + """ + if(not request.user.is_superuser): + user_sites = request.user.get_profile().sites.all() + if(hasattr(db_field.rel.to, "site")): + kwargs["queryset"] = db_field.rel.to.objects.filter( + site__in = user_sites + ) + if(hasattr(db_field.rel.to, "sites")): + kwargs["queryset"] = db_field.rel.to.objects.filter( + sites__in = user_sites + ) + if db_field.name == "site" or db_field.name == "sites": + kwargs["queryset"] = user_sites + return kwargs + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + kwargs = self.handle_multisite_foreign_keys(db_field, request, **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + kwargs = self.handle_multisite_foreign_keys(db_field, request, **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) From c4717c10b66825cad8f724cb69c79f44d76730fc Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Fri, 29 Apr 2011 15:34:36 -0700 Subject: [PATCH 002/196] Adding a subclass of CurrentSiteManager accepting a path to a 'site'/'sites' field. --- managers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 managers.py diff --git a/managers.py b/managers.py new file mode 100644 index 0000000..6ce5369 --- /dev/null +++ b/managers.py @@ -0,0 +1,12 @@ +from django.contrib.sites.managers import CurrentSiteManager +from django.contrib.sites.models import Site + +class PathAssistedCurrentSiteManager(CurrentSiteManager): + def __init__(self, field_path): + super(PathAssistedCurrentSiteManager, self).__init__() + self.__field_path = field_path + + def get_query_set(self): + return super(CurrentSiteManager, self).get_query_set().filter( + **{self.__field_path: Site.objects.get_current()} + ) From 8a606f127d19dce6264ebf224e4d14b5f0dce565 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Fri, 29 Apr 2011 15:37:03 -0700 Subject: [PATCH 003/196] Adding better handling of distant 'site' or 'sites' foreign keys. --- multisite/admin.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/multisite/admin.py b/multisite/admin.py index 42e9a90..7efd0f8 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -47,6 +47,21 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): Also prevents users from assigning objects to sites that they are not members of. + If the foreign key does not have a site/sites field directly, you can + specify a path to a site/sites field to filter on by setting the key: + + - multisite_foreign_key_site_path + + to a dictionary pointing specific foreign key field instances from their + model to the site field to filter on something like: + + multisite_indirect_foreign_key_path = { + 'plan_instance': 'plan__site' + } + + for a field named 'plan_instance' referencing a model with a foreign key + named 'plan' having a foreign key to 'site'. + (As long as you're not a superuser) """ if(not request.user.is_superuser): @@ -61,6 +76,14 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): ) if db_field.name == "site" or db_field.name == "sites": kwargs["queryset"] = user_sites + if(hasattr(self, "multisite_indirect_foreign_key_path")): + if db_field.name in self.multisite_indirect_foreign_key_path.keys(): + qkwargs = { + self.multisite_indirect_foreign_key_path[db_field.name]: user_sites + } + kwargs["queryset"] = db_field.rel.to.objects.filter( + **qkwargs + ) return kwargs def formfield_for_foreignkey(self, db_field, request, **kwargs): From a6ee0eb95d9eef2ce46576f5dd3b9981a1521bad Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Fri, 29 Apr 2011 15:39:08 -0700 Subject: [PATCH 004/196] Moving managers to the proper place. --- managers.py => multisite/managers.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename managers.py => multisite/managers.py (100%) diff --git a/managers.py b/multisite/managers.py similarity index 100% rename from managers.py rename to multisite/managers.py From 3c30e4e5ff1d12f7467dab88b115e8a185bd84d5 Mon Sep 17 00:00:00 2001 From: Robert Hall Date: Mon, 16 May 2011 13:45:37 -0700 Subject: [PATCH 005/196] Fixing admin to use "_default_manager", rather than the usual "objects". --- multisite/admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index 7efd0f8..fa1bfe3 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -67,11 +67,11 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): if(not request.user.is_superuser): user_sites = request.user.get_profile().sites.all() if(hasattr(db_field.rel.to, "site")): - kwargs["queryset"] = db_field.rel.to.objects.filter( + kwargs["queryset"] = db_field.rel.to._default_manager.filter( site__in = user_sites ) if(hasattr(db_field.rel.to, "sites")): - kwargs["queryset"] = db_field.rel.to.objects.filter( + kwargs["queryset"] = db_field.rel.to._default_manager.filter( sites__in = user_sites ) if db_field.name == "site" or db_field.name == "sites": @@ -81,7 +81,7 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): qkwargs = { self.multisite_indirect_foreign_key_path[db_field.name]: user_sites } - kwargs["queryset"] = db_field.rel.to.objects.filter( + kwargs["queryset"] = db_field.rel.to._default_manager.filter( **qkwargs ) return kwargs From ddbf6ab048e70c0cd844aaf8c589907a9c034af9 Mon Sep 17 00:00:00 2001 From: Aleksandr Zorin Date: Wed, 6 Jul 2011 12:36:24 +0400 Subject: [PATCH 006/196] new template loader --- multisite/template_loader.py | 55 ++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/multisite/template_loader.py b/multisite/template_loader.py index 7b36582..d63d8f1 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -1,28 +1,33 @@ -from django.template import TemplateDoesNotExist -from django.utils._os import safe_join -import os.path -from django.contrib.sites.models import Site +# -*- coding: utf-8 -*- + from django.conf import settings +from django.contrib.sites.models import Site +from django.template.loaders.filesystem import Loader as FilesystemLoader +from django.utils._os import safe_join + + +class Loader(FilesystemLoader): + def get_template_sources(self, template_name, template_dirs=None): + if not template_dirs: + template_dirs = settings.TEMPLATE_DIRS + + domain = Site.objects.get_current().domain + default_dir = getattr(settings, 'MULTISITE_DEFAULT_DIR', 'default') -def get_template_sources(template_name, template_dirs=None): - template_dir = os.path.join(settings.TEMPLATE_DIRS[0], Site.objects.get_current().domain) - try: - yield safe_join(template_dir, template_name) - except UnicodeDecodeError: - raise - except ValueError: - pass + new_template_dirs = [] + for template_dir in template_dirs: + new_template_dirs.append(safe_join(template_dir, domain)) + if default_dir: + new_template_dirs.append(safe_join(template_dir, default_dir)) -def load_template_source(template_name, template_dirs=None): - tried = [] - for filepath in get_template_sources(template_name, template_dirs): - try: - return (open(filepath).read().decode(settings.FILE_CHARSET), filepath) - except IOError: - tried.append(filepath) - if tried: - error_msg = "Tried %s" % tried - else: - error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." - raise TemplateDoesNotExist, error_msg -load_template_source.is_usable = True + for template_dir in new_template_dirs: + try: + yield safe_join(template_dir, template_name) + except UnicodeDecodeError: + # The template dir name was a bytestring that wasn't valid UTF-8. + raise + except ValueError: + # The joined path was located outside of this particular + # template_dir (it might be inside another one, so this isn't + # fatal). + pass From 76dc0375c0d62598316a8ed6a0a6e7840ecd274e Mon Sep 17 00:00:00 2001 From: Aleksandr Zorin Date: Wed, 6 Jul 2011 12:45:07 +0400 Subject: [PATCH 007/196] update README --- README.markdown | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.markdown b/README.markdown index 0d41b24..93243aa 100644 --- a/README.markdown +++ b/README.markdown @@ -3,7 +3,7 @@ README Get the code via svn: - git clone git://github.com/shestera/django-multisite.git django-multisite + git clone git://github.com/plazix/django-multisite.git django-multisite Add the django-multisite/multisite folder to your PYTHONPATH. @@ -15,8 +15,8 @@ Replace your SITE_ID in settings.py to: Add to settings.py TEMPLATE_LOADERS: TEMPLATE_LOADERS = ( - 'multisite.template_loader.load_template_source', - 'django.template.loaders.app_directories.load_template_source', + 'multisite.template_loader.Loader', + 'django.template.loaders.app_directories.Loader', ) Edit to settings.py MIDDLEWARE_CLASSES: From f81420562412253a2364bf1fbf366bbe29dc95a4 Mon Sep 17 00:00:00 2001 From: Robert Hall Date: Fri, 8 Jul 2011 16:23:35 -0700 Subject: [PATCH 008/196] Now hiding change list site filters which your user isn't allowed to use. --- multisite/admin.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/multisite/admin.py b/multisite/admin.py index fa1bfe3..c4c26bd 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -1,4 +1,43 @@ from django.contrib import admin +from django.contrib.admin.views.main import ChangeList +from django.contrib.sites.models import Site + + +class MultisiteChangeList(ChangeList): + """ + A ChangeList like the built-in admin one, but it excludes site filters for + sites you're not associated with, unless you're a super-user. + + At this point, it's probably fragile, given its reliance on Django internals. + """ + def get_filters(self, request, *args, **kwargs): + """ + This might be considered a fragile function, since it relies on a fair bit + of Django's internals. + """ + filter_specs, has_filter_specs = super(MultisiteChangeList, self).get_filters(request, *args, **kwargs) + if request.user.is_superuser or not has_filter_specs: + return filter_specs, has_filter_specs + new_filter_specs = [] + user_sites = frozenset(request.user.get_profile().sites.values_list("pk", "domain")) + for filter_spec in filter_specs: + try: + rel_to = filter_spec.field.rel.to + except AttributeError: + new_filter_specs.append(filter_spec) + continue + if rel_to is not Site: + new_filter_specs.append(filter_spec) + continue + lookup_choices = frozenset(filter_spec.lookup_choices) & user_sites + if len(lookup_choices) > 1: + # put the choices back into the form they came in + filter_spec.lookup_choices = list(lookup_choices) + filter_spec.lookup_choices.sort() + new_filter_specs.append(filter_spec) + + return new_filter_specs, bool(new_filter_specs) + class MultisiteModelAdmin(admin.ModelAdmin): """ @@ -93,3 +132,11 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): def formfield_for_manytomany(self, db_field, request, **kwargs): kwargs = self.handle_multisite_foreign_keys(db_field, request, **kwargs) return super(MultisiteModelAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) + + def get_changelist(self, request, **kwargs): + """ + Restrict the site filter (if there is one) to sites you are associated with, + or remove it entirely if you're just associated with one site. Unless you're a + super-user, of course. + """ + return MultisiteChangeList From cfc049e1e2822d16f54e09db1e395079da50c0b6 Mon Sep 17 00:00:00 2001 From: Robert Hall Date: Sat, 9 Jul 2011 20:42:05 -0700 Subject: [PATCH 009/196] Added option to limit FK fields to objects that belong to the same site as the current object being edited. --- multisite/admin.py | 88 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index c4c26bd..00e691e 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -43,6 +43,9 @@ class MultisiteModelAdmin(admin.ModelAdmin): """ A very helpful modeladmin class for handling multi-site django applications. """ + + filter_sites_by_current_object = False + def queryset(self, request): """ Filters lists of items to items belonging to sites assigned to the @@ -78,13 +81,33 @@ def queryset(self, request): qs = qs.filter(**qkwargs) return qs + def add_view(self, request, form_url = '', extra_context = None): + if self.filter_sites_by_current_object: + if hasattr(self.model, "site") or hasattr(self.model, "sites"): + self.object_sites = tuple() + return super(MultisiteModelAdmin, self).add_view(request, form_url, extra_context) + + def change_view(self, request, object_id, extra_context = None): + if self.filter_sites_by_current_object: + object_instance = self.get_object(request, object_id) + try: + self.object_sites = object_instance.sites.values_list("pk", flat = True) + except AttributeError: + try: + self.object_sites = (object_instance.site.pk, ) + except AttributeError: + pass # assume the object doesn't belong to a site + return super(MultisiteModelAdmin, self).change_view(request, object_id, extra_context) + def handle_multisite_foreign_keys(self, db_field, request, **kwargs): - """ - Filters the foreignkey queryset for fields referencing other models - to those models assigned to a site belonging to the current member. + """ + Filters the foreignkey queryset for fields referencing other models + to those models assigned to a site belonging to the current member + (if they aren't a superuser), and (optionally) belonging to the same + site as the current object. - Also prevents users from assigning objects to sites that they are not - members of. + Also prevents (non-super) users from assigning objects to sites that + they are not members of. If the foreign key does not have a site/sites field directly, you can specify a path to a site/sites field to filter on by setting the key: @@ -101,28 +124,47 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): for a field named 'plan_instance' referencing a model with a foreign key named 'plan' having a foreign key to 'site'. - (As long as you're not a superuser) + To filter the FK queryset to the same sites the current object belongs + to, simply set `filter_sites_by_current_object` to `True`. + + Caveats: + + 1) If you're adding an object that belongs to a site (or sites), + and you've set `self.limit_sites_by_current_object = True`, + then the FK fields to objects that also belong to a site won't show + any objects. This is due to filtering on an empty queryset. """ - if(not request.user.is_superuser): + + if request.user.is_superuser: + user_sites = Site.objects.all() + else: user_sites = request.user.get_profile().sites.all() - if(hasattr(db_field.rel.to, "site")): - kwargs["queryset"] = db_field.rel.to._default_manager.filter( - site__in = user_sites - ) - if(hasattr(db_field.rel.to, "sites")): + if self.filter_sites_by_current_object and hasattr(self, "object_sites"): + sites = user_sites.filter( + pk__in = self.object_sites + ) + else: + sites = user_sites + + if(hasattr(db_field.rel.to, "site")): + kwargs["queryset"] = db_field.rel.to._default_manager.filter( + site__in = sites + ) + if(hasattr(db_field.rel.to, "sites")): + kwargs["queryset"] = db_field.rel.to._default_manager.filter( + sites__in = sites + ) + if db_field.name == "site" or db_field.name == "sites": + kwargs["queryset"] = user_sites + if(hasattr(self, "multisite_indirect_foreign_key_path")): + if db_field.name in self.multisite_indirect_foreign_key_path.keys(): + qkwargs = { + self.multisite_indirect_foreign_key_path[db_field.name]: sites + } kwargs["queryset"] = db_field.rel.to._default_manager.filter( - sites__in = user_sites + **qkwargs ) - if db_field.name == "site" or db_field.name == "sites": - kwargs["queryset"] = user_sites - if(hasattr(self, "multisite_indirect_foreign_key_path")): - if db_field.name in self.multisite_indirect_foreign_key_path.keys(): - qkwargs = { - self.multisite_indirect_foreign_key_path[db_field.name]: user_sites - } - kwargs["queryset"] = db_field.rel.to._default_manager.filter( - **qkwargs - ) + return kwargs def formfield_for_foreignkey(self, db_field, request, **kwargs): From 9a130ab8446586e313d72cb71f1fb63622295928 Mon Sep 17 00:00:00 2001 From: Aleksandr Zorin Date: Mon, 11 Jul 2011 09:33:35 +0400 Subject: [PATCH 010/196] pep8 --- multisite/admin.py | 48 +++++++++++++++++------------------- multisite/managers.py | 1 + multisite/middleware.py | 3 +++ multisite/template_loader.py | 2 +- multisite/threadlocals.py | 3 +++ 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index fa1bfe3..e4e7ac8 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin + class MultisiteModelAdmin(admin.ModelAdmin): """ A very helpful modeladmin class for handling multi-site django applications. @@ -20,23 +21,22 @@ def queryset(self, request): (As long as you're not a superuser) """ qs = super(MultisiteModelAdmin, self).queryset(request) - if(request.user.is_superuser): + if request.user.is_superuser: return qs + user_sites = request.user.get_profile().sites.all() - if(hasattr(qs.model, "site")): - qs = qs.filter( - site__in = user_sites - ) - elif(hasattr(qs.model, "sites")): - qs = qs.filter( - sites__in = user_sites - ) - if(hasattr(self, "multisite_filter_fields")): + if hasattr(qs.model, "site"): + qs = qs.filter(site__in = user_sites) + elif hasattr(qs.model, "sites"): + qs = qs.filter(sites__in = user_sites) + + if hasattr(self, "multisite_filter_fields"): for field in self.multisite_filter_fields: qkwargs = { - "{field}__in".format(field = field): user_sites - } + "{field}__in".format(field = field): user_sites + } qs = qs.filter(**qkwargs) + return qs def handle_multisite_foreign_keys(self, db_field, request, **kwargs): @@ -64,26 +64,24 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): (As long as you're not a superuser) """ - if(not request.user.is_superuser): + if not request.user.is_superuser: user_sites = request.user.get_profile().sites.all() - if(hasattr(db_field.rel.to, "site")): + if hasattr(db_field.rel.to, "site"): kwargs["queryset"] = db_field.rel.to._default_manager.filter( - site__in = user_sites - ) - if(hasattr(db_field.rel.to, "sites")): + site__in = user_sites + ) + if hasattr(db_field.rel.to, "sites"): kwargs["queryset"] = db_field.rel.to._default_manager.filter( - sites__in = user_sites - ) + sites__in = user_sites + ) if db_field.name == "site" or db_field.name == "sites": kwargs["queryset"] = user_sites - if(hasattr(self, "multisite_indirect_foreign_key_path")): + if hasattr(self, "multisite_indirect_foreign_key_path"): if db_field.name in self.multisite_indirect_foreign_key_path.keys(): qkwargs = { - self.multisite_indirect_foreign_key_path[db_field.name]: user_sites - } - kwargs["queryset"] = db_field.rel.to._default_manager.filter( - **qkwargs - ) + self.multisite_indirect_foreign_key_path[db_field.name]: user_sites + } + kwargs["queryset"] = db_field.rel.to._default_manager.filter(**qkwargs) return kwargs def formfield_for_foreignkey(self, db_field, request, **kwargs): diff --git a/multisite/managers.py b/multisite/managers.py index 6ce5369..2ce0a9a 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -1,6 +1,7 @@ from django.contrib.sites.managers import CurrentSiteManager from django.contrib.sites.models import Site + class PathAssistedCurrentSiteManager(CurrentSiteManager): def __init__(self, field_path): super(PathAssistedCurrentSiteManager, self).__init__() diff --git a/multisite/middleware.py b/multisite/middleware.py index ec34832..0c5a200 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- + from django.conf import settings from django.contrib.sites.models import Site + HOST_CACHE = {} + class DynamicSiteMiddleware(object): def process_request(self, request): host = request.get_host() diff --git a/multisite/template_loader.py b/multisite/template_loader.py index d63d8f1..4f57a7a 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -12,7 +12,7 @@ def get_template_sources(self, template_name, template_dirs=None): template_dirs = settings.TEMPLATE_DIRS domain = Site.objects.get_current().domain - default_dir = getattr(settings, 'MULTISITE_DEFAULT_DIR', 'default') + default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 'default') new_template_dirs = [] for template_dir in template_dirs: diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 85912f5..48d72d2 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -5,11 +5,14 @@ except ImportError: from django.utils._threading_local import local + _thread_locals = local() + def get_request(): return getattr(_thread_locals, 'request', None) + class ThreadLocalsMiddleware(object): """Middleware that saves request in thread local starage""" def process_request(self, request): From 64cc32b41d013d7e9c83b1b48b1467c1193d8f72 Mon Sep 17 00:00:00 2001 From: Robert Hall Date: Mon, 11 Jul 2011 11:16:23 -0700 Subject: [PATCH 011/196] Updated README --- README.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.markdown b/README.markdown index 0d41b24..ea73c89 100644 --- a/README.markdown +++ b/README.markdown @@ -1,7 +1,7 @@ README ====== -Get the code via svn: +Get the code via git: git clone git://github.com/shestera/django-multisite.git django-multisite From d73c379bf6f7b4e1cd4fdc61e6c3b29f1a9bb0ef Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 1 Feb 2012 14:20:56 -0500 Subject: [PATCH 012/196] SiteIDHook supports comparisons to int and long. This makes django.contrib.sites.tests pass. --- multisite/threadlocals.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 48d72d2..c1cc2e8 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -30,6 +30,30 @@ def __int__(self): _thread_locals.SITE_ID = 1 return _thread_locals.SITE_ID + def __lt__(self, other): + if isinstance(other, (int, long)): + return self.__int__() < other + return super(SiteIDHook, self).__lt__(other) + + def __le__(self, other): + if isinstance(other, (int, long)): + return self.__int__() <= other + return super(SiteIDHook, self).__le__(other) + + def __eq__(self, other): + if isinstance(other, (int, long)): + return self.__int__() == other + return super(SiteIDHook, self).__eq__(other) + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + return not self.__le__(other) + + def __ge__(self, other): + return not self.__lt__(other) + def __hash__(self): return self.__int__() From e1cfa4ef53a5cb0bd5c3b23cf1ec49095567f8b2 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 17 Apr 2012 15:16:50 -0400 Subject: [PATCH 013/196] Tests for DynamicSiteMiddleware --- multisite/models.py | 0 multisite/tests.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 multisite/models.py create mode 100644 multisite/tests.py diff --git a/multisite/models.py b/multisite/models.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/tests.py b/multisite/tests.py new file mode 100644 index 0000000..55dfb77 --- /dev/null +++ b/multisite/tests.py @@ -0,0 +1,96 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.test import TestCase +from django.test.client import RequestFactory as DjangoRequestFactory +from django.utils.unittest import skipUnless + +try: + from django.test.utils import override_settings +except ImportError: + from override_settings import override_settings + +from multisite.middleware import DynamicSiteMiddleware, HOST_CACHE +from multisite.threadlocals import SiteIDHook, _thread_locals + + +class RequestFactory(DjangoRequestFactory): + def __init__(self, host): + super(RequestFactory, self).__init__() + self.host = host + + def get(self, path, data={}, host=None, **extra): + if host is None: + host = self.host + return super(RequestFactory, self).get(path=path, data=data, + HTTP_HOST=host, **extra) + + +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings(SITE_ID=SiteIDHook()) +class DynamicSiteMiddlewareTest(TestCase): + def setUp(self): + self.host = 'example.com' + self.factory = RequestFactory(host=self.host) + + Site.objects.all().delete() + self.site = Site.objects.create(domain=self.host) + + HOST_CACHE.clear() + self.middleware = DynamicSiteMiddleware() + + def tearDown(self): + HOST_CACHE.clear() + try: + del _thread_locals.SITE_ID + except AttributeError: + pass + + def test_valid_domain(self): + # Make the request + request = self.factory.get('/') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Request again + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_valid_domain_port(self): + # Make the request with a specific port + request = self.factory.get('/', host=self.host + ':8000') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Request again + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_change_domain(self): + # Make the request + request = self.factory.get('/') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Another request with a different site + site2 = Site.objects.create(domain='anothersite.example') + request = self.factory.get('/', host=site2.domain) + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, site2.pk) + + def test_invalid_domain(self): + # Make the request + request = self.factory.get('/', host='invalid') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + + def test_invalid_domain_port(self): + # Make the request + request = self.factory.get('/', host=':8000') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + + def test_no_sites(self): + # Remove all Sites + Site.objects.all().delete() + # Make the request + request = self.factory.get('/') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, 1) From 77cd208a3320e498c00b23bf00eda9ad1239206e Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 15:32:59 -0400 Subject: [PATCH 014/196] Tests for SiteIDHook. --- multisite/tests.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 55dfb77..419d4b8 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -94,3 +94,44 @@ def test_no_sites(self): request = self.factory.get('/') self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, 1) + + +class TestSiteIDHook(TestCase): + def setUp(self): + self.host = 'example.com' + + Site.objects.all().delete() + self.site = Site.objects.create(domain=self.host) + + self.reset_site_id() + self.site_id = SiteIDHook() + + def tearDown(self): + self.reset_site_id() + + def reset_site_id(self): + try: + del _thread_locals.SITE_ID + except AttributeError: + pass + + def test_compare_default_site_id(self): + # Default SITE_ID is 1 + self.assertEqual(self.site_id, 1) + self.assertFalse(self.site_id != 1) + self.assertFalse(self.site_id < 1) + self.assertTrue(self.site_id <= 1) + self.assertFalse(self.site_id > 1) + self.assertTrue(self.site_id >= 1) + + def test_set(self): + self.site_id.set(10) + self.assertEqual(int(self.site_id), 10) + self.site_id.set(20) + self.assertEqual(int(self.site_id), 20) + + def test_hash(self): + self.site_id.set(10) + self.assertEqual(hash(self.site_id), 10) + self.site_id.set(20) + self.assertEqual(hash(self.site_id), 20) From 49d6ef2d42c1226b1ecdb583ae7223fcaf432817 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 15:33:18 -0400 Subject: [PATCH 015/196] SiteIDHook() now properly compares with itself and other types. --- multisite/tests.py | 26 ++++++++++++++++++++++++++ multisite/threadlocals.py | 12 +++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 419d4b8..9d00bc7 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -124,6 +124,32 @@ def test_compare_default_site_id(self): self.assertFalse(self.site_id > 1) self.assertTrue(self.site_id >= 1) + def test_compare_site_ids(self): + self.site_id.set(1) + self.assertEqual(self.site_id, self.site_id) + self.assertFalse(self.site_id != self.site_id) + self.assertFalse(self.site_id < self.site_id) + self.assertTrue(self.site_id <= self.site_id) + self.assertFalse(self.site_id > self.site_id) + self.assertTrue(self.site_id >= self.site_id) + + def test_compare_differing_types(self): + self.site_id.set(1) + # SiteIDHook int + self.assertNotEqual(self.site_id, '1') + self.assertFalse(self.site_id == '1') + self.assertTrue(self.site_id < '1') + self.assertTrue(self.site_id <= '1') + self.assertFalse(self.site_id > '1') + self.assertFalse(self.site_id >= '1') + # int SiteIDHook + self.assertNotEqual('1', self.site_id) + self.assertFalse('1' == self.site_id) + self.assertFalse('1' < self.site_id) + self.assertFalse('1' <= self.site_id) + self.assertTrue('1' > self.site_id) + self.assertTrue('1' >= self.site_id) + def test_set(self): self.site_id.set(10) self.assertEqual(int(self.site_id), 10) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index c1cc2e8..97a7d8d 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -33,17 +33,23 @@ def __int__(self): def __lt__(self, other): if isinstance(other, (int, long)): return self.__int__() < other - return super(SiteIDHook, self).__lt__(other) + elif isinstance(other, SiteIDHook): + return self.__int__() < other.__int__() + return True def __le__(self, other): if isinstance(other, (int, long)): return self.__int__() <= other - return super(SiteIDHook, self).__le__(other) + elif isinstance(other, SiteIDHook): + return self.__int__() <= other.__int__() + return True def __eq__(self, other): if isinstance(other, (int, long)): return self.__int__() == other - return super(SiteIDHook, self).__eq__(other) + elif isinstance(other, SiteIDHook): + return self.__int__() == other.__int__() + return False def __ne__(self, other): return not self.__eq__(other) From b2b63eed4017576bec476ab8915d93cbcac101ce Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 15:55:33 -0400 Subject: [PATCH 016/196] Test case to verify that Site.objects.get_current() still works. --- multisite/tests.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 9d00bc7..9103757 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -25,6 +25,27 @@ def get(self, path, data={}, host=None, **extra): HTTP_HOST=host, **extra) +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings(SITE_ID=SiteIDHook(), DEBUG=True) +class TestContribSite(TestCase): + def setUp(self): + self.host = 'example.com' + self.site = Site.objects.create(domain=self.host) + settings.SITE_ID.set(self.site.id) + + def tearDown(self): + try: + del _thread_locals.SITE_ID + except AttributeError: + pass + + def test_get_current_site(self): + current_site = Site.objects.get_current() + self.assertEqual(current_site, self.site) + self.assertEqual(current_site.id, settings.SITE_ID) + + @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings(SITE_ID=SiteIDHook()) From 222b58621e40257bf8422376fe5fd37da6125c91 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 16:15:07 -0400 Subject: [PATCH 017/196] SiteIDHook.set() knows about Site models now. --- multisite/tests.py | 2 ++ multisite/threadlocals.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 9103757..7410f08 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -176,6 +176,8 @@ def test_set(self): self.assertEqual(int(self.site_id), 10) self.site_id.set(20) self.assertEqual(int(self.site_id), 20) + self.site_id.set(self.site) + self.assertEqual(int(self.site_id), self.site.id) def test_hash(self): self.site_id.set(10) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 97a7d8d..c78e058 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -64,4 +64,7 @@ def __hash__(self): return self.__int__() def set(self, value): + from django.db.models import Model + if isinstance(value, Model): + value = value.pk _thread_locals.SITE_ID = value From 1fee71c6a17429ae099b991ee14d4fb7de34c9a7 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 17:18:02 -0400 Subject: [PATCH 018/196] Make SiteIDHook into a threading.local object. This is so that you can have multiple SiteIDHooks, if you so desire. The singleton should be enforced by settings.py, not by a module-level global. --- multisite/tests.py | 22 ++-------------------- multisite/threadlocals.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 7410f08..f90df43 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -34,12 +34,6 @@ def setUp(self): self.site = Site.objects.create(domain=self.host) settings.SITE_ID.set(self.site.id) - def tearDown(self): - try: - del _thread_locals.SITE_ID - except AttributeError: - pass - def test_get_current_site(self): current_site = Site.objects.get_current() self.assertEqual(current_site, self.site) @@ -62,10 +56,7 @@ def setUp(self): def tearDown(self): HOST_CACHE.clear() - try: - del _thread_locals.SITE_ID - except AttributeError: - pass + settings.SITE_ID.reset() def test_valid_domain(self): # Make the request @@ -124,17 +115,8 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) - self.reset_site_id() self.site_id = SiteIDHook() - - def tearDown(self): - self.reset_site_id() - - def reset_site_id(self): - try: - del _thread_locals.SITE_ID - except AttributeError: - pass + self.site_id.reset() def test_compare_default_site_id(self): # Default SITE_ID is 1 diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index c78e058..47a0919 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -19,16 +19,17 @@ def process_request(self, request): _thread_locals.request = request -class SiteIDHook(object): +class SiteIDHook(local): + def __init__(self): + self.reset() + def __repr__(self): return str(self.__int__()) def __int__(self): - try: - return _thread_locals.SITE_ID - except AttributeError: - _thread_locals.SITE_ID = 1 - return _thread_locals.SITE_ID + if self.site_id is None: + return 1 + return self.site_id def __lt__(self, other): if isinstance(other, (int, long)): @@ -67,4 +68,7 @@ def set(self, value): from django.db.models import Model if isinstance(value, Model): value = value.pk - _thread_locals.SITE_ID = value + self.site_id = value + + def reset(self): + self.site_id = None From 3455ed7874df0e1fd9326e1e4e08ac85c5010dff Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 19:25:15 -0400 Subject: [PATCH 019/196] Replace SiteIDHook with SiteID: a better name, as it's not really a hook. In addition, remove the need to know about threadlocals: >>> from multisite import SiteID --- README.markdown | 4 +-- multisite/__init__.py | 1 + multisite/tests.py | 58 +++++++++++++++++++++++++-------------- multisite/threadlocals.py | 41 +++++++++++++++++++++++---- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/README.markdown b/README.markdown index a924752..041199d 100644 --- a/README.markdown +++ b/README.markdown @@ -9,8 +9,8 @@ Add the django-multisite/multisite folder to your PYTHONPATH. Replace your SITE_ID in settings.py to: - from multisite.threadlocals import SiteIDHook - SITE_ID = SiteIDHook() + from multisite import SiteID + SITE_ID = SiteID() Add to settings.py TEMPLATE_LOADERS: diff --git a/multisite/__init__.py b/multisite/__init__.py index e69de29..14286f4 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -0,0 +1 @@ +from .threadlocals import SiteID diff --git a/multisite/tests.py b/multisite/tests.py index f90df43..82cae97 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,3 +1,5 @@ +import warnings + from django.conf import settings from django.contrib.sites.models import Site from django.test import TestCase @@ -9,8 +11,9 @@ except ImportError: from override_settings import override_settings -from multisite.middleware import DynamicSiteMiddleware, HOST_CACHE -from multisite.threadlocals import SiteIDHook, _thread_locals +from . import SiteID, threadlocals +from .middleware import DynamicSiteMiddleware, HOST_CACHE +from .threadlocals import SiteIDHook class RequestFactory(DjangoRequestFactory): @@ -27,11 +30,10 @@ def get(self, path, data={}, host=None, **extra): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') -@override_settings(SITE_ID=SiteIDHook(), DEBUG=True) +@override_settings(SITE_ID=SiteID()) class TestContribSite(TestCase): def setUp(self): - self.host = 'example.com' - self.site = Site.objects.create(domain=self.host) + self.site = Site.objects.create(domain='example.com') settings.SITE_ID.set(self.site.id) def test_get_current_site(self): @@ -42,7 +44,7 @@ def test_get_current_site(self): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') -@override_settings(SITE_ID=SiteIDHook()) +@override_settings(SITE_ID=SiteID(default=1)) class DynamicSiteMiddlewareTest(TestCase): def setUp(self): self.host = 'example.com' @@ -108,24 +110,23 @@ def test_no_sites(self): self.assertEqual(settings.SITE_ID, 1) -class TestSiteIDHook(TestCase): +class TestSiteID(TestCase): def setUp(self): - self.host = 'example.com' + self.site = Site.objects.create(domain='example.com') + self.site_id = SiteID() - Site.objects.all().delete() - self.site = Site.objects.create(domain=self.host) - - self.site_id = SiteIDHook() - self.site_id.reset() + def test_invalid_default(self): + self.assertRaises(ValueError, SiteID, default='a') + self.assertRaises(ValueError, SiteID, default=self.site_id) def test_compare_default_site_id(self): - # Default SITE_ID is 1 - self.assertEqual(self.site_id, 1) - self.assertFalse(self.site_id != 1) - self.assertFalse(self.site_id < 1) - self.assertTrue(self.site_id <= 1) - self.assertFalse(self.site_id > 1) - self.assertTrue(self.site_id >= 1) + self.site_id = SiteID(default=self.site.id) + self.assertEqual(self.site_id, self.site.id) + self.assertFalse(self.site_id != self.site.id) + self.assertFalse(self.site_id < self.site.id) + self.assertTrue(self.site_id <= self.site.id) + self.assertFalse(self.site_id > self.site.id) + self.assertTrue(self.site_id >= self.site.id) def test_compare_site_ids(self): self.site_id.set(1) @@ -166,3 +167,20 @@ def test_hash(self): self.assertEqual(hash(self.site_id), 10) self.site_id.set(20) self.assertEqual(hash(self.site_id), 20) + + +class TestSiteIDHook(TestCase): + def test_deprecation_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + threadlocals.__warningregistry__ = {} + SiteIDHook() + self.assertTrue(w) + self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) + + def test_default_value(self): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + site_id = SiteIDHook() + self.assertEqual(site_id.default, 1) + self.assertEqual(int(site_id), 1) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 47a0919..a10d032 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -* +from warnings import warn + try: from threading import local except ImportError: @@ -19,8 +21,26 @@ def process_request(self, request): _thread_locals.request = request -class SiteIDHook(local): - def __init__(self): +class SiteID(local): + """ + Dynamic settings.SITE_ID replacement, which acts like an integer. + + django.contrib.sites can allow multiple Django sites to share the + same database. However, they cannot share the same code by + default. + + SiteID can be used to replace the static settings.SITE_ID integer + when combined with the appropriate middleware. + """ + + def __init__(self, default=None, *args, **kwargs): + """ + ``default``, if specified, determines the default SITE_ID, + if it is unset. + """ + if default is not None and not isinstance(default, (int, long)): + raise ValueError("%r is not a valid default." % default) + self.default = default self.reset() def __repr__(self): @@ -28,27 +48,29 @@ def __repr__(self): def __int__(self): if self.site_id is None: - return 1 + if self.default is None: + raise ValueError('SITE_ID has not been set.') + return self.default return self.site_id def __lt__(self, other): if isinstance(other, (int, long)): return self.__int__() < other - elif isinstance(other, SiteIDHook): + elif isinstance(other, SiteID): return self.__int__() < other.__int__() return True def __le__(self, other): if isinstance(other, (int, long)): return self.__int__() <= other - elif isinstance(other, SiteIDHook): + elif isinstance(other, SiteID): return self.__int__() <= other.__int__() return True def __eq__(self, other): if isinstance(other, (int, long)): return self.__int__() == other - elif isinstance(other, SiteIDHook): + elif isinstance(other, SiteID): return self.__int__() == other.__int__() return False @@ -72,3 +94,10 @@ def set(self, value): def reset(self): self.site_id = None + + +def SiteIDHook(): + """Deprecated: Use multisite.SiteID(default=1) for identical behaviour.""" + warn('Use multisite.SiteID instead of multisite.threadlocals.SiteIDHook', + DeprecationWarning, stacklevel=2) + return SiteID(default=1) From 7e2259de4528aa713a3d5b51b0f300bedffaaba4 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 28 May 2012 19:29:24 -0400 Subject: [PATCH 020/196] str() and repr() support for SiteID. --- multisite/tests.py | 5 +++++ multisite/threadlocals.py | 3 +++ 2 files changed, 8 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 82cae97..0f6e8b9 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -168,6 +168,11 @@ def test_hash(self): self.site_id.set(20) self.assertEqual(hash(self.site_id), 20) + def test_str_repr(self): + self.site_id.set(10) + self.assertEqual(str(self.site_id), '10') + self.assertEqual(repr(self.site_id), '10') + class TestSiteIDHook(TestCase): def test_deprecation_warning(self): diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index a10d032..5b46fa8 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -44,6 +44,9 @@ def __init__(self, default=None, *args, **kwargs): self.reset() def __repr__(self): + return repr(self.__int__()) + + def __str__(self): return str(self.__int__()) def __int__(self): From 866f17eae4eab623a9be9d0c7fff3d1fce179f59 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 29 May 2012 12:44:27 -0400 Subject: [PATCH 021/196] SiteDomain() allows you to specify a default SiteID based on domain name. --- multisite/__init__.py | 2 +- multisite/tests.py | 28 +++++++++++++++++++++++++++- multisite/threadlocals.py | 39 +++++++++++++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/multisite/__init__.py b/multisite/__init__.py index 14286f4..5767564 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1 +1 @@ -from .threadlocals import SiteID +from .threadlocals import SiteDomain, SiteID diff --git a/multisite/tests.py b/multisite/tests.py index 0f6e8b9..eee439e 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -11,7 +11,7 @@ except ImportError: from override_settings import override_settings -from . import SiteID, threadlocals +from . import SiteDomain, SiteID, threadlocals from .middleware import DynamicSiteMiddleware, HOST_CACHE from .threadlocals import SiteIDHook @@ -33,6 +33,7 @@ def get(self, path, data={}, host=None, **extra): @override_settings(SITE_ID=SiteID()) class TestContribSite(TestCase): def setUp(self): + Site.objects.all().delete() self.site = Site.objects.create(domain='example.com') settings.SITE_ID.set(self.site.id) @@ -112,6 +113,7 @@ def test_no_sites(self): class TestSiteID(TestCase): def setUp(self): + Site.objects.all().delete() self.site = Site.objects.create(domain='example.com') self.site_id = SiteID() @@ -174,6 +176,30 @@ def test_str_repr(self): self.assertEqual(repr(self.site_id), '10') +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +class TestSiteDomain(TestCase): + def setUp(self): + Site.objects.all().delete() + self.domain = 'example.com' + self.site = Site.objects.create(domain=self.domain) + + def test_init(self): + self.assertEqual(int(SiteDomain(default=self.domain)), self.site.id) + self.assertRaises(Site.DoesNotExist, + int, SiteDomain(default='invalid')) + self.assertRaises(ValueError, SiteDomain, default=None) + self.assertRaises(ValueError, SiteDomain, default=1) + + def test_deferred_site(self): + domain = 'example.org' + self.assertRaises(Site.DoesNotExist, + int, SiteDomain(default=domain)) + site = Site.objects.create(domain=domain) + self.assertEqual(int(SiteDomain(default=domain)), + site.id) + + class TestSiteIDHook(TestCase): def test_deprecation_warning(self): with warnings.catch_warnings(record=True) as w: diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 5b46fa8..3704152 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -7,6 +7,8 @@ except ImportError: from django.utils._threading_local import local +from django.core.exceptions import ImproperlyConfigured + _thread_locals = local() @@ -36,7 +38,7 @@ class SiteID(local): def __init__(self, default=None, *args, **kwargs): """ ``default``, if specified, determines the default SITE_ID, - if it is unset. + if that is unset. """ if default is not None and not isinstance(default, (int, long)): raise ValueError("%r is not a valid default." % default) @@ -51,9 +53,7 @@ def __str__(self): def __int__(self): if self.site_id is None: - if self.default is None: - raise ValueError('SITE_ID has not been set.') - return self.default + return self.get_default() return self.site_id def __lt__(self, other): @@ -98,6 +98,37 @@ def set(self, value): def reset(self): self.site_id = None + def get_default(self): + """Returns the default SITE_ID.""" + if self.default is None: + raise ValueError('SITE_ID has not been set.') + return self.default + + +class SiteDomain(SiteID): + def __init__(self, default, *args, **kwargs): + """ + ``default``, if specified, is the default domain name, resolved + to SITE_ID, if that is unset. + """ + if not isinstance(default, basestring): + raise ValueError("%r is not a valid default domain." % default) + self.default_domain = default + self.default = None + self.reset() + + def get_default(self): + """Returns the default SITE_ID that matches the default domain name.""" + from django.contrib.sites.models import Site + if not Site._meta.installed: + raise ImproperlyConfigured('django.contrib.sites is not in ' + 'settings.INSTALLED_APPS') + + if self.default is None: + qset = Site.objects.only('id') + self.default = qset.get(domain=self.default_domain).id + return self.default + def SiteIDHook(): """Deprecated: Use multisite.SiteID(default=1) for identical behaviour.""" From bd7809a513203ed8f87bb12ecf0647d0ad609ef9 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 29 May 2012 17:03:02 -0400 Subject: [PATCH 022/196] middleware.py: Use Django's cache framework for DynamicSiteMiddleware's cache. This replaces middleware.HOST_CACHE which was a module-level dictionary. These are undesirable because they're not shared between processes and because they cannot be expired properly. --- README.markdown | 25 +++++++++++++++++++++ multisite/middleware.py | 49 ++++++++++++++++++++++++++++++++--------- multisite/tests.py | 41 ++++++++++++++++++++++++++++++---- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/README.markdown b/README.markdown index 041199d..754863b 100644 --- a/README.markdown +++ b/README.markdown @@ -27,6 +27,31 @@ Edit to settings.py MIDDLEWARE_CLASSES: ... ) +Append to settings.py, in order to use a custom cache that can be +safely cleared: + + # The cache connection to use for django-multisite. + # Default: 'default' + CACHE_MULTISITE_ALIAS = 'multisite' + + # The cache key prefix that django-multisite should use. + # Default: '' (Empty string) + CACHE_MULTISITE_KEY_PREFIX = '' + +If you have set CACHE\_MULTISITE\_ALIAS to a custom value, _e.g._ +`'multisite'`, add a separate backend to settings.py CACHES: + + CACHES = { + 'default': { + ... + }, + 'multisite': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 60 * 60 * 24, # 24 hours + ... + }, + } + Create a directory settings.TEMPLATE_DIRS directory with the names of domains, such as: mkdir templates/example.com diff --git a/multisite/middleware.py b/multisite/middleware.py index 0c5a200..70c6eaa 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -2,25 +2,39 @@ from django.conf import settings from django.contrib.sites.models import Site +from django.core.cache import get_cache +from django.db.models.signals import pre_save, post_delete +from django.utils.hashcompat import md5_constructor -HOST_CACHE = {} +class DynamicSiteMiddleware(object): + def __init__(self): + self.cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', + 'default') + self.key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', + '') + self.cache = get_cache(self.cache_alias) + pre_save.connect(self.site_domain_changed_hook, sender=Site) + post_delete.connect(self.site_deleted_hook, sender=Site) + def get_cache_key(self, host): + """Returns a cache key based on ``host``.""" + host = md5_constructor(host) + return 'multisite.site_id.%s.%s' % (self.key_prefix, host.hexdigest()) -class DynamicSiteMiddleware(object): def process_request(self, request): host = request.get_host() shost = host.rsplit(':', 1)[0] # only host, without port + cache_key = self.get_cache_key(host) - try: - settings.SITE_ID.set(HOST_CACHE[host]) + site_id = self.cache.get(cache_key) + if site_id is not None: + settings.SITE_ID.set(site_id) return - except KeyError: - pass try: # get by whole hostname site = Site.objects.get(domain=host) - HOST_CACHE[host] = site.pk + self.cache.set(cache_key, site.pk) settings.SITE_ID.set(site.pk) return except Site.DoesNotExist: @@ -29,7 +43,7 @@ def process_request(self, request): if shost != host: # get by hostname without port try: site = Site.objects.get(domain=shost) - HOST_CACHE[host] = site.pk + self.cache.set(cache_key, site.pk) settings.SITE_ID.set(site.pk) return except Site.DoesNotExist: @@ -37,15 +51,30 @@ def process_request(self, request): try: # get by settings.SITE_ID site = Site.objects.get(pk=settings.SITE_ID) - HOST_CACHE[host] = site.pk + self.cache.set(cache_key, site.pk) return except Site.DoesNotExist: pass try: # misconfigured settings? site = Site.objects.all()[0] - HOST_CACHE[host] = site.pk + self.cache.set(cache_key, site.pk) settings.SITE_ID.set(site.pk) return except IndexError: # no sites in db pass + + def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): + """Clears the cache if Site.domain has changed.""" + if raw: + return + try: + original = sender.objects.get(pk=instance.pk) + if original.domain != instance.domain: + self.cache.clear() + except sender.DoesNotExist: + pass + + def site_deleted_hook(self, *args, **kwargs): + """Clears the cache if Site was deleted.""" + self.cache.clear() \ No newline at end of file diff --git a/multisite/tests.py b/multisite/tests.py index eee439e..b839789 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -12,7 +12,7 @@ from override_settings import override_settings from . import SiteDomain, SiteID, threadlocals -from .middleware import DynamicSiteMiddleware, HOST_CACHE +from .middleware import DynamicSiteMiddleware from .threadlocals import SiteIDHook @@ -45,7 +45,10 @@ def test_get_current_site(self): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') -@override_settings(SITE_ID=SiteID(default=1)) +@override_settings( + SITE_ID=SiteID(default=1), + CACHE_MULTISITE_ALIAS='django.core.cache.backends.dummy.DummyCache', +) class DynamicSiteMiddlewareTest(TestCase): def setUp(self): self.host = 'example.com' @@ -54,11 +57,9 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) - HOST_CACHE.clear() self.middleware = DynamicSiteMiddleware() def tearDown(self): - HOST_CACHE.clear() settings.SITE_ID.reset() def test_valid_domain(self): @@ -111,6 +112,38 @@ def test_no_sites(self): self.assertEqual(settings.SITE_ID, 1) +@override_settings( + SITE_ID=SiteID(default=1), + CACHE_MULTISITE_ALIAS='django.core.cache.backends.locmem.LocMemCache', +) +class CacheTest(TestCase): + def setUp(self): + self.host = 'example.com' + self.factory = RequestFactory(host=self.host) + + Site.objects.all().delete() + self.site = Site.objects.create(domain=self.host) + + self.middleware = DynamicSiteMiddleware() + + def test_site_domain_changed(self): + # Test to ensure that the cache is cleared properly + cache_key = self.middleware.get_cache_key(self.host) + self.assertEqual(self.middleware.cache.get(cache_key), None) + # Make the request + request = self.factory.get('/') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(self.middleware.cache.get(cache_key), self.site.pk) + # Change the domain name + self.site.domain = 'example.org' + self.site.save() + self.assertEqual(self.middleware.cache.get(cache_key), None) + # Make the request again, which will now be invalid + request = self.factory.get('/') + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + + class TestSiteID(TestCase): def setUp(self): Site.objects.all().delete() From ddc9db0c623b950b80506fbd009c907b53af9f5e Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 30 May 2012 11:40:51 -0400 Subject: [PATCH 023/196] middleware.py: DynamicSiteMiddleware must be case-insensitive for hosts. --- multisite/middleware.py | 2 +- multisite/tests.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 70c6eaa..afbd0c1 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -23,7 +23,7 @@ def get_cache_key(self, host): return 'multisite.site_id.%s.%s' % (self.key_prefix, host.hexdigest()) def process_request(self, request): - host = request.get_host() + host = request.get_host().lower() shost = host.rsplit(':', 1)[0] # only host, without port cache_key = self.get_cache_key(host) diff --git a/multisite/tests.py b/multisite/tests.py index b839789..a09d94f 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -80,6 +80,12 @@ def test_valid_domain_port(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) + def test_case_sensitivity(self): + # Make the request in all uppercase + request = self.factory.get('/', host=self.host.upper()) + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + def test_change_domain(self): # Make the request request = self.factory.get('/') From cf9869c2e00ddba23230c8c8ce5921fd4151c871 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 30 May 2012 13:45:44 -0400 Subject: [PATCH 024/196] middleware.py: DynamicSiteMiddleware raises TypeError on bad settings.SITE_ID settings.SITE_ID must now resemble multisite.SiteID. --- multisite/middleware.py | 6 +++++- multisite/tests.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index afbd0c1..e34c3f0 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -9,6 +9,10 @@ class DynamicSiteMiddleware(object): def __init__(self): + if not hasattr(settings.SITE_ID, 'set'): + raise TypeError('Invalid type for settings.SITE_ID: %s' % + type(settings.SITE_ID).__name__) + self.cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') self.key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', @@ -77,4 +81,4 @@ def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): def site_deleted_hook(self, *args, **kwargs): """Clears the cache if Site was deleted.""" - self.cache.clear() \ No newline at end of file + self.cache.clear() diff --git a/multisite/tests.py b/multisite/tests.py index a09d94f..7480b3f 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -118,6 +118,14 @@ def test_no_sites(self): self.assertEqual(settings.SITE_ID, 1) +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings(SITE_ID=0,) +class DynamicSiteMiddlewareSettingsTest(TestCase): + def test_invalid_settings(self): + self.assertRaises(TypeError, DynamicSiteMiddleware) + + @override_settings( SITE_ID=SiteID(default=1), CACHE_MULTISITE_ALIAS='django.core.cache.backends.locmem.LocMemCache', From 9bc0d58b7fab9c0e25d8e5169d3a556ebf4b2bdb Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 30 May 2012 16:38:16 -0400 Subject: [PATCH 025/196] middleware.py: settings.MULTISITE_FALLBACK lets you define a fallback view. When request.get_host() does not resolve to a known Site, django-multisite will now respond with an HTTP 404 Not Found. If settings.MULTISITE_FALLBACK is defined, django-multisite will call that view to decide how to continue. --- README.markdown | 15 ++++++ multisite/middleware.py | 117 +++++++++++++++++++++++++++++----------- multisite/tests.py | 108 +++++++++++++++++++++++++++++++++---- 3 files changed, 201 insertions(+), 39 deletions(-) diff --git a/README.markdown b/README.markdown index 754863b..4f4330a 100644 --- a/README.markdown +++ b/README.markdown @@ -52,6 +52,21 @@ If you have set CACHE\_MULTISITE\_ALIAS to a custom value, _e.g._ }, } +By default, if the domain name is unknown, multisite will respond with +an HTTP 404 Not Found error. To change this behaviour, add to +settings.py: + + # The view function or class-based view that django-multisite will + # use when it cannot match the hostname with a Site. This can be + # the name of the function or the function itself. + # Default: None + MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView + + # Keyword arguments for the MULTISITE_FALLBACK view. + # Default: {} + MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + Create a directory settings.TEMPLATE_DIRS directory with the names of domains, such as: mkdir templates/example.com diff --git a/multisite/middleware.py b/multisite/middleware.py index e34c3f0..00cfd59 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -2,8 +2,12 @@ from django.conf import settings from django.contrib.sites.models import Site +from django.core import mail from django.core.cache import get_cache +from django.core.exceptions import ImproperlyConfigured +from django.core.urlresolvers import get_callable from django.db.models.signals import pre_save, post_delete +from django.http import Http404 from django.utils.hashcompat import md5_constructor @@ -26,47 +30,100 @@ def get_cache_key(self, host): host = md5_constructor(host) return 'multisite.site_id.%s.%s' % (self.key_prefix, host.hexdigest()) + def get_testserver_site(self, host): + """ + Returns valid Site when running Django tests. Otherwise, returns None. + """ + if hasattr(mail, 'outbox') and host == 'testserver': + try: + # Prefer the default SITE_ID + site_id = settings.SITE_ID.get_default() + return Site.objects.get(pk=site_id) + except ValueError: + # Fallback to the first Site object + return Site.objects.order_by('pk')[0] + + def get_site(self, host): + """ + Returns the Site that matches ``host``. + + ``host`` can be a bare hostname ``'example.com'`` or a + hostname with a port number``'example.com:8000'``. + + Attempts to match by the full hostname with the port number + first, against the domain field in Site. If that fails, it + will try to match the bare hostname with no port number. + + All comparisons are done case-insensitively. + """ + try: + # Get by whole hostname + return Site.objects.get(domain__iexact=host) + except Site.DoesNotExist: + shost = host.rsplit(':', 1)[0] + if shost != host: + # Get by hostname without port + return Site.objects.get(domain__iexact=shost) + raise + + def fallback_view(self, request): + """ + Runs the fallback view function in ``settings.MULTISITE_FALLBACK``. + + If ``MULTISITE_FALLBACK`` is None, raises an Http404 error. + + If ``MULTISITE_FALLBACK`` is callable, will treat that + callable as a view that returns an HttpResponse. + + If ``MULTISITE_FALLBACK`` is a string, will resolve it to a + view that returns an HttpResponse. + + In order to use a generic view that takes additional + parameters, ``settings.MULTISITE_FALLBACK_KWARGS`` may be a + dictionary of additional keyword arguments. + """ + fallback = getattr(settings, 'MULTISITE_FALLBACK', None) + if fallback is None: + raise Http404 + if callable(fallback): + view = fallback + else: + view = get_callable(fallback) + if not callable(view): + raise ImproperlyConfigured( + 'settings.MULTISITE_FALLBACK is not callable: %s' % + fallback + ) + + kwargs = getattr(settings, 'MULTISITE_FALLBACK_KWARGS', {}) + if hasattr(view, 'as_view'): + # Class-based view + return view.as_view(**kwargs)(request) + # View function + return view(request, **kwargs) + def process_request(self, request): host = request.get_host().lower() - shost = host.rsplit(':', 1)[0] # only host, without port cache_key = self.get_cache_key(host) + # Find the SITE_ID in the cache site_id = self.cache.get(cache_key) if site_id is not None: settings.SITE_ID.set(site_id) return - try: # get by whole hostname - site = Site.objects.get(domain=host) - self.cache.set(cache_key, site.pk) - settings.SITE_ID.set(site.pk) - return - except Site.DoesNotExist: - pass - - if shost != host: # get by hostname without port - try: - site = Site.objects.get(domain=shost) - self.cache.set(cache_key, site.pk) - settings.SITE_ID.set(site.pk) - return - except Site.DoesNotExist: - pass - - try: # get by settings.SITE_ID - site = Site.objects.get(pk=settings.SITE_ID) - self.cache.set(cache_key, site.pk) - return + # Cache missed + try: + site = self.get_site(host) except Site.DoesNotExist: - pass + site = self.get_testserver_site(host) + if site is None: + # Fallback using settings.MULTISITE_FALLBACK + settings.SITE_ID.reset() + return self.fallback_view(request) - try: # misconfigured settings? - site = Site.objects.all()[0] - self.cache.set(cache_key, site.pk) - settings.SITE_ID.set(site.pk) - return - except IndexError: # no sites in db - pass + self.cache.set(cache_key, site.pk) + settings.SITE_ID.set(site.pk) def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): """Clears the cache if Site.domain has changed.""" diff --git a/multisite/tests.py b/multisite/tests.py index 7480b3f..fba7a9b 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -2,6 +2,8 @@ from django.conf import settings from django.contrib.sites.models import Site +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory from django.utils.unittest import skipUnless @@ -46,8 +48,9 @@ def test_get_current_site(self): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( - SITE_ID=SiteID(default=1), + SITE_ID=SiteID(default=0), CACHE_MULTISITE_ALIAS='django.core.cache.backends.dummy.DummyCache', + MULTISITE_FALLBACK=None, ) class DynamicSiteMiddlewareTest(TestCase): def setUp(self): @@ -100,22 +103,107 @@ def test_change_domain(self): def test_invalid_domain(self): # Make the request request = self.factory.get('/', host='invalid') - self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) def test_invalid_domain_port(self): # Make the request request = self.factory.get('/', host=':8000') - self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) def test_no_sites(self): # Remove all Sites Site.objects.all().delete() # Make the request request = self.factory.get('/') + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) + + +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings( + SITE_ID=SiteID(default=0), + CACHE_MULTISITE_ALIAS='django.core.cache.backends.dummy.DummyCache', + MULTISITE_FALLBACK=None, + MULTISITE_FALLBACK_KWARGS={}, +) +class DynamicSiteMiddlewareFallbackTest(TestCase): + def setUp(self): + self.factory = RequestFactory(host='unknown') + + Site.objects.all().delete() + + self.middleware = DynamicSiteMiddleware() + + def tearDown(self): + settings.SITE_ID.reset() + + def test_404(self): + request = self.factory.get('/') + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) + + def test_testserver(self): + host = 'testserver' + site = Site.objects.create(domain=host) + request = self.factory.get('/', host=host) self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(settings.SITE_ID, 1) + self.assertEqual(settings.SITE_ID, site.pk) + + def test_string_function(self): + # Function based + settings.MULTISITE_FALLBACK = 'django.views.generic.simple.redirect_to' + settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + request = self.factory.get('/') + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], + settings.MULTISITE_FALLBACK_KWARGS['url']) + + def test_string_class(self): + # Class based + settings.MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView' + settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + request = self.factory.get('/') + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], + settings.MULTISITE_FALLBACK_KWARGS['url']) + + def test_function_view(self): + from django.views.generic.simple import redirect_to + settings.MULTISITE_FALLBACK = redirect_to + settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + request = self.factory.get('/') + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], + settings.MULTISITE_FALLBACK_KWARGS['url']) + + def test_class_view(self): + from django.views.generic.base import RedirectView + settings.MULTISITE_FALLBACK = RedirectView.as_view( + url='http://example.com/', permanent=False + ) + request = self.factory.get('/') + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], 'http://example.com/') + + def test_invalid(self): + settings.MULTISITE_FALLBACK = '' + request = self.factory.get('/') + self.assertRaises(ImproperlyConfigured, + self.middleware.process_request, request) @skipUnless(Site._meta.installed, @@ -127,8 +215,9 @@ def test_invalid_settings(self): @override_settings( - SITE_ID=SiteID(default=1), + SITE_ID=SiteID(default=0), CACHE_MULTISITE_ALIAS='django.core.cache.backends.locmem.LocMemCache', + MULTISITE_FALLBACK=None, ) class CacheTest(TestCase): def setUp(self): @@ -154,8 +243,9 @@ def test_site_domain_changed(self): self.assertEqual(self.middleware.cache.get(cache_key), None) # Make the request again, which will now be invalid request = self.factory.get('/') - self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(settings.SITE_ID, Site.objects.all()[0].pk) + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) class TestSiteID(TestCase): From 4d246084a08e01b904a39dd21198c0e0911548ae Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 03:07:44 -0400 Subject: [PATCH 026/196] models.py: Alias model for domain name aliases of Site.domain Includes South migrations, for those using South. --- multisite/migrations/0001_initial.py | 49 ++++++ multisite/migrations/__init__.py | 0 multisite/models.py | 239 +++++++++++++++++++++++++++ multisite/tests.py | 193 ++++++++++++++++++++- 4 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 multisite/migrations/0001_initial.py create mode 100644 multisite/migrations/__init__.py diff --git a/multisite/migrations/0001_initial.py b/multisite/migrations/0001_initial.py new file mode 100644 index 0000000..4a6c6d1 --- /dev/null +++ b/multisite/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# encoding: utf-8 +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + def forwards(self, orm): + """Create Alias table.""" + + # Adding model 'Alias' + db.create_table('multisite_alias', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('site', self.gf('django.db.models.fields.related.ForeignKey')(related_name='aliases', to=orm['sites.Site'])), + ('domain', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), + ('is_canonical', self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True)), + )) + db.send_create_signal('multisite', ['Alias']) + + # Adding unique constraint on 'Alias', + # fields ['is_canonical', 'site'] + db.create_unique('multisite_alias', ['is_canonical', 'site_id']) + + def backwards(self, orm): + """Drop Alias table.""" + + # Removing unique constraint on 'Alias', + # fields ['is_canonical', 'site'] + db.delete_unique('multisite_alias', ['is_canonical', 'site_id']) + + # Deleting model 'Alias' + db.delete_table('multisite_alias') + + models = { + 'multisite.alias': { + 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, + 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + } + } + + complete_apps = ['multisite'] diff --git a/multisite/migrations/__init__.py b/multisite/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/models.py b/multisite/models.py index e69de29..a3180dd 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -0,0 +1,239 @@ +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.db import connections, models, router +from django.db.models.signals import pre_save, post_save, post_syncdb +from django.utils.translation import ugettext_lazy as _ + + +_site_domain = Site._meta.get_field('domain') + + +class AliasManager(models.Manager): + """Manager for all Aliases.""" + + def get_query_set(self): + return super(AliasManager, self).get_query_set().select_related('site') + + +class CanonicalAliasManager(models.Manager): + """Manager for Alias objects where is_canonical is True.""" + + def get_query_set(self): + qset = super(CanonicalAliasManager, self).get_query_set() + return qset.filter(is_canonical=True) + + def sync_many(self, *args, **kwargs): + """ + Synchronize canonical Alias objects based on Site.domain. + + You can pass Q-objects or filter arguments to update a subset of + Alias objects:: + + Alias.canonical.sync_many(site__domain='example.com') + """ + aliases = self.get_query_set().filter(*args, **kwargs) + for alias in aliases.select_related('site'): + domain = alias.site.domain + if domain and alias.domain != domain: + alias.domain = domain + alias.save() + + def sync_missing(self): + """Create missing canonical Alias objects based on Site.domain.""" + aliases = self.get_query_set() + sites = self.model._meta.get_field('site').rel.to + for site in sites.objects.exclude(aliases__in=aliases): + Alias.sync(site=site) + + def sync_all(self): + """Create or sync canonical Alias objects from all Site objects.""" + self.sync_many() + self.sync_missing() + + +class NotCanonicalAliasManager(models.Manager): + """Manager for Aliases where is_canonical is None.""" + + def get_query_set(self): + qset = super(NotCanonicalAliasManager, self).get_query_set() + return qset.filter(is_canonical__isnull=True) + + +def validate_true_or_none(value): + """Raises ValidationError if value is not True or None.""" + if value not in (True, None): + raise ValidationError(u'%r must be True or None' % value) + + +class Alias(models.Model): + """ + Model for domain-name aliases for Site objects. + + Domain names must be unique in the format of: 'hostname[:port].' + Each Site object that has a domain must have an ``is_canonical`` + Alias. + """ + + site = models.ForeignKey(Site, related_name='aliases') + domain = type(_site_domain)(_('domain name'), + max_length=_site_domain.max_length, + unique=True) + is_canonical = models.NullBooleanField(default=None, editable=False, + validators=[validate_true_or_none]) + + objects = AliasManager() + canonical = CanonicalAliasManager() + aliases = NotCanonicalAliasManager() + + class Meta: + unique_together = [('is_canonical', 'site')] + verbose_name_plural = _('aliases') + + def __unicode__(self): + return "%s -> %s" % (self.domain, self.site.domain) + + def save_base(self, *args, **kwargs): + self.full_clean() + super(Alias, self).save_base(*args, **kwargs) + + def clean_fields(self, exclude=None, *args, **kwargs): + errors = {} + try: + super(Alias, self).clean_fields(exclude=exclude, *args, **kwargs) + except ValidationError, e: + errors = e.update_error_dict(errors) + + try: + self.clean_domain() + except ValidationError, e: + errors = e.update_error_dict(errors) + + if errors: + raise ValidationError(errors) + + def clean_domain(self): + # For canonical Alias, domains must match Site domains. + if self.is_canonical and self.domain != self.site.domain: + raise ValidationError( + {'domain': ['Does not match %r' % self.site]} + ) + + def validate_unique(self, exclude=None): + errors = {} + try: + super(Alias, self).validate_unique(exclude=exclude) + except ValidationError, e: + errors = e.update_error_dict(errors) + + if exclude is not None and 'domain' not in exclude: + # Ensure domain is unique, insensitive to case + field_name = 'domain' + field_error = self.unique_error_message(self.__class__, + (field_name,)) + if field_name not in errors or \ + field_error not in errors[field_name]: + qset = self.__class__.objects.filter( + **{field_name + '__iexact': getattr(self, field_name)} + ) + if self.pk is not None: + qset = qset.exclude(pk=self.pk) + if qset.exists(): + errors.setdefault(field_name, []).append(field_error) + + if errors: + raise ValidationError(errors) + + @classmethod + def _sync_blank_domain(cls, site): + """Delete associated Alias object for ``site``, if domain is blank.""" + + if site.domain: + raise ValueError('%r has a domain' % site) + + # Remove canonical Alias, if no non-canonical aliases exist. + try: + alias = cls.objects.get(site=site) + except cls.DoesNotExist: + # Nothing to delete + pass + else: + if not alias.is_canonical: + raise cls.MultipleObjectsReturned( + 'Other %s still exist for %r' % + (cls._meta.verbose_name_plural.capitalize(), site) + ) + alias.delete() + + @classmethod + def sync(cls, site, force_insert=False): + """ + Create or synchronize Alias object from ``site``. + + If `force_insert`, forces creation of Alias object. + """ + domain = site.domain + if not domain: + cls._sync_blank_domain(site) + return + + if force_insert: + alias = cls.objects.create(site=site, is_canonical=True, + domain=domain) + + else: + alias, created = cls.objects.get_or_create( + site=site, is_canonical=True, + defaults={'domain': domain} + ) + if not created and alias.domain != domain: + alias.site = site + alias.domain = domain + alias.save() + + return alias + + @classmethod + def site_domain_changed_hook(cls, sender, instance, raw, *args, **kwargs): + """Updates canonical Alias object if Site.domain has changed.""" + if raw or instance.pk is None: + return + + try: + original = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + return + + # Update Alias.domain to match site + if original.domain != instance.domain: + cls.sync(site=instance) + + @classmethod + def site_created_hook(cls, sender, instance, raw, created, + *args, **kwargs): + """Creates canonical Alias object for a new Site.""" + if raw or not created: + return + + # When running create_default_site() because of post_syncdb, + # don't try to sync before the db_table has been created. + using = router.db_for_write(cls) + tables = connections[using].introspection.table_names() + if cls._meta.db_table not in tables: + return + + # Update Alias.domain to match site + cls.sync(site=instance) + + @classmethod + def db_table_created_hook(cls, created_models, *args, **kwargs): + """Syncs canonical Alias objects for all existing Site objects.""" + if cls in created_models: + Alias.canonical.sync_all() + + +# Hooks to handle Site objects being created or changed +pre_save.connect(Alias.site_domain_changed_hook, sender=Site) +post_save.connect(Alias.site_created_hook, sender=Site) + +# Hook to handle syncdb creating the Alias table +post_syncdb.connect(Alias.db_table_created_hook, sender=Alias.__module__) diff --git a/multisite/tests.py b/multisite/tests.py index fba7a9b..59ef74a 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -2,7 +2,7 @@ from django.conf import settings from django.contrib.sites.models import Site -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory @@ -15,6 +15,7 @@ from . import SiteDomain, SiteID, threadlocals from .middleware import DynamicSiteMiddleware +from .models import Alias from .threadlocals import SiteIDHook @@ -352,3 +353,193 @@ def test_default_value(self): site_id = SiteIDHook() self.assertEqual(site_id.default, 1) self.assertEqual(int(site_id), 1) + + +class AliasTest(TestCase): + def setUp(self): + Alias.objects.all().delete() + Site.objects.all().delete() + + def test_create(self): + site0 = Site.objects.create() + site1 = Site.objects.create(domain='1.example') + site2 = Site.objects.create(domain='2.example') + # Missing site + self.assertRaises(ValidationError, Alias.objects.create) + self.assertRaises(ValidationError, + Alias.objects.create, domain='0.example') + # Valid + self.assertTrue(Alias.objects.create(domain='1a.example', site=site1)) + # Duplicate domain + self.assertRaises( + ValidationError, + Alias.objects.create, domain=site1.domain, site=site1 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain=site2.domain, site=site1 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain='1a.example', site=site1 + ) + # Duplicate domains, case-sensitivity + self.assertRaises( + ValidationError, + Alias.objects.create, domain='1A.EXAMPLE', site=site2 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain='2.EXAMPLE', site=site2 + ) + # Duplicate is_canonical + site1.domain = '1b.example' + self.assertRaises( + ValidationError, + Alias.objects.create, + domain=site1.domain, site=site1, is_canonical=True + ) + # Invalid is_canonical + self.assertRaises( + ValidationError, + Alias.objects.create, + domain=site1.domain, site=site1, is_canonical=False + ) + + def test_repr(self): + site = Site.objects.create(domain='example.com') + self.assertEqual(repr(Alias.objects.get(site=site)), + u' %(domain)s>' % site.__dict__) + + def test_managers(self): + site = Site.objects.create(domain='example.com') + Alias.objects.create(site=site, domain='example.org') + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set(['example.com', 'example.org'])) + self.assertEqual(set(Alias.canonical.values_list('domain', flat=True)), + set(['example.com'])) + self.assertEqual(set(Alias.aliases.values_list('domain', flat=True)), + set(['example.org'])) + + def test_sync_many(self): + # Create Sites with Aliases + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + site2 = Site.objects.create(domain='2.example.com') + # Create Site without triggering signals + site3 = Site(domain='3.example.com') + site3.save_base(raw=True) + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + # Sync existing + site1.domain = '1.example.org' + site1.save_base(raw=True) + site2.domain = '2.example.org' + site2.save_base(raw=True) + Alias.canonical.sync_many() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + # Sync with filter + site1.domain = '1.example.net' + site1.save_base(raw=True) + site2.domain = '2.example.net' + site2.save_base(raw=True) + Alias.canonical.sync_many(site__domain=site1.domain) + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, '2.example.org'])) + + def test_sync_missing(self): + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + # Update site1 without triggering signals + site1.domain = '1.example.org' + site1.save_base(raw=True) + # Create site2 without triggering signals + site2 = Site(domain='2.example.org') + site2.save_base(raw=True) + # Only site2 should be updated + Alias.canonical.sync_missing() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set(['1.example.com', site2.domain])) + + def test_sync_all(self): + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + # Update site1 without triggering signals + site1.domain = '1.example.org' + site1.save_base(raw=True) + # Create site2 without triggering signals + site2 = Site(domain='2.example.org') + site2.save_base(raw=True) + # Sync all + Alias.canonical.sync_all() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + + def test_sync(self): + # Create Site without triggering signals + site = Site(domain='example.com') + site.save_base(raw=True) + # Insert Alias + self.assertFalse(Alias.objects.filter(site=site).exists()) + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Idempotent sync_alias + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Duplicate force_insert + self.assertRaises(ValidationError, + Alias.sync, site=site, force_insert=True) + # Update Alias + site.domain = 'example.org' + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Clear domain + site.domain = '' + Alias.sync(site=site) + self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_sync_blank_domain(self): + # Create Site + site = Site.objects.create(domain='example.com') + # Without clearing domain + self.assertRaises(ValueError, Alias._sync_blank_domain, site) + # With an extra Alias + site.domain = '' + alias = Alias.objects.create(site=site, domain='example.org') + self.assertRaises(Alias.MultipleObjectsReturned, + Alias._sync_blank_domain, site) + # With a blank site + alias.delete() + Alias._sync_blank_domain(site) + self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_hooks(self): + # Create empty Site + Site.objects.create() + self.assertFalse(Alias.objects.filter(domain='').exists()) + # Create Site + site = Site.objects.create(domain='example.com') + alias = Alias.objects.get(site=site) + self.assertEqual(alias.domain, site.domain) + self.assertTrue(alias.is_canonical) + # Create a non-canonical alias + Alias.objects.create(site=site, domain='example.info') + # Change Site to another domain name + site.domain = 'example.org' + site.save() + self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) + self.assertEqual(Alias.aliases.get(site=site).domain, 'example.info') + # Change Site to an empty domain name + site.domain = '' + self.assertRaises(Alias.MultipleObjectsReturned, site.save) + Alias.aliases.all().delete() + site.save() + self.assertFalse(Alias.objects.filter(site=site).exists()) + # Change Site from an empty domain name + site.domain = 'example.net' + site.save() + self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) + # Delete Site + site.delete() + self.assertFalse(Alias.objects.filter(site=site).exists()) From 914c914da855419b4ba9803d77a5482d49fc32f5 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 03:20:22 -0400 Subject: [PATCH 027/196] middleware.py: Rename 'host' to 'netloc', because it may contain the port. --- multisite/middleware.py | 44 +++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 00cfd59..b3b8037 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -25,16 +25,17 @@ def __init__(self): pre_save.connect(self.site_domain_changed_hook, sender=Site) post_delete.connect(self.site_deleted_hook, sender=Site) - def get_cache_key(self, host): - """Returns a cache key based on ``host``.""" - host = md5_constructor(host) - return 'multisite.site_id.%s.%s' % (self.key_prefix, host.hexdigest()) + def get_cache_key(self, netloc): + """Returns a cache key based on ``netloc``.""" + netloc = md5_constructor(netloc) + return 'multisite.site_id.%s.%s' % (self.key_prefix, + netloc.hexdigest()) - def get_testserver_site(self, host): + def get_testserver_site(self, netloc): """ Returns valid Site when running Django tests. Otherwise, returns None. """ - if hasattr(mail, 'outbox') and host == 'testserver': + if hasattr(mail, 'outbox') and netloc == 'testserver': try: # Prefer the default SITE_ID site_id = settings.SITE_ID.get_default() @@ -43,27 +44,28 @@ def get_testserver_site(self, host): # Fallback to the first Site object return Site.objects.order_by('pk')[0] - def get_site(self, host): + def get_site(self, netloc): """ - Returns the Site that matches ``host``. + Returns the Site that matches ``netloc``. - ``host`` can be a bare hostname ``'example.com'`` or a + ``netloc`` can be a bare hostname ``'example.com'`` or a hostname with a port number``'example.com:8000'``. - Attempts to match by the full hostname with the port number - first, against the domain field in Site. If that fails, it - will try to match the bare hostname with no port number. + Attempts to match by netloc with the port number first, + against the domain field in Site. If that fails, it will try + to match the bare hostname with no port number. All comparisons are done case-insensitively. + """ try: - # Get by whole hostname - return Site.objects.get(domain__iexact=host) + # Get by netloc + return Site.objects.get(domain__iexact=netloc) except Site.DoesNotExist: - shost = host.rsplit(':', 1)[0] - if shost != host: + host = netloc.rsplit(':', 1)[0] + if host != netloc: # Get by hostname without port - return Site.objects.get(domain__iexact=shost) + return Site.objects.get(domain__iexact=host) raise def fallback_view(self, request): @@ -103,8 +105,8 @@ def fallback_view(self, request): return view(request, **kwargs) def process_request(self, request): - host = request.get_host().lower() - cache_key = self.get_cache_key(host) + netloc = request.get_host().lower() + cache_key = self.get_cache_key(netloc) # Find the SITE_ID in the cache site_id = self.cache.get(cache_key) @@ -114,9 +116,9 @@ def process_request(self, request): # Cache missed try: - site = self.get_site(host) + site = self.get_site(netloc) except Site.DoesNotExist: - site = self.get_testserver_site(host) + site = self.get_testserver_site(netloc) if site is None: # Fallback using settings.MULTISITE_FALLBACK settings.SITE_ID.reset() From 259f9169c18aab3de8762138cb3ee528bec2cbe8 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 04:37:50 -0400 Subject: [PATCH 028/196] middleware.py: Use Alias model to lookup aliases and wildcards for Site.domain --- multisite/middleware.py | 83 +++++++++++++++++++++++++++++++++-------- multisite/tests.py | 61 +++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 20 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index b3b8037..ce6c406 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,15 +1,21 @@ # -*- coding: utf-8 -*- +import operator + from django.conf import settings from django.contrib.sites.models import Site from django.core import mail from django.core.cache import get_cache -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.urlresolvers import get_callable +from django.core.validators import validate_ipv4_address +from django.db.models import Q from django.db.models.signals import pre_save, post_delete from django.http import Http404 from django.utils.hashcompat import md5_constructor +from .models import Alias + class DynamicSiteMiddleware(object): def __init__(self): @@ -46,7 +52,7 @@ def get_testserver_site(self, netloc): def get_site(self, netloc): """ - Returns the Site that matches ``netloc``. + Returns the Site that best matches ``netloc``, or None. ``netloc`` can be a bare hostname ``'example.com'`` or a hostname with a port number``'example.com:8000'``. @@ -58,15 +64,57 @@ def get_site(self, netloc): All comparisons are done case-insensitively. """ + domains = self._expand_netloc(netloc) + q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) + aliases = dict((a.domain, a) for a in Alias.objects.filter(q)) + for domain in domains: + try: + return aliases[domain].site + except KeyError: + pass + + @classmethod + def _expand_netloc(cls, netloc): + """ + Returns a list of possible domain expansions for ``netloc``. + + Expansions are ordered from highest to lowest preference and may + include wildcards. Examples:: + + >>> DynamicSiteMiddleware._expand_netloc('www.example.com') + ['www.example.com', '*.example.com', '*.com', '*'] + + >>> DynamicSiteMiddleware._expand_netloc('www.example.com:80') + ['www.example.com:80', 'www.example.com', + '*.example.com:80', '*.example.com', + '*.com:80', '*.com', + '*:80', '*'] + """ + if ':' in netloc: + host, port = netloc.rsplit(':', 1) + else: + host, port = netloc, None + + if not host: + raise ValueError("Invalid netloc: %r" % netloc) + try: - # Get by netloc - return Site.objects.get(domain__iexact=netloc) - except Site.DoesNotExist: - host = netloc.rsplit(':', 1)[0] - if host != netloc: - # Get by hostname without port - return Site.objects.get(domain__iexact=host) - raise + validate_ipv4_address(host) + bits = [host] + except ValidationError: + # Not an IP address + bits = host.split('.') + + result = [] + for i in xrange(0, (len(bits) + 1)): + if i == 0: + host = '.'.join(bits[i:]) + else: + host = '.'.join(['*'] + bits[i:]) + if port: + result.append(host + ':' + port) + result.append(host) + return result def fallback_view(self, request): """ @@ -111,19 +159,24 @@ def process_request(self, request): # Find the SITE_ID in the cache site_id = self.cache.get(cache_key) if site_id is not None: + self.cache.set(cache_key, site_id) settings.SITE_ID.set(site_id) return # Cache missed try: site = self.get_site(netloc) - except Site.DoesNotExist: + except ValueError: + site = None + # Running under TestCase? + if site is None: site = self.get_testserver_site(netloc) - if site is None: - # Fallback using settings.MULTISITE_FALLBACK - settings.SITE_ID.reset() - return self.fallback_view(request) + # Fallback using settings.MULTISITE_FALLBACK + if site is None: + settings.SITE_ID.reset() + return self.fallback_view(request) + # Found Site self.cache.set(cache_key, site.pk) settings.SITE_ID.set(site.pk) diff --git a/multisite/tests.py b/multisite/tests.py index 59ef74a..ea5adfb 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -66,6 +66,29 @@ def setUp(self): def tearDown(self): settings.SITE_ID.reset() + def test_expand_netloc(self): + _expand_netloc = self.middleware._expand_netloc + self.assertRaises(ValueError, _expand_netloc, '') + self.assertRaises(ValueError, _expand_netloc, ':8000') + self.assertEqual(_expand_netloc('testserver:8000'), + ['testserver:8000', 'testserver', + '*:8000', '*']) + self.assertEqual(_expand_netloc('testserver'), + ['testserver', '*']) + self.assertEqual(_expand_netloc('example.com:8000'), + ['example.com:8000', 'example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('example.com'), + ['example.com', '*.com', '*']) + self.assertEqual(_expand_netloc('www.example.com:8000'), + ['www.example.com:8000', 'www.example.com', + '*.example.com:8000', '*.example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('www.example.com'), + ['www.example.com', '*.example.com', '*.com', '*']) + def test_valid_domain(self): # Make the request request = self.factory.get('/') @@ -90,6 +113,24 @@ def test_case_sensitivity(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) + def test_wildcards(self): + # *.example.com + self.assertEqual(self.middleware.get_site('www.example.com'), + None) + self.assertEqual(self.middleware.get_site('www.dev.example.com'), + None) + Alias.objects.create(site=self.site, domain='*.example.com') + self.assertEqual(self.middleware.get_site('www.example.com'), + self.site) + self.assertEqual(self.middleware.get_site('www.dev.example.com'), + self.site) + # * + self.assertEqual(self.middleware.get_site('example.net'), + None) + Alias.objects.create(site=self.site, domain='*') + self.assertEqual(self.middleware.get_site('example.net'), + self.site) + def test_change_domain(self): # Make the request request = self.factory.get('/') @@ -101,15 +142,25 @@ def test_change_domain(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, site2.pk) - def test_invalid_domain(self): - # Make the request - request = self.factory.get('/', host='invalid') + def test_unknown_host(self): + # Unknown host + request = self.factory.get('/', host='unknown') + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) + # Unknown host:port + request = self.factory.get('/', host='unknown:8000') self.assertRaises(Http404, self.middleware.process_request, request) self.assertEqual(settings.SITE_ID, 0) - def test_invalid_domain_port(self): - # Make the request + def test_invalid_host(self): + # Invalid host + request = self.factory.get('/', host='') + self.assertRaises(Http404, + self.middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) + # Invalid host:port request = self.factory.get('/', host=':8000') self.assertRaises(Http404, self.middleware.process_request, request) From 6e1b4dca2fca21de5e11bd33f7c17dab305907e4 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 04:52:57 -0400 Subject: [PATCH 029/196] models.py: Move DynamicSiteMiddleware methods to AliasManager: * DynamicSiteMiddleware.get_site() -> AliasManager.resolve() AliasManager.resolve now returns an Alias object, not a Site object. * DynamicSiteMiddleware._expand_netloc() -> AliasManager._expand_netloc() Implementation unchanged. --- multisite/middleware.py | 79 +++------------------------------------ multisite/models.py | 69 ++++++++++++++++++++++++++++++++++ multisite/tests.py | 83 +++++++++++++++++++++-------------------- 3 files changed, 117 insertions(+), 114 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index ce6c406..88f1752 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,15 +1,11 @@ # -*- coding: utf-8 -*- -import operator - from django.conf import settings from django.contrib.sites.models import Site from django.core import mail from django.core.cache import get_cache -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import get_callable -from django.core.validators import validate_ipv4_address -from django.db.models import Q from django.db.models.signals import pre_save, post_delete from django.http import Http404 from django.utils.hashcompat import md5_constructor @@ -50,72 +46,6 @@ def get_testserver_site(self, netloc): # Fallback to the first Site object return Site.objects.order_by('pk')[0] - def get_site(self, netloc): - """ - Returns the Site that best matches ``netloc``, or None. - - ``netloc`` can be a bare hostname ``'example.com'`` or a - hostname with a port number``'example.com:8000'``. - - Attempts to match by netloc with the port number first, - against the domain field in Site. If that fails, it will try - to match the bare hostname with no port number. - - All comparisons are done case-insensitively. - - """ - domains = self._expand_netloc(netloc) - q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) - aliases = dict((a.domain, a) for a in Alias.objects.filter(q)) - for domain in domains: - try: - return aliases[domain].site - except KeyError: - pass - - @classmethod - def _expand_netloc(cls, netloc): - """ - Returns a list of possible domain expansions for ``netloc``. - - Expansions are ordered from highest to lowest preference and may - include wildcards. Examples:: - - >>> DynamicSiteMiddleware._expand_netloc('www.example.com') - ['www.example.com', '*.example.com', '*.com', '*'] - - >>> DynamicSiteMiddleware._expand_netloc('www.example.com:80') - ['www.example.com:80', 'www.example.com', - '*.example.com:80', '*.example.com', - '*.com:80', '*.com', - '*:80', '*'] - """ - if ':' in netloc: - host, port = netloc.rsplit(':', 1) - else: - host, port = netloc, None - - if not host: - raise ValueError("Invalid netloc: %r" % netloc) - - try: - validate_ipv4_address(host) - bits = [host] - except ValidationError: - # Not an IP address - bits = host.split('.') - - result = [] - for i in xrange(0, (len(bits) + 1)): - if i == 0: - host = '.'.join(bits[i:]) - else: - host = '.'.join(['*'] + bits[i:]) - if port: - result.append(host + ':' + port) - result.append(host) - return result - def fallback_view(self, request): """ Runs the fallback view function in ``settings.MULTISITE_FALLBACK``. @@ -164,10 +94,13 @@ def process_request(self, request): return # Cache missed + site = None try: - site = self.get_site(netloc) + alias = Alias.objects.resolve(netloc) + if alias: + site = alias.site except ValueError: - site = None + pass # Running under TestCase? if site is None: site = self.get_testserver_site(netloc) diff --git a/multisite/models.py b/multisite/models.py index a3180dd..68a38a9 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -1,6 +1,10 @@ +import operator + from django.contrib.sites.models import Site from django.core.exceptions import ValidationError +from django.core.validators import validate_ipv4_address from django.db import connections, models, router +from django.db.models import Q from django.db.models.signals import pre_save, post_save, post_syncdb from django.utils.translation import ugettext_lazy as _ @@ -14,6 +18,71 @@ class AliasManager(models.Manager): def get_query_set(self): return super(AliasManager, self).get_query_set().select_related('site') + def resolve(self, netloc): + """ + Returns the Alias that best matches ``netloc``, or None. + + ``netloc`` can be a bare hostname ``'example.com'`` or a + hostname with a port number``'example.com:8000'``. + + Attempts to match by netloc with the port number first, + against Alias.domain. If that fails, it will try to match the + bare hostname with no port number. + + All comparisons are done case-insensitively. + """ + domains = self._expand_netloc(netloc) + q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) + aliases = dict((a.domain, a) for a in self.get_query_set().filter(q)) + for domain in domains: + try: + return aliases[domain] + except KeyError: + pass + + @classmethod + def _expand_netloc(cls, netloc): + """ + Returns a list of possible domain expansions for ``netloc``. + + Expansions are ordered from highest to lowest preference and may + include wildcards. Examples:: + + >>> AliasManager._expand_netloc('www.example.com') + ['www.example.com', '*.example.com', '*.com', '*'] + + >>> AliasManager._expand_netloc('www.example.com:80') + ['www.example.com:80', 'www.example.com', + '*.example.com:80', '*.example.com', + '*.com:80', '*.com', + '*:80', '*'] + """ + if ':' in netloc: + host, port = netloc.rsplit(':', 1) + else: + host, port = netloc, None + + if not host: + raise ValueError("Invalid netloc: %r" % netloc) + + try: + validate_ipv4_address(host) + bits = [host] + except ValidationError: + # Not an IP address + bits = host.split('.') + + result = [] + for i in xrange(0, (len(bits) + 1)): + if i == 0: + host = '.'.join(bits[i:]) + else: + host = '.'.join(['*'] + bits[i:]) + if port: + result.append(host + ':' + port) + result.append(host) + return result + class CanonicalAliasManager(models.Manager): """Manager for Alias objects where is_canonical is True.""" diff --git a/multisite/tests.py b/multisite/tests.py index ea5adfb..16dbc37 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -66,29 +66,6 @@ def setUp(self): def tearDown(self): settings.SITE_ID.reset() - def test_expand_netloc(self): - _expand_netloc = self.middleware._expand_netloc - self.assertRaises(ValueError, _expand_netloc, '') - self.assertRaises(ValueError, _expand_netloc, ':8000') - self.assertEqual(_expand_netloc('testserver:8000'), - ['testserver:8000', 'testserver', - '*:8000', '*']) - self.assertEqual(_expand_netloc('testserver'), - ['testserver', '*']) - self.assertEqual(_expand_netloc('example.com:8000'), - ['example.com:8000', 'example.com', - '*.com:8000', '*.com', - '*:8000', '*']) - self.assertEqual(_expand_netloc('example.com'), - ['example.com', '*.com', '*']) - self.assertEqual(_expand_netloc('www.example.com:8000'), - ['www.example.com:8000', 'www.example.com', - '*.example.com:8000', '*.example.com', - '*.com:8000', '*.com', - '*:8000', '*']) - self.assertEqual(_expand_netloc('www.example.com'), - ['www.example.com', '*.example.com', '*.com', '*']) - def test_valid_domain(self): # Make the request request = self.factory.get('/') @@ -113,24 +90,6 @@ def test_case_sensitivity(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) - def test_wildcards(self): - # *.example.com - self.assertEqual(self.middleware.get_site('www.example.com'), - None) - self.assertEqual(self.middleware.get_site('www.dev.example.com'), - None) - Alias.objects.create(site=self.site, domain='*.example.com') - self.assertEqual(self.middleware.get_site('www.example.com'), - self.site) - self.assertEqual(self.middleware.get_site('www.dev.example.com'), - self.site) - # * - self.assertEqual(self.middleware.get_site('example.net'), - None) - Alias.objects.create(site=self.site, domain='*') - self.assertEqual(self.middleware.get_site('example.net'), - self.site) - def test_change_domain(self): # Make the request request = self.factory.get('/') @@ -594,3 +553,45 @@ def test_hooks(self): # Delete Site site.delete() self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_expand_netloc(self): + _expand_netloc = Alias.objects._expand_netloc + self.assertRaises(ValueError, _expand_netloc, '') + self.assertRaises(ValueError, _expand_netloc, ':8000') + self.assertEqual(_expand_netloc('testserver:8000'), + ['testserver:8000', 'testserver', + '*:8000', '*']) + self.assertEqual(_expand_netloc('testserver'), + ['testserver', '*']) + self.assertEqual(_expand_netloc('example.com:8000'), + ['example.com:8000', 'example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('example.com'), + ['example.com', '*.com', '*']) + self.assertEqual(_expand_netloc('www.example.com:8000'), + ['www.example.com:8000', 'www.example.com', + '*.example.com:8000', '*.example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('www.example.com'), + ['www.example.com', '*.example.com', '*.com', '*']) + + def test_resolve(self): + site = Site.objects.create(domain='example.com') + # *.example.com + self.assertEqual(Alias.objects.resolve('www.example.com'), + None) + self.assertEqual(Alias.objects.resolve('www.dev.example.com'), + None) + alias = Alias.objects.create(site=site, domain='*.example.com') + self.assertEqual(Alias.objects.resolve('www.example.com'), + alias) + self.assertEqual(Alias.objects.resolve('www.dev.example.com'), + alias) + # * + self.assertEqual(Alias.objects.resolve('example.net'), + None) + alias = Alias.objects.create(site=site, domain='*') + self.assertEqual(Alias.objects.resolve('example.net'), + alias) From 56fdb607526c31c77898dc9b809308ad3acc65a9 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 05:02:42 -0400 Subject: [PATCH 030/196] AliasManager.resolve() and _expand_netloc() take 'host' and 'port' params. This replaces the 'netloc' parameter, as it is more useful to parse it beforehand. --- multisite/middleware.py | 7 ++++++- multisite/models.py | 34 ++++++++++++++++------------------ multisite/tests.py | 8 ++++---- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 88f1752..3bc75b0 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -93,10 +93,15 @@ def process_request(self, request): settings.SITE_ID.set(site_id) return + if ':' in netloc: + host, port = netloc.rsplit(':', 1) + else: + host, port = netloc, None + # Cache missed site = None try: - alias = Alias.objects.resolve(netloc) + alias = Alias.objects.resolve(host=host, port=port) if alias: site = alias.site except ValueError: diff --git a/multisite/models.py b/multisite/models.py index 68a38a9..9240528 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -18,20 +18,20 @@ class AliasManager(models.Manager): def get_query_set(self): return super(AliasManager, self).get_query_set().select_related('site') - def resolve(self, netloc): + def resolve(self, host, port=None): """ - Returns the Alias that best matches ``netloc``, or None. + Returns the Alias that best matches ``host`` and ``port``, or None. - ``netloc`` can be a bare hostname ``'example.com'`` or a - hostname with a port number``'example.com:8000'``. + ``host`` is a hostname like ``'example.com'``. + ``port`` is a port number like 8000, or None. - Attempts to match by netloc with the port number first, - against Alias.domain. If that fails, it will try to match the - bare hostname with no port number. + Attempts to first match by 'host:port' against + Alias.domain. If that fails, it will try to match the bare + 'host' with no port number. All comparisons are done case-insensitively. """ - domains = self._expand_netloc(netloc) + domains = self._expand_netloc(host=host, port=port) q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) aliases = dict((a.domain, a) for a in self.get_query_set().filter(q)) for domain in domains: @@ -41,9 +41,12 @@ def resolve(self, netloc): pass @classmethod - def _expand_netloc(cls, netloc): + def _expand_netloc(cls, host, port=None): """ - Returns a list of possible domain expansions for ``netloc``. + Returns a list of possible domain expansions for ``host`` and ``port``. + + ``host`` is a hostname like ``'example.com'``. + ``port`` is a port number like 8000, or None. Expansions are ordered from highest to lowest preference and may include wildcards. Examples:: @@ -51,19 +54,14 @@ def _expand_netloc(cls, netloc): >>> AliasManager._expand_netloc('www.example.com') ['www.example.com', '*.example.com', '*.com', '*'] - >>> AliasManager._expand_netloc('www.example.com:80') + >>> AliasManager._expand_netloc('www.example.com', 80) ['www.example.com:80', 'www.example.com', '*.example.com:80', '*.example.com', '*.com:80', '*.com', '*:80', '*'] """ - if ':' in netloc: - host, port = netloc.rsplit(':', 1) - else: - host, port = netloc, None - if not host: - raise ValueError("Invalid netloc: %r" % netloc) + raise ValueError(u"Invalid host: %s" % host) try: validate_ipv4_address(host) @@ -79,7 +77,7 @@ def _expand_netloc(cls, netloc): else: host = '.'.join(['*'] + bits[i:]) if port: - result.append(host + ':' + port) + result.append("%s:%s" % (host, port)) result.append(host) return result diff --git a/multisite/tests.py b/multisite/tests.py index 16dbc37..400d74d 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -557,19 +557,19 @@ def test_hooks(self): def test_expand_netloc(self): _expand_netloc = Alias.objects._expand_netloc self.assertRaises(ValueError, _expand_netloc, '') - self.assertRaises(ValueError, _expand_netloc, ':8000') - self.assertEqual(_expand_netloc('testserver:8000'), + self.assertRaises(ValueError, _expand_netloc, '', 8000) + self.assertEqual(_expand_netloc('testserver', 8000), ['testserver:8000', 'testserver', '*:8000', '*']) self.assertEqual(_expand_netloc('testserver'), ['testserver', '*']) - self.assertEqual(_expand_netloc('example.com:8000'), + self.assertEqual(_expand_netloc('example.com', 8000), ['example.com:8000', 'example.com', '*.com:8000', '*.com', '*:8000', '*']) self.assertEqual(_expand_netloc('example.com'), ['example.com', '*.com', '*']) - self.assertEqual(_expand_netloc('www.example.com:8000'), + self.assertEqual(_expand_netloc('www.example.com', 8000), ['www.example.com:8000', 'www.example.com', '*.example.com:8000', '*.example.com', '*.com:8000', '*.com', From 1ed04aa5b030aba8bf5a783553407ff3fcc04b0e Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 14:11:08 -0400 Subject: [PATCH 031/196] models.py: Add Alias.redirect_to_canonical field. --- ...__add_field_alias_redirect_to_canonical.py | 34 +++++++++++++++++++ multisite/models.py | 1 + 2 files changed, 35 insertions(+) create mode 100644 multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py diff --git a/multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py b/multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py new file mode 100644 index 0000000..6854175 --- /dev/null +++ b/multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py @@ -0,0 +1,34 @@ +# encoding: utf-8 +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + def forwards(self, orm): + """Adding field 'Alias.redirect_to_canonical""" + db.add_column('multisite_alias', 'redirect_to_canonical', + self.gf('django.db.models.fields.BooleanField')(default=True), + keep_default=False) + + def backwards(self, orm): + """Deleting field 'Alias.redirect_to_canonical'""" + db.delete_column('multisite_alias', 'redirect_to_canonical') + + models = { + 'multisite.alias': { + 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, + 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'redirect_to_canonical': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + } + } + + complete_apps = ['multisite'] diff --git a/multisite/models.py b/multisite/models.py index 9240528..451ec56 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -147,6 +147,7 @@ class Alias(models.Model): unique=True) is_canonical = models.NullBooleanField(default=None, editable=False, validators=[validate_true_or_none]) + redirect_to_canonical = models.BooleanField(default=True) objects = AliasManager() canonical = CanonicalAliasManager() From b46a841a362817d40b31e016f31313bfd991e846 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 14:11:44 -0400 Subject: [PATCH 032/196] middleware.py: DynamicSiteMiddleware redirects to canonical domain names. --- multisite/middleware.py | 87 ++++++++++++++++++++++++++--------------- multisite/tests.py | 23 ++++++++++- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 3bc75b0..9cc9f61 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from urlparse import urlsplit, urlunsplit from django.conf import settings from django.contrib.sites.models import Site @@ -7,7 +8,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import get_callable from django.db.models.signals import pre_save, post_delete -from django.http import Http404 +from django.http import Http404, HttpResponsePermanentRedirect from django.utils.hashcompat import md5_constructor from .models import Alias @@ -30,21 +31,48 @@ def __init__(self): def get_cache_key(self, netloc): """Returns a cache key based on ``netloc``.""" netloc = md5_constructor(netloc) - return 'multisite.site_id.%s.%s' % (self.key_prefix, - netloc.hexdigest()) + return 'multisite.alias.%s.%s' % (self.key_prefix, + netloc.hexdigest()) - def get_testserver_site(self, netloc): + def netloc_parse(self, netloc): """ - Returns valid Site when running Django tests. Otherwise, returns None. + Returns ``(host, port)`` for ``netloc`` of the form ``'host:port'``. + + If netloc does not have a port number, ``port`` will be None. + """ + if ':' in netloc: + return netloc.rsplit(':', 1) + else: + return netloc, None + + def get_testserver_alias(self, netloc): + """ + Returns valid Alias when running Django tests. Otherwise, returns None. """ if hasattr(mail, 'outbox') and netloc == 'testserver': try: # Prefer the default SITE_ID site_id = settings.SITE_ID.get_default() - return Site.objects.get(pk=site_id) + return Alias.canonical.get(site=site_id) except ValueError: # Fallback to the first Site object - return Site.objects.order_by('pk')[0] + return Alias.canonical.order_by('site')[0] + + def get_alias(self, netloc): + """ + Returns Alias matching ``netloc``. Otherwise, returns None. + """ + host, port = self.netloc_parse(netloc) + + try: + alias = Alias.objects.resolve(host=host, port=port) + except ValueError: + alias = None + + if alias is None: + # Running under TestCase? + return self.get_testserver_alias(netloc) + return alias def fallback_view(self, request): """ @@ -82,41 +110,38 @@ def fallback_view(self, request): # View function return view(request, **kwargs) + def redirect_to_canonical(self, request, alias): + if not alias.redirect_to_canonical or alias.is_canonical: + return + url = urlsplit(request.build_absolute_uri(request.get_full_path())) + url = urlunsplit((url.scheme, + alias.site.domain, + url.path, url.query, url.fragment)) + return HttpResponsePermanentRedirect(url) + def process_request(self, request): netloc = request.get_host().lower() cache_key = self.get_cache_key(netloc) - # Find the SITE_ID in the cache - site_id = self.cache.get(cache_key) - if site_id is not None: - self.cache.set(cache_key, site_id) - settings.SITE_ID.set(site_id) - return - - if ':' in netloc: - host, port = netloc.rsplit(':', 1) - else: - host, port = netloc, None + # Find the Alias in the cache + alias = self.cache.get(cache_key) + if alias is not None: + self.cache.set(cache_key, alias) + settings.SITE_ID.set(alias.site_id) + return self.redirect_to_canonical(request, alias) # Cache missed - site = None - try: - alias = Alias.objects.resolve(host=host, port=port) - if alias: - site = alias.site - except ValueError: - pass - # Running under TestCase? - if site is None: - site = self.get_testserver_site(netloc) + alias = self.get_alias(netloc) + # Fallback using settings.MULTISITE_FALLBACK - if site is None: + if alias is None: settings.SITE_ID.reset() return self.fallback_view(request) # Found Site - self.cache.set(cache_key, site.pk) - settings.SITE_ID.set(site.pk) + self.cache.set(cache_key, alias) + settings.SITE_ID.set(alias.site_id) + return self.redirect_to_canonical(request, alias) def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): """Clears the cache if Site.domain has changed.""" diff --git a/multisite/tests.py b/multisite/tests.py index 400d74d..29224eb 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -134,6 +134,26 @@ def test_no_sites(self): self.middleware.process_request, request) self.assertEqual(settings.SITE_ID, 0) + def test_redirect(self): + host = 'example.org' + alias = Alias.objects.create(site=self.site, domain=host) + self.assertTrue(alias.redirect_to_canonical) + # Make the request + request = self.factory.get('/path', host=host) + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 301) + self.assertEqual(response['Location'], + "http://%s/path" % self.host) + + def test_no_redirect(self): + host = 'example.org' + Alias.objects.create(site=self.site, domain=host, + redirect_to_canonical=False) + # Make the request + request = self.factory.get('/path', host=host) + self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @@ -247,7 +267,8 @@ def test_site_domain_changed(self): # Make the request request = self.factory.get('/') self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(self.middleware.cache.get(cache_key), self.site.pk) + self.assertEqual(self.middleware.cache.get(cache_key).site_id, + self.site.pk) # Change the domain name self.site.domain = 'example.org' self.site.save() From 43ff35dbd48280d4c4ac57219eac3ce80acc881b Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 14:35:17 -0400 Subject: [PATCH 033/196] admin.py: PEP8 and PyFlakes --- multisite/admin.py | 100 ++++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index 6b549b5..b13fc20 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -8,18 +8,21 @@ class MultisiteChangeList(ChangeList): A ChangeList like the built-in admin one, but it excludes site filters for sites you're not associated with, unless you're a super-user. - At this point, it's probably fragile, given its reliance on Django internals. + At this point, it's probably fragile, given its reliance on Django + internals. """ def get_filters(self, request, *args, **kwargs): """ - This might be considered a fragile function, since it relies on a fair bit - of Django's internals. + This might be considered a fragile function, since it relies on a + fair bit of Django's internals. """ - filter_specs, has_filter_specs = super(MultisiteChangeList, self).get_filters(request, *args, **kwargs) + get_filters = super(MultisiteChangeList, self).get_filters + filter_specs, has_filter_specs = get_filters(request, *args, **kwargs) if request.user.is_superuser or not has_filter_specs: return filter_specs, has_filter_specs new_filter_specs = [] - user_sites = frozenset(request.user.get_profile().sites.values_list("pk", "domain")) + profile = request.user.get_profile() + user_sites = frozenset(profile.sites.values_list("pk", "domain")) for filter_spec in filter_specs: try: rel_to = filter_spec.field.rel.to @@ -39,10 +42,10 @@ def get_filters(self, request, *args, **kwargs): return new_filter_specs, bool(new_filter_specs) - class MultisiteModelAdmin(admin.ModelAdmin): """ - A very helpful modeladmin class for handling multi-site django applications. + A very helpful ModelAdmin class for handling multi-site django + applications. """ filter_sites_by_current_object = False @@ -52,9 +55,9 @@ def queryset(self, request): Filters lists of items to items belonging to sites assigned to the current member. - Additionally, for cases where the field containing a reference to 'site' - or 'sites' isn't immediate-- one can supply the ModelAdmin class with - a list of fields to check the site of: + Additionally, for cases where the field containing a reference + to 'site' or 'sites' isn't immediate -- one can supply the + ModelAdmin class with a list of fields to check the site of: - multisite_filter_fields A list of paths to a 'site' or 'sites' field on a related model to @@ -68,36 +71,40 @@ def queryset(self, request): user_sites = request.user.get_profile().sites.all() if hasattr(qs.model, "site"): - qs = qs.filter(site__in = user_sites) + qs = qs.filter(site__in=user_sites) elif hasattr(qs.model, "sites"): - qs = qs.filter(sites__in = user_sites) + qs = qs.filter(sites__in=user_sites) if hasattr(self, "multisite_filter_fields"): for field in self.multisite_filter_fields: qkwargs = { - "{field}__in".format(field = field): user_sites + "{field}__in".format(field=field): user_sites } qs = qs.filter(**qkwargs) return qs - def add_view(self, request, form_url = '', extra_context = None): + def add_view(self, request, form_url='', extra_context=None): if self.filter_sites_by_current_object: if hasattr(self.model, "site") or hasattr(self.model, "sites"): self.object_sites = tuple() - return super(MultisiteModelAdmin, self).add_view(request, form_url, extra_context) + return super(MultisiteModelAdmin, self).add_view(request, form_url, + extra_context) - def change_view(self, request, object_id, extra_context = None): + def change_view(self, request, object_id, extra_context=None): if self.filter_sites_by_current_object: object_instance = self.get_object(request, object_id) try: - self.object_sites = object_instance.sites.values_list("pk", flat = True) + self.object_sites = object_instance.sites.values_list( + "pk", flat=True + ) except AttributeError: try: - self.object_sites = (object_instance.site.pk, ) + self.object_sites = (object_instance.site.pk,) except AttributeError: - pass # assume the object doesn't belong to a site - return super(MultisiteModelAdmin, self).change_view(request, object_id, extra_context) + pass # assume the object doesn't belong to a site + return super(MultisiteModelAdmin, self).change_view(request, object_id, + extra_context) def handle_multisite_foreign_keys(self, db_field, request, **kwargs): """ @@ -114,15 +121,16 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): - multisite_foreign_key_site_path - to a dictionary pointing specific foreign key field instances from their - model to the site field to filter on something like: + to a dictionary pointing specific foreign key field instances + from their model to the site field to filter on something + like: multisite_indirect_foreign_key_path = { 'plan_instance': 'plan__site' } - for a field named 'plan_instance' referencing a model with a foreign key - named 'plan' having a foreign key to 'site'. + for a field named 'plan_instance' referencing a model with a + foreign key named 'plan' having a foreign key to 'site'. To filter the FK queryset to the same sites the current object belongs to, simply set `filter_sites_by_current_object` to `True`. @@ -139,44 +147,50 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): user_sites = Site.objects.all() else: user_sites = request.user.get_profile().sites.all() - if self.filter_sites_by_current_object and hasattr(self, "object_sites"): - sites = user_sites.filter( - pk__in = self.object_sites - ) + if self.filter_sites_by_current_object and \ + hasattr(self, "object_sites"): + sites = user_sites.filter(pk__in=self.object_sites) else: sites = user_sites if hasattr(db_field.rel.to, "site"): kwargs["queryset"] = db_field.rel.to._default_manager.filter( - site__in = user_sites + site__in=user_sites ) if hasattr(db_field.rel.to, "sites"): kwargs["queryset"] = db_field.rel.to._default_manager.filter( - sites__in = user_sites + sites__in=user_sites ) if db_field.name == "site" or db_field.name == "sites": kwargs["queryset"] = user_sites - if hasattr(self, "multisite_indirect_foreign_key_path"): - if db_field.name in self.multisite_indirect_foreign_key_path.keys(): - qkwargs = { - self.multisite_indirect_foreign_key_path[db_field.name]: user_sites - } - kwargs["queryset"] = db_field.rel.to._default_manager.filter(**qkwargs) + if hasattr(self, "multisite_indirect_foreign_key_path") and \ + db_field.name in self.multisite_indirect_foreign_key_path.keys(): + fkey = self.multisite_indirect_foreign_key_path[db_field.name] + kwargs["queryset"] = db_field.rel.to._default_manager.filter( + **{fkey: user_sites} + ) return kwargs def formfield_for_foreignkey(self, db_field, request, **kwargs): - kwargs = self.handle_multisite_foreign_keys(db_field, request, **kwargs) - return super(MultisiteModelAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) + kwargs = self.handle_multisite_foreign_keys(db_field, request, + **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_foreignkey( + db_field, request, **kwargs + ) def formfield_for_manytomany(self, db_field, request, **kwargs): - kwargs = self.handle_multisite_foreign_keys(db_field, request, **kwargs) - return super(MultisiteModelAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) + kwargs = self.handle_multisite_foreign_keys(db_field, request, + **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_manytomany( + db_field, request, **kwargs + ) def get_changelist(self, request, **kwargs): """ - Restrict the site filter (if there is one) to sites you are associated with, - or remove it entirely if you're just associated with one site. Unless you're a - super-user, of course. + Restrict the site filter (if there is one) to sites you are + associated with, or remove it entirely if you're just + associated with one site. Unless you're a super-user, of + course. """ return MultisiteChangeList From 2bfa170e121f8ffc4617a64ef81c038dfebe0d62 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 15:38:20 -0400 Subject: [PATCH 034/196] middleware.py: Allow missing Alias when under runserver and settings.DEBUG=True --- multisite/middleware.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 9cc9f61..f418295 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -45,11 +45,22 @@ def netloc_parse(self, netloc): else: return netloc, None - def get_testserver_alias(self, netloc): + def get_development_alias(self, netloc): """ - Returns valid Alias when running Django tests. Otherwise, returns None. + Returns valid Alias when in development mode. Otherwise, returns None. + + Development mode is either: + - Running tests, i.e. manage.py test + - Running locally in settings.DEBUG = True, where the hostname is + a top-level name, i.e. localhost """ - if hasattr(mail, 'outbox') and netloc == 'testserver': + # When running tests, django.core.mail.outbox exists and + # netloc == 'testserver' + is_testserver = (hasattr(mail, 'outbox') and netloc == 'testserver') + # When using runserver, assume that host will only have one path + # component. This covers 'localhost' and your machine name. + is_local_debug = (settings.DEBUG and len(netloc.split('.')) == 1) + if is_testserver or is_local_debug: try: # Prefer the default SITE_ID site_id = settings.SITE_ID.get_default() @@ -70,8 +81,8 @@ def get_alias(self, netloc): alias = None if alias is None: - # Running under TestCase? - return self.get_testserver_alias(netloc) + # Running under TestCase or runserver? + return self.get_development_alias(netloc) return alias def fallback_view(self, request): From 177a555f588cfad7ea83b8dde2fb9418f9b3b8bf Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 15:41:50 -0400 Subject: [PATCH 035/196] models.py: Force post_syncdb signal to fire. --- multisite/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/models.py b/multisite/models.py index 451ec56..eb6c50b 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -304,4 +304,4 @@ def db_table_created_hook(cls, created_models, *args, **kwargs): post_save.connect(Alias.site_created_hook, sender=Site) # Hook to handle syncdb creating the Alias table -post_syncdb.connect(Alias.db_table_created_hook, sender=Alias.__module__) +post_syncdb.connect(Alias.db_table_created_hook) From e6076ef0c2edc09e4ba83b4382037150cd754bc9 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 1 Jun 2012 18:15:03 -0400 Subject: [PATCH 036/196] admin.py: AdminAdmin for Alias model and an AliasInline for Sites. --- multisite/admin.py | 33 +++++++++++++++++++++++++++++++++ multisite/models.py | 23 +++++++++++++++++------ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index b13fc20..984c300 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -1,6 +1,39 @@ from django.contrib import admin from django.contrib.admin.views.main import ChangeList from django.contrib.sites.models import Site +from django.contrib.sites.admin import SiteAdmin + +from .models import Alias + + +class AliasAdmin(admin.ModelAdmin): + """Admin for Alias model.""" + list_display = ('domain', 'site', 'is_canonical', 'redirect_to_canonical') + list_filter = ('is_canonical', 'redirect_to_canonical') + ordering = ('domain',) + raw_id_fields = ('site',) + readonly_fields = ('is_canonical',) + search_fields = ('domain',) + +admin.site.register(Alias, AliasAdmin) + + +class AliasInline(admin.TabularInline): + """Inline for Alias model, showing non-canonical aliases.""" + model = Alias + extra = 1 + ordering = ('domain',) + + def queryset(self, request): + """Returns only non-canonical aliases.""" + qs = self.model.aliases.get_query_set() + ordering = self.ordering or () + if ordering: + qs = qs.order_by(*ordering) + return qs + +# HACK: Monkeypatch AliasInline into SiteAdmin +SiteAdmin.inlines = type(SiteAdmin.inlines)([AliasInline]) + SiteAdmin.inlines class MultisiteChangeList(ChangeList): diff --git a/multisite/models.py b/multisite/models.py index eb6c50b..d2d4fe2 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -141,13 +141,24 @@ class Alias(models.Model): Alias. """ + domain = type(_site_domain)( + _('domain name'), + max_length=_site_domain.max_length, + unique=True, + help_text=_('Either "domain" or "domain:port"'), + ) site = models.ForeignKey(Site, related_name='aliases') - domain = type(_site_domain)(_('domain name'), - max_length=_site_domain.max_length, - unique=True) - is_canonical = models.NullBooleanField(default=None, editable=False, - validators=[validate_true_or_none]) - redirect_to_canonical = models.BooleanField(default=True) + is_canonical = models.NullBooleanField( + _('is canonical?'), + default=None, editable=False, + validators=[validate_true_or_none], + help_text=_('Does this domain name match the one in site?'), + ) + redirect_to_canonical = models.BooleanField( + _('redirect to canonical?'), + default=True, + help_text=_('Should this domain name redirect to the one in site?'), + ) objects = AliasManager() canonical = CanonicalAliasManager() From 0d7b07f160e44708c1e05479a9b8655b264beda4 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 4 Jun 2012 16:36:11 -0400 Subject: [PATCH 037/196] setup.py: Remove auto-discovery of packages - do it the old-fashioned way. --- .gitignore | 1 + MANIFEST.in | 2 ++ setup.py | 30 +++--------------------------- 3 files changed, 6 insertions(+), 27 deletions(-) create mode 100644 MANIFEST.in diff --git a/.gitignore b/.gitignore index 48905ee..85a1fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ dist downloads eggs parts +MANIFEST multisite/*.egg-info diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..843aed3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include INSTALL.txt +include README.markdown diff --git a/setup.py b/setup.py index cf29754..595a55a 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,4 @@ from distutils.core import setup -import os - -# Compile the list of packages available, because distutils doesn't have -# an easy way to do this. -packages, data_files = [], [] -root_dir = os.path.dirname(__file__) -if root_dir: - os.chdir(root_dir) - -for dirpath, dirnames, filenames in os.walk('multisite'): - # Ignore dirnames that start with '.' - for i, dirname in enumerate(dirnames): - if dirname.startswith('.'): del dirnames[i] - if '__init__.py' in filenames: - pkg = dirpath.replace(os.path.sep, '.') - if os.path.altsep: - pkg = pkg.replace(os.path.altsep, '.') - packages.append(pkg) - elif filenames: - prefix = dirpath[10:] # Strip "multisite/" or "multisite\" - for f in filenames: - data_files.append(os.path.join(prefix, f)) setup(name='django-multisite', @@ -29,9 +7,8 @@ author='Leonid S Shestera', author_email='leonid@shestera.ru', url='http://github.com/shestera/django-multisite', - package_dir={'multisite': 'multisite'}, - packages=packages, - package_data={'multisite': data_files}, + packages=['multisite', + 'multisite.migrations'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Intended Audience :: Developers', @@ -39,5 +16,4 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Utilities'], - ) - +) From b2ff78b73a3d83628034bc7f32c6321c2126e6f3 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 4 Jun 2012 16:44:59 -0400 Subject: [PATCH 038/196] Convert README.markdown to README.rst: reStructuredText --HG-- rename : README.markdown => README.rst --- MANIFEST.in | 2 +- README.markdown => README.rst | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) rename README.markdown => README.rst (84%) diff --git a/MANIFEST.in b/MANIFEST.in index 843aed3..fd52153 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ include INSTALL.txt -include README.markdown +include README.rst diff --git a/README.markdown b/README.rst similarity index 84% rename from README.markdown rename to README.rst index 4f4330a..9d26917 100644 --- a/README.markdown +++ b/README.rst @@ -1,25 +1,25 @@ README ====== -Get the code via git: +Get the code via git:: git clone git://github.com/plazix/django-multisite.git django-multisite Add the django-multisite/multisite folder to your PYTHONPATH. -Replace your SITE_ID in settings.py to: +Replace your SITE_ID in settings.py to:: from multisite import SiteID SITE_ID = SiteID() -Add to settings.py TEMPLATE_LOADERS: +Add to settings.py TEMPLATE_LOADERS:: TEMPLATE_LOADERS = ( 'multisite.template_loader.Loader', 'django.template.loaders.app_directories.Loader', ) -Edit to settings.py MIDDLEWARE_CLASSES: +Edit to settings.py MIDDLEWARE_CLASSES:: MIDDLEWARE_CLASSES = ( ... @@ -28,7 +28,7 @@ Edit to settings.py MIDDLEWARE_CLASSES: ) Append to settings.py, in order to use a custom cache that can be -safely cleared: +safely cleared:: # The cache connection to use for django-multisite. # Default: 'default' @@ -38,8 +38,8 @@ safely cleared: # Default: '' (Empty string) CACHE_MULTISITE_KEY_PREFIX = '' -If you have set CACHE\_MULTISITE\_ALIAS to a custom value, _e.g._ -`'multisite'`, add a separate backend to settings.py CACHES: +If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* +``'multisite'``, add a separate backend to settings.py CACHES:: CACHES = { 'default': { @@ -54,7 +54,7 @@ If you have set CACHE\_MULTISITE\_ALIAS to a custom value, _e.g._ By default, if the domain name is unknown, multisite will respond with an HTTP 404 Not Found error. To change this behaviour, add to -settings.py: +settings.py:: # The view function or class-based view that django-multisite will # use when it cannot match the hostname with a Site. This can be @@ -67,8 +67,7 @@ settings.py: MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', 'permanent': False} -Create a directory settings.TEMPLATE_DIRS directory with the names of domains, such as: +Create a directory settings.TEMPLATE_DIRS directory with the names of +domains, such as:: mkdir templates/example.com - - From 2a257345fb6e76c36214f5f5a9fe5510b50b0ebd Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 4 Jun 2012 17:10:42 -0400 Subject: [PATCH 039/196] setup.py: Add long_description for PyPi. --- setup.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/setup.py b/setup.py index 595a55a..d6fc210 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,19 @@ from distutils.core import setup +import os + +_dir_ = os.path.dirname(__file__) + + +def long_description(): + """Returns the value of README.rst""" + with open(os.path.join(_dir_, 'README.rst')) as f: + return f.read() setup(name='django-multisite', version='0.1', description='Multisite for Django', + long_description=long_description(), author='Leonid S Shestera', author_email='leonid@shestera.ru', url='http://github.com/shestera/django-multisite', From 92fbf398ee02fc1ad3a2af9a51d2093444142d7d Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 4 Jun 2012 17:28:43 -0400 Subject: [PATCH 040/196] threadlocals.py: SiteID.override() is a contextmanager that overrides SITE_ID. --- multisite/tests.py | 9 +++++++++ multisite/threadlocals.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 29224eb..1dc0b13 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -344,6 +344,15 @@ def test_str_repr(self): self.assertEqual(str(self.site_id), '10') self.assertEqual(repr(self.site_id), '10') + def test_context_manager(self): + self.assertEqual(self.site_id.site_id, None) + with self.site_id.override(1): + self.assertEqual(self.site_id.site_id, 1) + with self.site_id.override(2): + self.assertEqual(self.site_id.site_id, 2) + self.assertEqual(self.site_id.site_id, 1) + self.assertEqual(self.site_id.site_id, None) + @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 3704152..2dd9368 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -* +from contextlib import contextmanager from warnings import warn try: @@ -89,6 +90,20 @@ def __ge__(self, other): def __hash__(self): return self.__int__() + @contextmanager + def override(self, value): + """ + Overrides SITE_ID temporarily:: + + >>> with settings.SITE_ID.override(2): + ... print settings.SITE_ID + 2 + """ + site_id = self.site_id + self.set(value) + yield self + self.site_id = site_id + def set(self, value): from django.db.models import Model if isinstance(value, Model): From 283eb44640e871d8ac7a68d0bde54cd82f59b277 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 4 Jun 2012 19:12:04 -0400 Subject: [PATCH 041/196] hacks.py: Monkeypatch django.contrib.sites to use Django's cache framework --- multisite/hacks.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ multisite/models.py | 3 +++ 2 files changed, 61 insertions(+) create mode 100644 multisite/hacks.py diff --git a/multisite/hacks.py b/multisite/hacks.py new file mode 100644 index 0000000..f1abfae --- /dev/null +++ b/multisite/hacks.py @@ -0,0 +1,58 @@ +import sys + +from django.conf import settings + + +def use_framework_for_site_cache(): + """Patches sites app to use the caching framework instead of a dict.""" + from django.contrib.sites import models + from django.core.cache import get_cache + + # Patch the SITE_CACHE + cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') + key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', '') + models.SITE_CACHE = DictCache(get_cache(backend=cache_alias, + KEY_PREFIX=key_prefix)) + + # Patch the SiteManager class + models.SiteManager.clear_cache = SiteManager_clear_cache + + +# Override SiteManager.clear_cache so it doesn't clobber SITE_CACHE +def SiteManager_clear_cache(self): + """Clears the ``Site`` object cache.""" + models = sys.modules.get(self.__class__.__module__) + models.SITE_CACHE.clear() + + +class DictCache(object): + """Add dictionary protocol to django.core.cache.backends.BaseCache.""" + def __init__(self, cache): + self._cache = cache + + def __getitem__(self, key): + """x.__getitem__(y) <==> x[y]""" + hash(key) # Raise TypeError if unhashable + result = self._cache.get(key=key) + if result is None: + raise KeyError(key) + return result + + def __setitem__(self, key, value): + """x.__setitem__(i, y) <==> x[i]=y""" + hash(key) # Raise TypeError if unhashable + self._cache.set(key=key, value=value) + + def __delitem__(self, key): + """x.__delitem__(y) <==> del x[y]""" + hash(key) # Raise TypeError if unhashable + self._cache.delete(key=key) + + def __contains__(self, item): + """D.__contains__(k) -> True if D has a key k, else False""" + hash(item) # Raise TypeError if unhashable + return self._cache.__contains__(key=item) + + def clear(self): + """D.clear() -> None. Remove all items from D.""" + self._cache.clear() diff --git a/multisite/models.py b/multisite/models.py index d2d4fe2..7ea14ea 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -8,9 +8,12 @@ from django.db.models.signals import pre_save, post_save, post_syncdb from django.utils.translation import ugettext_lazy as _ +from .hacks import use_framework_for_site_cache _site_domain = Site._meta.get_field('domain') +use_framework_for_site_cache() + class AliasManager(models.Manager): """Manager for all Aliases.""" From d12e40d082065813fca5a5967ea1ceb6cbb9476e Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 Jun 2012 11:28:15 -0400 Subject: [PATCH 042/196] Caching now uses key prefixes properly. --- multisite/hacks.py | 43 ++++++++++++++++++++++++++++++++++++----- multisite/middleware.py | 2 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index f1abfae..1187101 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -6,13 +6,9 @@ def use_framework_for_site_cache(): """Patches sites app to use the caching framework instead of a dict.""" from django.contrib.sites import models - from django.core.cache import get_cache # Patch the SITE_CACHE - cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') - key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', '') - models.SITE_CACHE = DictCache(get_cache(backend=cache_alias, - KEY_PREFIX=key_prefix)) + models.SITE_CACHE = DictCache(SiteCache()) # Patch the SiteManager class models.SiteManager.clear_cache = SiteManager_clear_cache @@ -25,8 +21,45 @@ def SiteManager_clear_cache(self): models.SITE_CACHE.clear() +class SiteCache(object): + """Wrapper for SITE_CACHE that assigns a key_prefix.""" + + def __init__(self, cache=None, key_prefix=None): + from django.core.cache import get_cache + + if key_prefix is None: + key_prefix = getattr(settings, 'CACHE_SITES_KEY_PREFIX', '') + self.key_prefix = key_prefix + + if cache is None: + cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') + cache = get_cache(cache_alias, KEY_PREFIX=self.key_prefix) + self._cache = cache + + def _get_cache_key(self, key): + return 'sites.%s.%s' % (self.key_prefix, key) + + def get(self, key, *args, **kwargs): + return self._cache.get(key=self._get_cache_key(key), *args, **kwargs) + + def set(self, key, value, *args, **kwargs): + self._cache.set(key=self._get_cache_key(key), value=value, + *args, **kwargs) + + def delete(self, key, *args, **kwargs): + self._cache.delete(key=self._get_cache_key(key), *args, **kwargs) + + def __contains__(self, key, *args, **kwargs): + return self._cache.__contains__(key=self._get_cache_key(key), + *args, **kwargs) + + def clear(self, *args, **kwargs): + self._cache.clear(*args, **kwargs) + + class DictCache(object): """Add dictionary protocol to django.core.cache.backends.BaseCache.""" + def __init__(self, cache): self._cache = cache diff --git a/multisite/middleware.py b/multisite/middleware.py index f418295..e7ba557 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -24,7 +24,7 @@ def __init__(self): 'default') self.key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', '') - self.cache = get_cache(self.cache_alias) + self.cache = get_cache(self.cache_alias, KEY_PREFIX=self.key_prefix) pre_save.connect(self.site_domain_changed_hook, sender=Site) post_delete.connect(self.site_deleted_hook, sender=Site) From 69a4a2774324e119c2263b8c398ee1eeafb40edf Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 Jun 2012 14:40:48 -0400 Subject: [PATCH 043/196] Update django.contrib.sites.models.SITE_CACHE when Site objects change. --- multisite/hacks.py | 28 +++++++++++++++++++++++-- multisite/tests.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 1187101..da81a04 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -1,18 +1,28 @@ import sys from django.conf import settings +from django.db.models.signals import post_save, post_delete def use_framework_for_site_cache(): """Patches sites app to use the caching framework instead of a dict.""" + # This patch has to exist because SITE_CACHE is normally a dict, + # which is local only to the process. When running multiple + # processes, a change to a Site will not be reflected across other + # ones. from django.contrib.sites import models # Patch the SITE_CACHE - models.SITE_CACHE = DictCache(SiteCache()) + site_cache = SiteCache() + models.SITE_CACHE = DictCache(site_cache) # Patch the SiteManager class models.SiteManager.clear_cache = SiteManager_clear_cache + # Hooks to update SiteCache + post_save.connect(site_cache._site_changed_hook, sender=models.Site) + post_delete.connect(site_cache._site_deleted_hook, sender=models.Site) + # Override SiteManager.clear_cache so it doesn't clobber SITE_CACHE def SiteManager_clear_cache(self): @@ -39,11 +49,17 @@ def __init__(self, cache=None, key_prefix=None): def _get_cache_key(self, key): return 'sites.%s.%s' % (self.key_prefix, key) + def _clean_site(self, site): + # Force site.id to be an int, not a SiteID object. + site.id = int(site.id) + return site + def get(self, key, *args, **kwargs): return self._cache.get(key=self._get_cache_key(key), *args, **kwargs) def set(self, key, value, *args, **kwargs): - self._cache.set(key=self._get_cache_key(key), value=value, + self._cache.set(key=self._get_cache_key(key), + value=self._clean_site(value), *args, **kwargs) def delete(self, key, *args, **kwargs): @@ -56,6 +72,14 @@ def __contains__(self, key, *args, **kwargs): def clear(self, *args, **kwargs): self._cache.clear(*args, **kwargs) + def _site_changed_hook(self, sender, instance, raw, *args, **kwargs): + if raw: + return + self.set(key=instance.pk, value=instance) + + def _site_deleted_hook(self, sender, instance, *args, **kwargs): + self.delete(key=instance.pk) + class DictCache(object): """Add dictionary protocol to django.core.cache.backends.BaseCache.""" diff --git a/multisite/tests.py b/multisite/tests.py index 1dc0b13..ac449df 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -280,6 +280,57 @@ def test_site_domain_changed(self): self.assertEqual(settings.SITE_ID, 0) +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings( + SITE_ID=SiteID(), + CACHE_SITES_ALIAS='django.core.cache.backends.locmem.LocMemCache', + CACHE_SITES_KEY_PREFIX='', +) +class SiteCacheTest(TestCase): + def setUp(self): + from django.contrib.sites import models + Site.objects.all().delete() + self.host = 'example.com' + self.site = Site.objects.create(domain=self.host) + self.cache = models.SITE_CACHE + settings.SITE_ID.set(self.site.id) + + def test_get_current(self): + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + # Clear cache + self.cache.clear() + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + + def test_create_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Create new site + site = Site.objects.create(domain='example.org') + settings.SITE_ID.set(site.id) + self.assertEqual(Site.objects.get_current(), site) + self.assertEqual(Site.objects.get_current().domain, site.domain) + + def test_change_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Change site domain + self.site.domain = 'example.org' + self.site.save() + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + + def test_delete_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Delete site + self.site.delete() + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + + class TestSiteID(TestCase): def setUp(self): Site.objects.all().delete() From 88716a3a171bdabe30214f1e586915d8daf51130 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 Jun 2012 16:57:20 -0400 Subject: [PATCH 044/196] managers.py: SpanningCurrentSiteManager replaces PathAssistedCurrentSiteManager --- multisite/managers.py | 97 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/multisite/managers.py b/multisite/managers.py index 2ce0a9a..0d0dd88 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -1,13 +1,102 @@ -from django.contrib.sites.managers import CurrentSiteManager -from django.contrib.sites.models import Site +# -*- coding: utf-8 -* +from warnings import warn -class PathAssistedCurrentSiteManager(CurrentSiteManager): +from django.db import models +from django.contrib.sites import managers +from django.db.models.fields import FieldDoesNotExist +from django.db.models.sql import constants + + +class SpanningCurrentSiteManager(managers.CurrentSiteManager): + """As opposed to django.contrib.sites.managers.CurrentSiteManager, this + CurrentSiteManager can span multiple related models by using the django + filtering syntax, namely foo__bar__baz__site. + + For example, let's say you have a model called Layer, which has a field + called family, which points to a model called LayerFamily, which in + turn has a field called site pointing to a django.contrib.sites Site + model. On Layer, add the following manager: + + on_site = SpanningCurrentSiteManager("family__site") + + and it will do the proper thing.""" + + def _validate_field_name(self): + """Given the field identifier, goes down the chain to check that + each specified field + a) exists, + b) is of type ForeignKey or ManyToManyField + + If no field name is specified when instantiating + SpanningCurrentSiteManager, it tries to find either 'site' or + 'sites' as the site link, much like CurrentSiteManager does. + """ + if self._CurrentSiteManager__field_name is None: + # Guess at field name + field_names = self.model._meta.get_all_field_names() + for potential_name in ['site', 'sites']: + if potential_name in field_names: + self._CurrentSiteManager__field_name = potential_name + break + else: + raise ValueError( + "%s couldn't find a field named either 'site' or 'sites' " + "in %s." % + (self.__class__.__name__, self.model._meta.object_name) + ) + + fieldname_chain = self._CurrentSiteManager__field_name.split( + constants.LOOKUP_SEP + ) + model = self.model + + for fieldname in fieldname_chain: + # Throws an exception if anything goes bad + self._validate_single_field_name(model, fieldname) + model = self._get_related_model(model, fieldname) + + # If we get this far without an exception, everything is good + self._CurrentSiteManager__is_validated = True + + def _validate_single_field_name(self, model, field_name): + """Checks if the given fieldname can be used to make a link between a + model and a site with the SpanningCurrentSiteManager class. If + anything is wrong, will raises an appropriate exception, because that + is what CurrentSiteManager expects.""" + try: + field = model._meta.get_field(field_name) + if not isinstance(field, (models.ForeignKey, + models.ManyToManyField)): + raise TypeError( + "Field %r must be a ForeignKey or ManyToManyField." + % field_name + ) + except FieldDoesNotExist: + raise ValueError( + "Couldn't find a field named %r in %s." % + (field_name, model._meta.object_name) + ) + + def _get_related_model(self, model, fieldname): + """Given a model and the name of a ForeignKey or ManyToManyField column + as a string, returns the associated model.""" + return model._meta.get_field_by_name(fieldname)[0].rel.to + + +class PathAssistedCurrentSiteManager(models.CurrentSiteManager): + """ + Deprecated: Use multisite.managers.SpanningCurrentSiteManager instead. + """ def __init__(self, field_path): + warn(('Use multisite.managers.SpanningCurrentSiteManager instead of ' + 'multisite.managers.PathAssistedCurrentSiteManager'), + DeprecationWarning, stacklevel=2) super(PathAssistedCurrentSiteManager, self).__init__() self.__field_path = field_path def get_query_set(self): - return super(CurrentSiteManager, self).get_query_set().filter( + from django.contrib.sites.models import Site + return super(models.CurrentSiteManager, self).get_query_set().filter( **{self.__field_path: Site.objects.get_current()} ) From e4481acc22bde8cbfafa0045e8f97a603ea07056 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 Jun 2012 17:45:45 -0400 Subject: [PATCH 045/196] hacks.py: Warn if the SITE_CACHE backend is the wrong type. --- multisite/hacks.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/multisite/hacks.py b/multisite/hacks.py index da81a04..c89ea30 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -1,4 +1,5 @@ import sys +from warnings import warn from django.conf import settings from django.db.models.signals import post_save, post_delete @@ -44,8 +45,24 @@ def __init__(self, cache=None, key_prefix=None): if cache is None: cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') cache = get_cache(cache_alias, KEY_PREFIX=self.key_prefix) + self._warn_cache_backend(cache, cache_alias) self._cache = cache + def _warn_cache_backend(self, cache, cache_alias): + from django.core.cache.backends.dummy import DummyCache + from django.core.cache.backends.db import DatabaseCache + from django.core.cache.backends.filebased import FileBasedCache + from django.core.cache.backends.locmem import LocMemCache + + if isinstance(cache, (LocMemCache, FileBasedCache)): + warn(("'%s' cache is %s, which may cause stale caches." % + (cache_alias, type(cache).__name__)), + RuntimeWarning, stacklevel=3) + elif isinstance(cache, (DatabaseCache, DummyCache)): + warn(("'%s' is %s, causing extra database queries." % + (cache_alias, type(cache).__name__)), + RuntimeWarning, stacklevel=3) + def _get_cache_key(self, key): return 'sites.%s.%s' % (self.key_prefix, key) From 38712b2312d9b12849f8de0bb84d11baf2cde38f Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 Jun 2012 17:49:15 -0400 Subject: [PATCH 046/196] hacks.py: CACHE_SITES_KEY_PREFIX lookup is now deferred to each operation. --- multisite/hacks.py | 10 +++++++--- multisite/tests.py | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index c89ea30..21e0e3d 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -38,9 +38,7 @@ class SiteCache(object): def __init__(self, cache=None, key_prefix=None): from django.core.cache import get_cache - if key_prefix is None: - key_prefix = getattr(settings, 'CACHE_SITES_KEY_PREFIX', '') - self.key_prefix = key_prefix + self._key_prefix = key_prefix if cache is None: cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') @@ -71,6 +69,12 @@ def _clean_site(self, site): site.id = int(site.id) return site + @property + def key_prefix(self): + if self._key_prefix is None: + return getattr(settings, 'CACHE_SITES_KEY_PREFIX', '') + return self._key_prefix + def get(self, key, *args, **kwargs): return self._cache.get(key=self._get_cache_key(key), *args, **kwargs) diff --git a/multisite/tests.py b/multisite/tests.py index ac449df..b4018fe 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -33,7 +33,10 @@ def get(self, path, data={}, host=None, **extra): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') -@override_settings(SITE_ID=SiteID()) +@override_settings( + SITE_ID=SiteID(), + CACHE_SITES_KEY_PREFIX='__test__', +) class TestContribSite(TestCase): def setUp(self): Site.objects.all().delete() @@ -284,8 +287,7 @@ def test_site_domain_changed(self): 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( SITE_ID=SiteID(), - CACHE_SITES_ALIAS='django.core.cache.backends.locmem.LocMemCache', - CACHE_SITES_KEY_PREFIX='', + CACHE_SITES_KEY_PREFIX='__test__', ) class SiteCacheTest(TestCase): def setUp(self): From 007721c70717e16798619e475dcef4356ae49873 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 6 Jun 2012 11:27:37 -0400 Subject: [PATCH 047/196] Version 0.2. Add info@ecometrica.com as a maintainer. --- setup.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index d6fc210..0497c6a 100644 --- a/setup.py +++ b/setup.py @@ -11,12 +11,14 @@ def long_description(): setup(name='django-multisite', - version='0.1', - description='Multisite for Django', + version='0.2', + description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', author_email='leonid@shestera.ru', - url='http://github.com/shestera/django-multisite', + maintainer='Ecometrica', + maintainer_email='info@ecometrica.com', + url='http://github.com/ecometrica/django-multisite', packages=['multisite', 'multisite.migrations'], classifiers=['Development Status :: 4 - Beta', @@ -25,5 +27,10 @@ def long_description(): 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Libraries', 'Topic :: Utilities'], ) From 8802769554835b9394f17185930397d92b11bccf Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 6 Jun 2012 11:27:50 -0400 Subject: [PATCH 048/196] Added tag version-0.2 for changeset eddc73ee5453 --- .hgtags | 1 + 1 file changed, 1 insertion(+) create mode 100644 .hgtags diff --git a/.hgtags b/.hgtags new file mode 100644 index 0000000..56cc69e --- /dev/null +++ b/.hgtags @@ -0,0 +1 @@ +eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 From f9ec719a60b84ed98f8833cb70c308ce3e09ce11 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Thu, 7 Jun 2012 11:20:59 -0400 Subject: [PATCH 049/196] threadlocals.py: Fix incorrect documentation in SiteDomain.__init__ --- multisite/threadlocals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 2dd9368..21b3f34 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -123,8 +123,8 @@ def get_default(self): class SiteDomain(SiteID): def __init__(self, default, *args, **kwargs): """ - ``default``, if specified, is the default domain name, resolved - to SITE_ID, if that is unset. + ``default`` is the default domain name, resolved to SITE_ID, if + that is unset. """ if not isinstance(default, basestring): raise ValueError("%r is not a valid default domain." % default) From 18030656ebc2074f50c989cd5d985fd1bfcc2730 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Fri, 8 Jun 2012 12:12:55 -0400 Subject: [PATCH 050/196] setup.py: Requires Django >= 1.3 --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 0497c6a..98783d3 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,10 @@ def long_description(): url='http://github.com/ecometrica/django-multisite', packages=['multisite', 'multisite.migrations'], + install_requires=['Django>=1.3'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', + 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', From 66949de0a674b09050cac3d290124235beb3752f Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Mon, 29 Oct 2012 15:11:04 -0400 Subject: [PATCH 051/196] Fix get_development_alias for Django 1.3.4 and 1.4.2 by adding adminsite.com --- multisite/middleware.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index e7ba557..88a3417 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -56,7 +56,8 @@ def get_development_alias(self, netloc): """ # When running tests, django.core.mail.outbox exists and # netloc == 'testserver' - is_testserver = (hasattr(mail, 'outbox') and netloc == 'testserver') + is_testserver = (hasattr(mail, 'outbox') and + netloc in ('testserver', 'adminsite.com')) # When using runserver, assume that host will only have one path # component. This covers 'localhost' and your machine name. is_local_debug = (settings.DEBUG and len(netloc.split('.')) == 1) From 366b4aa7785f62e69a74276005f792d95c44a151 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 30 Oct 2012 20:42:29 -0400 Subject: [PATCH 052/196] Bump version to 0.2.1 --- README.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 9d26917..b0f21ca 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ README Get the code via git:: - git clone git://github.com/plazix/django-multisite.git django-multisite + git clone git://github.com/ecometrica/django-multisite.git django-multisite Add the django-multisite/multisite folder to your PYTHONPATH. diff --git a/setup.py b/setup.py index 98783d3..65b297f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.2', + version='0.2.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From e1b19309fea81f9e3c64fb978f101cc3e2a385de Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 30 Oct 2012 20:42:35 -0400 Subject: [PATCH 053/196] Added tag version-0.2.1 for changeset 444842039a40 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 56cc69e..b9b7951 100644 --- a/.hgtags +++ b/.hgtags @@ -1 +1,2 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 +444842039a404fe6ffb5124865df2e5ab26e69a3 version-0.2.1 From d1bfcea1f79dd5d68b45f8f06467d77b6be405f6 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 12 Nov 2012 15:10:13 -0500 Subject: [PATCH 054/196] Replace SiteAdmin.form with one that checks if Alias will fail validation. Because Alias objects are created in the post_save signal for Site, creating or editing Site objects in the admin may cause validation failures that need to be reported. --- multisite/admin.py | 3 +++ multisite/forms.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 multisite/forms.py diff --git a/multisite/admin.py b/multisite/admin.py index 984c300..95ef2ab 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -35,6 +35,9 @@ def queryset(self, request): # HACK: Monkeypatch AliasInline into SiteAdmin SiteAdmin.inlines = type(SiteAdmin.inlines)([AliasInline]) + SiteAdmin.inlines +# HACK: Monkeypatch Alias validation into SiteForm +SiteAdmin.form = SiteForm + class MultisiteChangeList(ChangeList): """ diff --git a/multisite/forms.py b/multisite/forms.py new file mode 100644 index 0000000..0eeeca1 --- /dev/null +++ b/multisite/forms.py @@ -0,0 +1,21 @@ +from django.contrib.sites.admin import SiteAdmin +from django.core.exceptions import ValidationError + +from .models import Alias + + +class SiteForm(SiteAdmin.form): + def clean_domain(self): + domain = self.cleaned_data['domain'] + + try: + alias = Alias.objects.get(domain=domain) + except Alias.DoesNotExist: + # New Site that doesn't clobber an Alias + return domain + + if alias.site_id == self.instance.pk and alias.is_canonical: + return domain + + raise ValidationError('Cannot overwrite non-canonical Alias: "%s"' % + alias.domain) From 06218fb8397837c15cd64e1753e1f6ac562e0bb2 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 12 Nov 2012 15:11:28 -0500 Subject: [PATCH 055/196] Bump version to 0.2.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 65b297f..9e64c73 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.2.1', + version='0.2.2', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From a84ba360aaef2ab7346c36df1a2df96c9bd516ee Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 12 Nov 2012 15:11:36 -0500 Subject: [PATCH 056/196] Added tag version-0.2.2 for changeset 0de1201845d8 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index b9b7951..5c43f02 100644 --- a/.hgtags +++ b/.hgtags @@ -1,2 +1,3 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 444842039a404fe6ffb5124865df2e5ab26e69a3 version-0.2.1 +0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 From 3932abce98009dea3613acdbe46805f6fafa8925 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 12 Nov 2012 15:16:31 -0500 Subject: [PATCH 057/196] admin.py: Fix typo --- multisite/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/multisite/admin.py b/multisite/admin.py index 95ef2ab..73b36fc 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -3,6 +3,7 @@ from django.contrib.sites.models import Site from django.contrib.sites.admin import SiteAdmin +from .forms import SiteForm from .models import Alias From 8f0851c7d83a4309e18fb445dce9bf07ad34f72e Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 12 Nov 2012 15:16:40 -0500 Subject: [PATCH 058/196] Added tag version-0.2.2 for changeset c723f9796de6 --- .hgtags | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.hgtags b/.hgtags index 5c43f02..1c9cfd0 100644 --- a/.hgtags +++ b/.hgtags @@ -1,3 +1,5 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 444842039a404fe6ffb5124865df2e5ab26e69a3 version-0.2.1 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 +0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 +c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 From 4890495fafd8c3abd9a7182a195a92282ad6a898 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 12 Dec 2012 00:54:31 -0500 Subject: [PATCH 059/196] Update to work with Django 1.3.5 --- multisite/tests.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index b4018fe..c080f9f 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -2,7 +2,8 @@ from django.conf import settings from django.contrib.sites.models import Site -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, + ValidationError) from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory @@ -119,12 +120,12 @@ def test_unknown_host(self): def test_invalid_host(self): # Invalid host request = self.factory.get('/', host='') - self.assertRaises(Http404, + self.assertRaises(SuspiciousOperation, self.middleware.process_request, request) self.assertEqual(settings.SITE_ID, 0) # Invalid host:port request = self.factory.get('/', host=':8000') - self.assertRaises(Http404, + self.assertRaises(SuspiciousOperation, self.middleware.process_request, request) self.assertEqual(settings.SITE_ID, 0) From 4e7fcce077c93fd8f25d5f6376b3efb6125ddb51 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 12 Dec 2012 00:54:38 -0500 Subject: [PATCH 060/196] Bump version to 0.2.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9e64c73..a4bb260 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.2.2', + version='0.2.3', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From e46296186bddbe9907483e97116993c9c38ba328 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 12 Dec 2012 00:54:42 -0500 Subject: [PATCH 061/196] Added tag version-0.2.3 for changeset be904a3e798c --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 1c9cfd0..42227cd 100644 --- a/.hgtags +++ b/.hgtags @@ -3,3 +3,4 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 +be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 From 90ac573efba25a747cb472b5d3d3351e70d8c54c Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 26 Feb 2013 14:53:29 -0500 Subject: [PATCH 062/196] Improve performance of DynamicSiteMiddleware. Instead of asking the database if a Site object has changed, cache the original value and check it. As well, pre-populate the SITE_CACHE when resolving aliases. Writing to the cache too often is better than hitting the database. --- multisite/middleware.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 88a3417..1e6dfe5 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -2,12 +2,12 @@ from urlparse import urlsplit, urlunsplit from django.conf import settings -from django.contrib.sites.models import Site +from django.contrib.sites.models import Site, SITE_CACHE from django.core import mail from django.core.cache import get_cache from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import get_callable -from django.db.models.signals import pre_save, post_delete +from django.db.models.signals import pre_save, post_delete, post_init from django.http import Http404, HttpResponsePermanentRedirect from django.utils.hashcompat import md5_constructor @@ -25,6 +25,8 @@ def __init__(self): self.key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', '') self.cache = get_cache(self.cache_alias, KEY_PREFIX=self.key_prefix) + post_init.connect(self.site_domain_cache_hook, sender=Site, + dispatch_uid='multisite_post_init') pre_save.connect(self.site_domain_changed_hook, sender=Site) post_delete.connect(self.site_deleted_hook, sender=Site) @@ -153,18 +155,22 @@ def process_request(self, request): # Found Site self.cache.set(cache_key, alias) settings.SITE_ID.set(alias.site_id) + SITE_CACHE[settings.SITE_ID] = alias.site # Pre-populate SITE_CACHE return self.redirect_to_canonical(request, alias) + @classmethod + def site_domain_cache_hook(self, sender, instance, *args, **kwargs): + """Caches Site.domain in the object for site_domain_changed_hook.""" + instance._domain_cache = instance.domain + def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): """Clears the cache if Site.domain has changed.""" - if raw: + if raw or instance.pk is None: return - try: - original = sender.objects.get(pk=instance.pk) - if original.domain != instance.domain: - self.cache.clear() - except sender.DoesNotExist: - pass + + original = getattr(instance, '_domain_cache', None) + if original != instance.domain: + self.cache.clear() def site_deleted_hook(self, *args, **kwargs): """Clears the cache if Site was deleted.""" From d2a2f3e03239fb67700a065787f9c7e2efe879ea Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 26 Feb 2013 14:57:18 -0500 Subject: [PATCH 063/196] Bump version to 0.2.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a4bb260..83a20be 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.2.3', + version='0.2.4', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From b2659c3111508e5ecdf8c29c9aab90b7ae45a9b6 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 26 Feb 2013 14:57:21 -0500 Subject: [PATCH 064/196] Added tag version-0.2.4 for changeset b1cedca9137c --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 42227cd..8cb5e9c 100644 --- a/.hgtags +++ b/.hgtags @@ -4,3 +4,4 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 +b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 From 6d536bfd33dcce65609360a86341def32678ff88 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 27 Mar 2013 12:41:04 -0400 Subject: [PATCH 065/196] Cross-domain cookie support. Prepend multisite.middleware.CookieDomainMiddleware to settings.MIDDLEWARE_CLASSES to handle cross-domain cookies. http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path --- README.rst | 43 ++++++ multisite/management/__init__.py | 0 multisite/management/commands/__init__.py | 1 + .../commands/update_public_suffix_list.py | 44 ++++++ multisite/middleware.py | 55 ++++++++ multisite/tests.py | 131 +++++++++++++++++- setup.py | 5 +- 7 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 multisite/management/__init__.py create mode 100644 multisite/management/commands/__init__.py create mode 100644 multisite/management/commands/update_public_suffix_list.py diff --git a/README.rst b/README.rst index b0f21ca..2ff586c 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,10 @@ Get the code via git:: Add the django-multisite/multisite folder to your PYTHONPATH. + +Quickstart +---------- + Replace your SITE_ID in settings.py to:: from multisite import SiteID @@ -52,6 +56,10 @@ If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* }, } + +Domain fallbacks +---------------- + By default, if the domain name is unknown, multisite will respond with an HTTP 404 Not Found error. To change this behaviour, add to settings.py:: @@ -71,3 +79,38 @@ Create a directory settings.TEMPLATE_DIRS directory with the names of domains, such as:: mkdir templates/example.com + + +Cross-domain cookies +-------------------- + +*New in version 0.3.0.* + +In order to support `cross-domain cookies`_, +for purposes like single-sign-on, +prepend the following to the top of +settings.py MIDDLEWARE_CLASSES:: + + MIDDLEWARE_CLASSES = ( + 'multisite.middleware.CookieDomainMiddleware', + ... + ) + +CookieDomainMiddleware will consult the `Public Suffix List`_ +for effective top-level domains. +It caches this file +in the system's default temporary directory +as ``effective_tld_names.dat``. +To change this in settings.py:: + + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE = '/path/to/multisite_tld.da't + +By default, +any cookies without a domain set +will be reset to allow \*.domain.tld. +To change this in settings.py:: + + MULTISITE_COOKIE_DOMAIN_DEPTH = 1 # Allow only *.subdomain.domain.tld + +.. _cross-domain cookies: http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path +.. _Public Suffix List: http://publicsuffix.org/ diff --git a/multisite/management/__init__.py b/multisite/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/management/commands/__init__.py b/multisite/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/multisite/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py new file mode 100644 index 0000000..5939ac4 --- /dev/null +++ b/multisite/management/commands/update_public_suffix_list.py @@ -0,0 +1,44 @@ +import logging +import os +import tempfile + +from django.conf import settings +from django.core.management.base import NoArgsCommand + +import tldextract + + +class Command(NoArgsCommand): + def handle_noargs(self, **options): + self.setup_logging(verbosity=options.get('verbosity', 1)) + + filename = getattr( + settings, 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', + os.path.join(tempfile.gettempdir(), 'multisite_tld.dat') + ) + self.log("Updating {filename}".format(filename=filename)) + + with tempfile.NamedTemporaryFile(dir=os.path.dirname(filename)) as f: + tmpname = f.name + + extract = tldextract.TLDExtract(fetch=True, cache_file=tmpname) + extract._get_tld_extractor() + self.log( + "Downloaded new data to {filename}".format(filename=tmpname) + ) + os.rename(tmpname, filename) + f.delete = False # No need to delete f any more. + + self.log("Done.") + + def setup_logging(self, verbosity): + self.verbosity = int(verbosity) + + # Mute tldextract's logger + logger = logging.getLogger('tldextract') + if self.verbosity < 2: + logger.setLevel(logging.CRITICAL) + + def log(self, msg, level=2): + if self.verbosity >= level: + print msg diff --git a/multisite/middleware.py b/multisite/middleware.py index 1e6dfe5..24c8431 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import os +import tempfile from urlparse import urlsplit, urlunsplit from django.conf import settings @@ -175,3 +177,56 @@ def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): def site_deleted_hook(self, *args, **kwargs): """Clears the cache if Site was deleted.""" self.cache.clear() + + +class CookieDomainMiddleware(object): + def __init__(self): + self.depth = int(getattr(settings, 'MULTISITE_COOKIE_DOMAIN_DEPTH', 0)) + if self.depth < 0: + raise ValueError( + 'Invalid MULTISITE_COOKIE_DOMAIN_DEPTH: {depth!r}'.format( + depth=self.depth + ) + ) + self.psl_cache = getattr(settings, + 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', + None) + if self.psl_cache is None: + self.psl_cache = os.path.join(tempfile.gettempdir(), + 'multisite_tld.dat') + self._tldextract = None + + def tldextract(self, url): + import tldextract + if self._tldextract is None: + self._tldextract = tldextract.TLDExtract(fetch=True, + cache_file=self.psl_cache) + return self._tldextract(url) + + def match_cookies(self, request, response): + return [c for c in response.cookies.values() if not c['domain']] + + def process_response(self, request, response): + matched = self.match_cookies(request=request, response=response) + if not matched: + return response # No cookies to edit + + parsed = self.tldextract(request.get_host()) + if not parsed.tld: + return response # IP address or local path + if not parsed.domain: + return response # Only TLD + + subdomains = parsed.subdomain.split('.') if parsed.subdomain else [] + if not self.depth: + subdomains = [''] + elif len(subdomains) < self.depth: + return response # Not enough subdomain parts + else: + subdomains = [''] + subdomains[-self.depth:] + + domain = '.'.join(subdomains + [parsed.domain, parsed.tld]) + + for morsel in matched: + morsel['domain'] = domain + return response diff --git a/multisite/tests.py b/multisite/tests.py index c080f9f..b9a38d5 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,10 +1,12 @@ +import os +import tempfile import warnings from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, ValidationError) -from django.http import Http404 +from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory from django.utils.unittest import skipUnless @@ -15,7 +17,7 @@ from override_settings import override_settings from . import SiteDomain, SiteID, threadlocals -from .middleware import DynamicSiteMiddleware +from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias from .threadlocals import SiteIDHook @@ -679,3 +681,128 @@ def test_resolve(self): alias = Alias.objects.create(site=site, domain='*') self.assertEqual(Alias.objects.resolve('example.net'), alias) + + +@override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=0, + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, +) +class TestCookieDomainMiddleware(TestCase): + def setUp(self): + self.factory = RequestFactory(host='example.com') + self.middleware = CookieDomainMiddleware() + + def test_init(self): + self.assertEqual(self.middleware.depth, 0) + self.assertEqual(self.middleware.psl_cache, + os.path.join(tempfile.gettempdir(), + 'multisite_tld.dat')) + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=1, + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE='/var/psl'): + middleware = CookieDomainMiddleware() + self.assertEqual(middleware.depth, 1) + self.assertEqual(middleware.psl_cache, '/var/psl') + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=-1): + self.assertRaises(ValueError, CookieDomainMiddleware) + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH='invalid'): + self.assertRaises(ValueError, CookieDomainMiddleware) + + def test_no_matched_cookies(self): + # No cookies + request = self.factory.get('/') + response = HttpResponse() + self.assertEqual(self.middleware.match_cookies(request, response), + []) + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies.values(), []) + + # Add some cookies with their domains already set + response.set_cookie(key='a', value='a', domain='.example.org') + response.set_cookie(key='b', value='b', domain='.example.co.uk') + self.assertEqual(self.middleware.match_cookies(request, response), + []) + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies.values(), [cookies['a'], cookies['b']]) + self.assertEqual(cookies['a']['domain'], '.example.org') + self.assertEqual(cookies['b']['domain'], '.example.co.uk') + + def test_matched_cookies(self): + request = self.factory.get('/') + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + self.assertEqual(self.middleware.match_cookies(request, response), + [response.cookies['a']]) + # No new cookies should be introduced + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies.values(), [cookies['a']]) + + def test_ip_address(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + # IP addresses should not be mutated + request = self.factory.get('/', host='192.0.43.10') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + + def test_localpath(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + # Local domains should not be mutated + request = self.factory.get('/', host='localhost') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Even local subdomains + request = self.factory.get('/', host='localhost.localdomain') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + + def test_simple_tld(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + # Top-level domains shouldn't get mutated + request = self.factory.get('/', host='ai') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains inside a TLD are OK + request = self.factory.get('/', host='www.ai') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.www.ai') + + def test_effective_tld(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + # Effective top-level domains with a webserver shouldn't get mutated + request = self.factory.get('/', host='com.ai') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains within an effective TLD are OK + request = self.factory.get('/', host='nic.com.ai') + cookies = self.middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.nic.com.ai') + + def test_subdomain_depth(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=1): + # At depth 1: + middleware = CookieDomainMiddleware() + # Top-level domains are ignored + request = self.factory.get('/', host='com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # As are domains within a TLD + request = self.factory.get('/', host='example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # But subdomains will get matched + request = self.factory.get('/', host='www.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.www.example.com') + # And sub-subdomains will get matched + cookies['a']['domain'] = '' + request = self.factory.get('/', host='www.us.app.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.app.example.com') diff --git a/setup.py b/setup.py index 83a20be..f81b93f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.2.4', + version='0.3.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', @@ -21,7 +21,8 @@ def long_description(): url='http://github.com/ecometrica/django-multisite', packages=['multisite', 'multisite.migrations'], - install_requires=['Django>=1.3'], + install_requires=['Django>=1.3', + 'tldextract>=1.1.3'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', From 1b27afc42829b4879c500f16ca47d3f9cd485d73 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 27 Mar 2013 12:41:20 -0400 Subject: [PATCH 066/196] Added tag version-0.3.0 for changeset ca16e31171a0 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 8cb5e9c..2ab4101 100644 --- a/.hgtags +++ b/.hgtags @@ -5,3 +5,4 @@ eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 +ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 From f70fd38c82882ac9db4cb5846f9185d76b1b2279 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 6 May 2013 12:38:01 -0400 Subject: [PATCH 067/196] Ship update_public_suffix_list command --- README.rst | 7 ++++++- setup.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 2ff586c..4a4060a 100644 --- a/README.rst +++ b/README.rst @@ -103,7 +103,7 @@ in the system's default temporary directory as ``effective_tld_names.dat``. To change this in settings.py:: - MULTISITE_PUBLIC_SUFFIX_LIST_CACHE = '/path/to/multisite_tld.da't + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE = '/path/to/multisite_tld.dat' By default, any cookies without a domain set @@ -112,5 +112,10 @@ To change this in settings.py:: MULTISITE_COOKIE_DOMAIN_DEPTH = 1 # Allow only *.subdomain.domain.tld +In order to fetch a new version of the list, +run:: + + manage.py update_public_suffix_list + .. _cross-domain cookies: http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path .. _Public Suffix List: http://publicsuffix.org/ diff --git a/setup.py b/setup.py index f81b93f..607648f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.3.0', + version='0.3.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', @@ -20,6 +20,8 @@ def long_description(): maintainer_email='info@ecometrica.com', url='http://github.com/ecometrica/django-multisite', packages=['multisite', + 'multisite.management', + 'multisite.management.commands', 'multisite.migrations'], install_requires=['Django>=1.3', 'tldextract>=1.1.3'], From d090a75cc72d120e9d0ed66f0801a86ae03284a8 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 6 May 2013 12:39:04 -0400 Subject: [PATCH 068/196] Added tag version-0.3.1 for changeset 3fa7a1923f4f --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 2ab4101..a437b62 100644 --- a/.hgtags +++ b/.hgtags @@ -6,3 +6,4 @@ c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 +3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 From 00e4062ab0421ba65b744c742fdc4adc6d46ee93 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Wed, 16 Oct 2013 14:21:08 -0400 Subject: [PATCH 069/196] test.py: Skip function-based views tests if using Django 1.5 or above. --- multisite/tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index b9a38d5..774e36b 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,7 +1,10 @@ import os import tempfile +from unittest import skipIf import warnings + +import django from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, @@ -193,6 +196,8 @@ def test_testserver(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, site.pk) + @skipIf(django.VERSION >= (1, 5, 0), "Starting Django 1.5, there are no " + "function based views.") def test_string_function(self): # Function based settings.MULTISITE_FALLBACK = 'django.views.generic.simple.redirect_to' @@ -215,6 +220,8 @@ def test_string_class(self): self.assertEqual(response['Location'], settings.MULTISITE_FALLBACK_KWARGS['url']) + @skipIf(django.VERSION >= (1, 5, 0), "Starting Django 1.5, there are no " + "function based views.") def test_function_view(self): from django.views.generic.simple import redirect_to settings.MULTISITE_FALLBACK = redirect_to From ccddf70263f9a7b35947ee4e4be082c535cde13f Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Thu, 17 Oct 2013 14:27:34 -0400 Subject: [PATCH 070/196] hosts.py: Added ALLOWED_HOSTS lazy iterable object. By default, Django 1.5 requires ALLOWED_HOSTS to be defined when DEBUG is False. This lazy iterable object allows us to query our aliases for hosts. We can also specify wild cards by setting `MULTISITE_EXTRA_HOSTS` if we so desire. The hosts specified in this variable will be verified first so we don't have to hit the database if we don't have to. To use, put the following in your Django settings file: from multisite.hosts import ALLOWED_HOSTS as AH ALLOWED_HOSTS = AH If you want to specify extra allowed hosts, add this line to your settings: MULTISITE_EXTRA_HOSTS = ['*.mydomain.com'] --- multisite/hosts.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 multisite/hosts.py diff --git a/multisite/hosts.py b/multisite/hosts.py new file mode 100644 index 0000000..80bdd45 --- /dev/null +++ b/multisite/hosts.py @@ -0,0 +1,50 @@ +from django.utils.functional import SimpleLazyObject + +from django import VERSION as django_version + + +__ALL__ = ('ALLOWED_HOSTS', 'AllowedHosts') + + +# In Django 1.3, LazyObject compares _wrapped against None, while in Django +# 1.4 and above, LazyObjects compares _wrapped against an instance of +# `object` stored in `empty`. +_wrapped_default = None +if django_version >= (1, 4, 0): + from django.utils.functional import empty + _wrapped_default = empty + + +class IterableLazyObject(SimpleLazyObject): + + _wrapped_default = globals()['_wrapped_default'] + + def __iter__(self): + if self._wrapped is self._wrapped_default: + self._setup() + return self._wrapped.__iter__() + + +class AllowedHosts(object): + + alias_model = None + + def __init__(self): + from django.conf import settings + self.extra_hosts = getattr(settings, 'MULTISITE_EXTRA_HOSTS', []) + + if self.alias_model is None: + from .models import Alias + self.alias_model = Alias + + def __iter__(self): + # Yielding extra hosts before actual hosts because there might be + # wild cards in there that would prevent us from doing a database + # query every time. + for host in self.extra_hosts: + yield host + + for host in self.alias_model.objects.values_list('domain'): + yield host[0] + +ALLOWED_HOSTS = IterableLazyObject(lambda: AllowedHosts()) From d5260761e57855e066eeec83802f6576444e8c7c Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Mon, 3 Mar 2014 15:55:55 -0500 Subject: [PATCH 071/196] Added cached template loader. Re-did the template loaders modules to mimick Django's. This cached template loader caches on a per-domain basis. Kept a reference to the multisite template loader in template_loader.py for compatibilty. --- multisite/template/__init__.py | 0 multisite/template/loaders/__init__.py | 0 multisite/template/loaders/cached.py | 47 ++++++++++++++++++++++++ multisite/template/loaders/filesystem.py | 33 +++++++++++++++++ multisite/template_loader.py | 39 ++++---------------- 5 files changed, 88 insertions(+), 31 deletions(-) create mode 100644 multisite/template/__init__.py create mode 100644 multisite/template/loaders/__init__.py create mode 100644 multisite/template/loaders/cached.py create mode 100644 multisite/template/loaders/filesystem.py diff --git a/multisite/template/__init__.py b/multisite/template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/template/loaders/__init__.py b/multisite/template/loaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/template/loaders/cached.py b/multisite/template/loaders/cached.py new file mode 100644 index 0000000..359aa6a --- /dev/null +++ b/multisite/template/loaders/cached.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +from collections import defaultdict +import hashlib + +from django.contrib.sites.models import Site +from django.template.base import TemplateDoesNotExist +from django.template.loader import get_template_from_string +from django.template.loaders.cached import Loader as CachedLoader + + +class Loader(CachedLoader): + """ + This is an adaptation of Django's cached template loader. It differs in + that the cache is domain-based, so you can actually run more than one + site with one process. + + The load_template() method has been adapted from Django 1.6. + """ + + def __init__(self, *args, **kwargs): + super(Loader, self).__init__(*args, **kwargs) + self.template_cache = defaultdict(dict) + + def load_template(self, template_name, template_dirs=None): + domain = Site.objects.get_current().domain + key = template_name + if template_dirs: + # If template directories were specified, use a hash to differentiate + key = '-'.join([template_name, hashlib.sha1('|'.join(template_dirs)).hexdigest()]) + + try: + template = self.template_cache[domain][key] + except KeyError: + template, origin = self.find_template(template_name, template_dirs) + if not hasattr(template, 'render'): + try: + template = get_template_from_string(template, origin, template_name) + except TemplateDoesNotExist: + # If compiling the template we found raises TemplateDoesNotExist, + # back off to returning the source and display name for the template + # we were asked to load. This allows for correct identification (later) + # of the actual template that does not exist. + return template, origin + self.template_cache[domain][key] = template + return template, None + diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py new file mode 100644 index 0000000..4f57a7a --- /dev/null +++ b/multisite/template/loaders/filesystem.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings +from django.contrib.sites.models import Site +from django.template.loaders.filesystem import Loader as FilesystemLoader +from django.utils._os import safe_join + + +class Loader(FilesystemLoader): + def get_template_sources(self, template_name, template_dirs=None): + if not template_dirs: + template_dirs = settings.TEMPLATE_DIRS + + domain = Site.objects.get_current().domain + default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 'default') + + new_template_dirs = [] + for template_dir in template_dirs: + new_template_dirs.append(safe_join(template_dir, domain)) + if default_dir: + new_template_dirs.append(safe_join(template_dir, default_dir)) + + for template_dir in new_template_dirs: + try: + yield safe_join(template_dir, template_name) + except UnicodeDecodeError: + # The template dir name was a bytestring that wasn't valid UTF-8. + raise + except ValueError: + # The joined path was located outside of this particular + # template_dir (it might be inside another one, so this isn't + # fatal). + pass diff --git a/multisite/template_loader.py b/multisite/template_loader.py index 4f57a7a..1270a68 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -1,33 +1,10 @@ # -*- coding: utf-8 -*- -from django.conf import settings -from django.contrib.sites.models import Site -from django.template.loaders.filesystem import Loader as FilesystemLoader -from django.utils._os import safe_join - - -class Loader(FilesystemLoader): - def get_template_sources(self, template_name, template_dirs=None): - if not template_dirs: - template_dirs = settings.TEMPLATE_DIRS - - domain = Site.objects.get_current().domain - default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 'default') - - new_template_dirs = [] - for template_dir in template_dirs: - new_template_dirs.append(safe_join(template_dir, domain)) - if default_dir: - new_template_dirs.append(safe_join(template_dir, default_dir)) - - for template_dir in new_template_dirs: - try: - yield safe_join(template_dir, template_name) - except UnicodeDecodeError: - # The template dir name was a bytestring that wasn't valid UTF-8. - raise - except ValueError: - # The joined path was located outside of this particular - # template_dir (it might be inside another one, so this isn't - # fatal). - pass +from .template.loaders.filesystem import Loader + +# The template.loaders.filesystem.Loader class used to live here. Now that +# we have more than one Loader class in the project, they are defined in the +# same fashion as Django's. +# For backward-compatibility reasons, Loader in this file points to what +# used to be defined here. +__all__ = ['Loader'] From 10ab5c91641bf6ee5a0c8b131e660f47cdd5d672 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Fri, 14 Mar 2014 15:51:29 -0400 Subject: [PATCH 072/196] middleware.py: Import md5_constructor from Python starting Django 1.6 --- multisite/middleware.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 24c8431..dd563f7 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -11,7 +11,13 @@ from django.core.urlresolvers import get_callable from django.db.models.signals import pre_save, post_delete, post_init from django.http import Http404, HttpResponsePermanentRedirect -from django.utils.hashcompat import md5_constructor + +try: + # Deprecated in Django 1.5 + from django.utils.hashcompat import md5_constructor +except ImportError: + # The above has been removed in Django 1.6 + from hashlib import md5 as md5_constructor from .models import Alias From 079020be759c3898cdd748d965b0cf12826f66d1 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Fri, 14 Mar 2014 15:53:42 -0400 Subject: [PATCH 073/196] tests.py: Fix a test that was failing because Site object creation has changed in Django 1.6 --- multisite/tests.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 774e36b..5ba3816 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -302,6 +302,15 @@ def test_site_domain_changed(self): class SiteCacheTest(TestCase): def setUp(self): from django.contrib.sites import models + + if hasattr(models, 'clear_site_cache'): + # Before Django 1.6, the Site cache is cleared after the Site + # object has been created. This replicates that behaviour. + def save(self, *args, **kwargs): + super(models.Site, self).save(*args, **kwargs) + models.SITE_CACHE.clear() + models.Site.save = save + Site.objects.all().delete() self.host = 'example.com' self.site = Site.objects.create(domain=self.host) From 35c1a118f5911f5c70f06fabe1f7723633ca4b60 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Mon, 26 May 2014 16:12:06 -0400 Subject: [PATCH 074/196] setup.py: Added missing packages. --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 607648f..72fc739 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,9 @@ def long_description(): packages=['multisite', 'multisite.management', 'multisite.management.commands', - 'multisite.migrations'], + 'multisite.migrations', + 'multisite.template', + 'multisite.template.loaders'], install_requires=['Django>=1.3', 'tldextract>=1.1.3'], classifiers=['Development Status :: 4 - Beta', From 62a4a23697dec11dbb7cb331b74e2b3e6588b9eb Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Fri, 1 Aug 2014 12:13:18 +0100 Subject: [PATCH 075/196] setup.py: Bump version to 0.4.0 In this release: - Django 1.6 support - ALLOWED_HOSTS iterable for Django 1.5+ populated with the sites defined - Per-domain cached template loader --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 72fc739..8b49a0c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.3.1', + version='0.4.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From f65ce43420556ce795d5aad2367423bdf48eb675 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Fri, 1 Aug 2014 12:13:33 +0100 Subject: [PATCH 076/196] Added tag version-0.4.0 for changeset 2da6336d70b0 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index a437b62..9bb6ad7 100644 --- a/.hgtags +++ b/.hgtags @@ -7,3 +7,4 @@ be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 +2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 From 97ecd3d44a7c6d6a603ae88226a6c27c3df63386 Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Mon, 6 Oct 2014 14:49:07 +0100 Subject: [PATCH 077/196] hacks.py: Added get() method to DictCache The get() method has the same semantics as BaseCache.get(). --- multisite/hacks.py | 5 +++++ multisite/tests.py | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 21e0e3d..f259de2 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -134,3 +134,8 @@ def __contains__(self, item): def clear(self): """D.clear() -> None. Remove all items from D.""" self._cache.clear() + + def get(self, key, default=None, version=None): + """D.key(k[, d]) -> k if D has a key k, else d. Defaults to None""" + hash(key) # Raise TypeError if unhashable + return self._cache.get(key=key, default=default, version=version) diff --git a/multisite/tests.py b/multisite/tests.py index 5ba3816..e10051c 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,6 +1,5 @@ import os import tempfile -from unittest import skipIf import warnings @@ -12,7 +11,7 @@ from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory -from django.utils.unittest import skipUnless +from django.utils.unittest import skipUnless, skipIf try: from django.test.utils import override_settings @@ -322,9 +321,21 @@ def test_get_current(self): # Populate cache self.assertEqual(Site.objects.get_current(), self.site) self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual(self.cache.get(key=self.site.id), self.site) + self.assertEqual(self.cache.get(key=-1), + None) # Site doesn't exist + self.assertEqual(self.cache.get(-1, 'Default'), + 'Default') # Site doesn't exist + self.assertEqual(self.cache.get(key=-1, default='Non-existant'), + 'Non-existant') # Site doesn't exist + self.assertEqual('Non-existant', + self.cache.get(self.site.id, default='Non-existant', + version=100)) # Wrong key version 3 # Clear cache self.cache.clear() self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + self.assertEqual(self.cache.get(key=self.site.id, default='Cleared'), + 'Cleared') def test_create_site(self): self.assertEqual(Site.objects.get_current(), self.site) From d1e5806ce2ade4f3ca595c5fab0323fb9259305c Mon Sep 17 00:00:00 2001 From: Maxime Dupuis Date: Mon, 6 Oct 2014 15:35:40 +0100 Subject: [PATCH 078/196] setup.py: Bumped version to 0.4.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8b49a0c..663e99a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.4.0', + version='0.4.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 5a47c2f0a5f786bc65b0b1543cfa87746f0e911f Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 2 Sep 2015 11:46:29 +0100 Subject: [PATCH 079/196] Use default key prefix if multisite key prefix is not set --- multisite/hacks.py | 15 ++++++++++----- multisite/middleware.py | 8 ++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index f259de2..d4e3552 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -35,15 +35,22 @@ def SiteManager_clear_cache(self): class SiteCache(object): """Wrapper for SITE_CACHE that assigns a key_prefix.""" - def __init__(self, cache=None, key_prefix=None): + def __init__(self, cache=None): from django.core.cache import get_cache - self._key_prefix = key_prefix - if cache is None: cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') + self._key_prefix = getattr( + settings, + 'CACHE_MULTISITE_KEY_PREFIX', + settings.CACHES[cache_alias].get('KEY_PREFIX', '') + ) cache = get_cache(cache_alias, KEY_PREFIX=self.key_prefix) self._warn_cache_backend(cache, cache_alias) + else: + self._key_prefix = getattr( + settings, 'CACHE_MULTISITE_KEY_PREFIX', cache.key_prefix + ) self._cache = cache def _warn_cache_backend(self, cache, cache_alias): @@ -71,8 +78,6 @@ def _clean_site(self, site): @property def key_prefix(self): - if self._key_prefix is None: - return getattr(settings, 'CACHE_SITES_KEY_PREFIX', '') return self._key_prefix def get(self, key, *args, **kwargs): diff --git a/multisite/middleware.py b/multisite/middleware.py index dd563f7..df5205a 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -30,8 +30,12 @@ def __init__(self): self.cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') - self.key_prefix = getattr(settings, 'CACHE_MULTISITE_KEY_PREFIX', - '') + self.key_prefix = getattr( + settings, + 'CACHE_MULTISITE_KEY_PREFIX', + settings.CACHES[self.cache_alias].get('KEY_PREFIX', '') + ) + self.cache = get_cache(self.cache_alias, KEY_PREFIX=self.key_prefix) post_init.connect(self.site_domain_cache_hook, sender=Site, dispatch_uid='multisite_post_init') From 575e659b0bea05090e529866ef32814d011aa03d Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 3 Sep 2015 16:55:56 +0100 Subject: [PATCH 080/196] Update setup and changelog for version 0.5.0 --- CHANGELOG.rst | 8 ++++++++ multisite/__init__.py | 2 ++ setup.py | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..1fdffb8 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,8 @@ +============= +Release Notes +============= + +0.5.0 +----- + +* Allow use of cache key prefixes from the CACHES settings if CACHE_MULTISITE_KEY_PREFIX not set diff --git a/multisite/__init__.py b/multisite/__init__.py index 5767564..d111659 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1 +1,3 @@ from .threadlocals import SiteDomain, SiteID + +VERSION = "0.5.0" diff --git a/setup.py b/setup.py index 663e99a..de68995 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.4.1', + version='0.5.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 98f51a79e15fb668ed7e4e2182cba0141c8a3cdb Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Mon, 7 Sep 2015 13:30:39 +0100 Subject: [PATCH 081/196] Correct test settings for CACHE_MULTISITE_ALIAS --- multisite/test_settings.py | 19 +++++++++++++++++++ multisite/tests.py | 18 +++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 multisite/test_settings.py diff --git a/multisite/test_settings.py b/multisite/test_settings.py new file mode 100644 index 0000000..765a64b --- /dev/null +++ b/multisite/test_settings.py @@ -0,0 +1,19 @@ +import django + + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test', + } +} + +INSTALLED_APPS = [ + 'django.contrib.sites', + 'multisite', +] + +if django.VERSION[:2] < (1, 6): + TEST_RUNNER = 'discover_runner.DiscoverRunner' + +SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" \ No newline at end of file diff --git a/multisite/tests.py b/multisite/tests.py index e10051c..c419d57 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -58,7 +58,10 @@ def test_get_current_site(self): 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( SITE_ID=SiteID(default=0), - CACHE_MULTISITE_ALIAS='django.core.cache.backends.dummy.DummyCache', + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} + }, MULTISITE_FALLBACK=None, ) class DynamicSiteMiddlewareTest(TestCase): @@ -167,8 +170,10 @@ def test_no_redirect(self): 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( SITE_ID=SiteID(default=0), - CACHE_MULTISITE_ALIAS='django.core.cache.backends.dummy.DummyCache', - MULTISITE_FALLBACK=None, + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} + }, MULTISITE_FALLBACK=None, MULTISITE_FALLBACK_KWARGS={}, ) class DynamicSiteMiddlewareFallbackTest(TestCase): @@ -259,7 +264,10 @@ def test_invalid_settings(self): @override_settings( SITE_ID=SiteID(default=0), - CACHE_MULTISITE_ALIAS='django.core.cache.backends.locmem.LocMemCache', + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} + }, MULTISITE_FALLBACK=None, ) class CacheTest(TestCase): @@ -296,7 +304,7 @@ def test_site_domain_changed(self): 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( SITE_ID=SiteID(), - CACHE_SITES_KEY_PREFIX='__test__', + CACHE_MULTISITE_KEY_PREFIX='__test__', ) class SiteCacheTest(TestCase): def setUp(self): From 7441d70c5d33954196ffdc2c97788e894e18a036 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Mon, 7 Sep 2015 14:48:04 +0100 Subject: [PATCH 082/196] Tests for cache key prefixes --- multisite/tests.py | 69 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index c419d57..4f3fd22 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -13,6 +13,8 @@ from django.test.client import RequestFactory as DjangoRequestFactory from django.utils.unittest import skipUnless, skipIf +from hacks import use_framework_for_site_cache + try: from django.test.utils import override_settings except ImportError: @@ -302,11 +304,15 @@ def test_site_domain_changed(self): @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') -@override_settings( - SITE_ID=SiteID(), - CACHE_MULTISITE_KEY_PREFIX='__test__', -) +@override_settings(SITE_ID=SiteID(),) class SiteCacheTest(TestCase): + + def _initialize_cache(self): + # initialize cache again so override key prefix settings are used + from django.contrib.sites import models + use_framework_for_site_cache() + self.cache = models.SITE_CACHE + def setUp(self): from django.contrib.sites import models @@ -318,10 +324,10 @@ def save(self, *args, **kwargs): models.SITE_CACHE.clear() models.Site.save = save + self._initialize_cache() Site.objects.all().delete() self.host = 'example.com' self.site = Site.objects.create(domain=self.host) - self.cache = models.SITE_CACHE settings.SITE_ID.set(self.site.id) def test_get_current(self): @@ -370,6 +376,59 @@ def test_delete_site(self): self.site.delete() self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + @override_settings(CACHE_MULTISITE_KEY_PREFIX="__test__") + def test_multisite_key_prefix(self): + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + 'sites.{}.{}'.format( + settings.CACHE_MULTISITE_KEY_PREFIX, self.site.id + ), + self.cache._cache._get_cache_key(self.site.id) + ) + + @override_settings( + CACHES={'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'KEY_PREFIX': 'test1' + }} + ) + def test_default_key_prefix(self): + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + 'sites.{}.{}'.format( + settings.CACHES['default']['KEY_PREFIX'], self.site.id + ), + self.cache._cache._get_cache_key(self.site.id) + ) + + @override_settings( + CACHE_MULTISITE_KEY_PREFIX="__test__", + CACHES={'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'KEY_PREFIX': 'test1' + }} + ) + def test_multisite_key_prefix_takes_priority_over_default(self): + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + 'sites.{}.{}'.format( + settings.CACHE_MULTISITE_KEY_PREFIX, self.site.id + ), + self.cache._cache._get_cache_key(self.site.id) + ) + class TestSiteID(TestCase): def setUp(self): From 64859956a368ad4064e28fa9371a8bcdc0db3bf0 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Wed, 9 Sep 2015 14:47:39 -0300 Subject: [PATCH 083/196] Update setup and changelog for version 0.5.1 --- CHANGELOG.rst | 5 +++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1fdffb8..2b0a152 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,11 @@ Release Notes ============= +0.5.1 +----- + +* Add key prefix tests + 0.5.0 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index d111659..0a23d50 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "0.5.0" +VERSION = "0.5.1" diff --git a/setup.py b/setup.py index de68995..042f4ad 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='0.5.0', + version='0.5.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 93e02b1e0ea15b533c44161cfb8a3f618c75aef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 2 Oct 2015 10:37:49 -0400 Subject: [PATCH 084/196] rename get_query_set to get_query_set Django 1.6 had already deprecated it, and it got completely removed for Django 1.8: https://docs.djangoproject.com/en/dev/releases/1.6/#get-query-set-and-similar-methods-renamed-to-get-queryset https://docs.djangoproject.com/en/dev/releases/1.8/#features-removed-in-1-8 --- multisite/admin.py | 2 +- multisite/managers.py | 4 ++-- multisite/models.py | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index 73b36fc..2f72874 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -27,7 +27,7 @@ class AliasInline(admin.TabularInline): def queryset(self, request): """Returns only non-canonical aliases.""" - qs = self.model.aliases.get_query_set() + qs = self.model.aliases.get_queryset() ordering = self.ordering or () if ordering: qs = qs.order_by(*ordering) diff --git a/multisite/managers.py b/multisite/managers.py index 0d0dd88..d8be80b 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -95,8 +95,8 @@ def __init__(self, field_path): super(PathAssistedCurrentSiteManager, self).__init__() self.__field_path = field_path - def get_query_set(self): + def get_queryset(self): from django.contrib.sites.models import Site - return super(models.CurrentSiteManager, self).get_query_set().filter( + return super(models.CurrentSiteManager, self).get_queryset().filter( **{self.__field_path: Site.objects.get_current()} ) diff --git a/multisite/models.py b/multisite/models.py index 7ea14ea..73664c6 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -18,8 +18,8 @@ class AliasManager(models.Manager): """Manager for all Aliases.""" - def get_query_set(self): - return super(AliasManager, self).get_query_set().select_related('site') + def get_queryset(self): + return super(AliasManager, self).get_queryset().select_related('site') def resolve(self, host, port=None): """ @@ -36,7 +36,7 @@ def resolve(self, host, port=None): """ domains = self._expand_netloc(host=host, port=port) q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) - aliases = dict((a.domain, a) for a in self.get_query_set().filter(q)) + aliases = dict((a.domain, a) for a in self.get_queryset().filter(q)) for domain in domains: try: return aliases[domain] @@ -88,8 +88,8 @@ def _expand_netloc(cls, host, port=None): class CanonicalAliasManager(models.Manager): """Manager for Alias objects where is_canonical is True.""" - def get_query_set(self): - qset = super(CanonicalAliasManager, self).get_query_set() + def get_queryset(self): + qset = super(CanonicalAliasManager, self).get_queryset() return qset.filter(is_canonical=True) def sync_many(self, *args, **kwargs): @@ -101,7 +101,7 @@ def sync_many(self, *args, **kwargs): Alias.canonical.sync_many(site__domain='example.com') """ - aliases = self.get_query_set().filter(*args, **kwargs) + aliases = self.get_queryset().filter(*args, **kwargs) for alias in aliases.select_related('site'): domain = alias.site.domain if domain and alias.domain != domain: @@ -110,7 +110,7 @@ def sync_many(self, *args, **kwargs): def sync_missing(self): """Create missing canonical Alias objects based on Site.domain.""" - aliases = self.get_query_set() + aliases = self.get_queryset() sites = self.model._meta.get_field('site').rel.to for site in sites.objects.exclude(aliases__in=aliases): Alias.sync(site=site) @@ -124,8 +124,8 @@ def sync_all(self): class NotCanonicalAliasManager(models.Manager): """Manager for Aliases where is_canonical is None.""" - def get_query_set(self): - qset = super(NotCanonicalAliasManager, self).get_query_set() + def get_queryset(self): + qset = super(NotCanonicalAliasManager, self).get_queryset() return qset.filter(is_canonical__isnull=True) From 2a362193904f25367e82ed5e8848da6819376f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 2 Oct 2015 11:26:20 -0400 Subject: [PATCH 085/196] Update setup and changelog for version 1.0.0 --- CHANGELOG.rst | 6 ++++++ multisite/__init__.py | 2 +- setup.py | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b0a152..08570d5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Release Notes ============= +1.0.0 +----- + +* 1.0 release. API stability promised from now on. +* Following the deprecation in Django itself, all get_query_set methods have been renamed to get_queryset. This means Django 1.6 is now the minimum required version. + 0.5.1 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index 0a23d50..5341955 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "0.5.1" +VERSION = "1.0.0" diff --git a/setup.py b/setup.py index 042f4ad..37594a9 100644 --- a/setup.py +++ b/setup.py @@ -11,13 +11,13 @@ def long_description(): setup(name='django-multisite', - version='0.5.1', + version='1.0.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', author_email='leonid@shestera.ru', maintainer='Ecometrica', - maintainer_email='info@ecometrica.com', + maintainer_email='dev@ecometrica.com', url='http://github.com/ecometrica/django-multisite', packages=['multisite', 'multisite.management', @@ -25,7 +25,7 @@ def long_description(): 'multisite.migrations', 'multisite.template', 'multisite.template.loaders'], - install_requires=['Django>=1.3', + install_requires=['Django>=1.6', 'tldextract>=1.1.3'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', From bf818053fb93fb1e74867196d8cd9e8cfe71ac6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 9 Oct 2015 09:32:23 -0400 Subject: [PATCH 086/196] add Django 1.7+ post-South migrations Keeping the old South migrations under south_migrations is the recommended method: https://docs.djangoproject.com/en/1.8/topics/migrations/#libraries-third-party-apps --- multisite/migrations/0001_initial.py | 81 ++++++++----------- multisite/south_migrations/0001_initial.py | 49 +++++++++++ ...__add_field_alias_redirect_to_canonical.py | 0 multisite/south_migrations/__init__.py | 0 4 files changed, 81 insertions(+), 49 deletions(-) create mode 100644 multisite/south_migrations/0001_initial.py rename multisite/{migrations => south_migrations}/0002_auto__add_field_alias_redirect_to_canonical.py (100%) create mode 100644 multisite/south_migrations/__init__.py diff --git a/multisite/migrations/0001_initial.py b/multisite/migrations/0001_initial.py index 4a6c6d1..900e3f4 100644 --- a/multisite/migrations/0001_initial.py +++ b/multisite/migrations/0001_initial.py @@ -1,49 +1,32 @@ -# encoding: utf-8 -from south.db import db -from south.v2 import SchemaMigration - - -class Migration(SchemaMigration): - def forwards(self, orm): - """Create Alias table.""" - - # Adding model 'Alias' - db.create_table('multisite_alias', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('site', self.gf('django.db.models.fields.related.ForeignKey')(related_name='aliases', to=orm['sites.Site'])), - ('domain', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), - ('is_canonical', self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True)), - )) - db.send_create_signal('multisite', ['Alias']) - - # Adding unique constraint on 'Alias', - # fields ['is_canonical', 'site'] - db.create_unique('multisite_alias', ['is_canonical', 'site_id']) - - def backwards(self, orm): - """Drop Alias table.""" - - # Removing unique constraint on 'Alias', - # fields ['is_canonical', 'site'] - db.delete_unique('multisite_alias', ['is_canonical', 'site_id']) - - # Deleting model 'Alias' - db.delete_table('multisite_alias') - - models = { - 'multisite.alias': { - 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, - 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), - 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) - }, - 'sites.site': { - 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, - 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['multisite'] +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import multisite.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Alias', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('domain', models.CharField(help_text='Either "domain" or "domain:port"', unique=True, max_length=100, verbose_name='domain name')), + ('is_canonical', models.NullBooleanField(default=None, validators=[multisite.models.validate_true_or_none], editable=False, help_text='Does this domain name match the one in site?', verbose_name='is canonical?')), + ('redirect_to_canonical', models.BooleanField(default=True, help_text='Should this domain name redirect to the one in site?', verbose_name='redirect to canonical?')), + ('site', models.ForeignKey(related_name='aliases', to='sites.Site')), + ], + options={ + 'verbose_name_plural': 'aliases', + }, + ), + migrations.AlterUniqueTogether( + name='alias', + unique_together=set([('is_canonical', 'site')]), + ), + ] diff --git a/multisite/south_migrations/0001_initial.py b/multisite/south_migrations/0001_initial.py new file mode 100644 index 0000000..4a6c6d1 --- /dev/null +++ b/multisite/south_migrations/0001_initial.py @@ -0,0 +1,49 @@ +# encoding: utf-8 +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + def forwards(self, orm): + """Create Alias table.""" + + # Adding model 'Alias' + db.create_table('multisite_alias', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('site', self.gf('django.db.models.fields.related.ForeignKey')(related_name='aliases', to=orm['sites.Site'])), + ('domain', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), + ('is_canonical', self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True)), + )) + db.send_create_signal('multisite', ['Alias']) + + # Adding unique constraint on 'Alias', + # fields ['is_canonical', 'site'] + db.create_unique('multisite_alias', ['is_canonical', 'site_id']) + + def backwards(self, orm): + """Drop Alias table.""" + + # Removing unique constraint on 'Alias', + # fields ['is_canonical', 'site'] + db.delete_unique('multisite_alias', ['is_canonical', 'site_id']) + + # Deleting model 'Alias' + db.delete_table('multisite_alias') + + models = { + 'multisite.alias': { + 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, + 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) + }, + 'sites.site': { + 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, + 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + } + } + + complete_apps = ['multisite'] diff --git a/multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py b/multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py similarity index 100% rename from multisite/migrations/0002_auto__add_field_alias_redirect_to_canonical.py rename to multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py diff --git a/multisite/south_migrations/__init__.py b/multisite/south_migrations/__init__.py new file mode 100644 index 0000000..e69de29 From c0a89c67ea19aeca8f14041a0e5770dd3e568be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 9 Oct 2015 09:42:18 -0400 Subject: [PATCH 087/196] release 1.1.0 --- CHANGELOG.rst | 5 +++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 08570d5..40d5bc9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,11 @@ Release Notes ============= +1.1.0 +----- + +* We now support post-South Django 1.7 native migrations. + 1.0.0 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index 5341955..693df56 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.0.0" +VERSION = "1.1.0" diff --git a/setup.py b/setup.py index 37594a9..7022b8c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.0.0', + version='1.1.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 0516569e376e9836fb314d06a2819423b6af5dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 9 Oct 2015 09:43:27 -0400 Subject: [PATCH 088/196] Added tag version-1.1.0 for changeset 1f497c216209 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 9bb6ad7..1459eac 100644 --- a/.hgtags +++ b/.hgtags @@ -8,3 +8,4 @@ b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 +1f497c216209683af3bc20bb15bb29ef305f7ca8 version-1.1.0 From eaaa24fc785c7cd2fa7629edcaf0eed38a4210e1 Mon Sep 17 00:00:00 2001 From: Elliot Marsden Date: Tue, 27 Oct 2015 16:09:37 +0000 Subject: [PATCH 089/196] Rename variable to clarify context manager's logic --- multisite/threadlocals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 21b3f34..1cea758 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -99,10 +99,10 @@ def override(self, value): ... print settings.SITE_ID 2 """ - site_id = self.site_id + site_id_original = self.site_id self.set(value) yield self - self.site_id = site_id + self.site_id = site_id_original def set(self, value): from django.db.models import Model From 46a06bcb3225013547c464bcc9b2775f63e88ade Mon Sep 17 00:00:00 2001 From: Elliot Marsden Date: Tue, 27 Oct 2015 16:10:27 +0000 Subject: [PATCH 090/196] Cleanup site override even if exception occurs --- multisite/threadlocals.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 1cea758..a9ac320 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -101,8 +101,10 @@ def override(self, value): """ site_id_original = self.site_id self.set(value) - yield self - self.site_id = site_id_original + try: + yield self + finally: + self.site_id = site_id_original def set(self, value): from django.db.models import Model From 6188e3e073c1bde5cdedd117284fbd2da3e6d073 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 6 Nov 2015 12:10:16 -0400 Subject: [PATCH 091/196] Bump up version to 1.1.1 --- multisite/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/multisite/__init__.py b/multisite/__init__.py index 693df56..1da12fb 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.1.0" +VERSION = "1.1.1" diff --git a/setup.py b/setup.py index 7022b8c..dedd27d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.1.0', + version='1.1.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From d9c550fe7dd7f81db9b714b49f710212d41a9f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 16 Nov 2015 08:56:18 -0500 Subject: [PATCH 092/196] templates: don't error out if template isn't in a particular path Django 1.8 changed what safe_join can raise: https://github.com/django/django/commit/b8ba73cd0cb6#diff-3e12401856ca6b30d8d877de2e31cf3aL63 While their intention is for us to catch only SuspiciousFileOperation and not ValueError, it's relatively safe to catch both and keep compatibility with both Django 1.6 and 1.8 --- multisite/template/loaders/filesystem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index 4f57a7a..5c4de1c 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from django.core.exceptions import SuspiciousFileOperation from django.conf import settings from django.contrib.sites.models import Site from django.template.loaders.filesystem import Loader as FilesystemLoader @@ -26,7 +27,7 @@ def get_template_sources(self, template_name, template_dirs=None): except UnicodeDecodeError: # The template dir name was a bytestring that wasn't valid UTF-8. raise - except ValueError: + except (ValueError, SuspiciousFileOperation): # The joined path was located outside of this particular # template_dir (it might be inside another one, so this isn't # fatal). From b57a470ffa796bbf75cf83fd870445763f2ec1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 16 Nov 2015 09:00:10 -0500 Subject: [PATCH 093/196] DynamicSiteMiddleware: error out more gracefully in Django 1.8 In Django 1.8, get_callable raises by default instead of returning the same string in case of failure. In order to not raise and do our own error handling, we need to pass can_fail=True to recover the old behaviour of returning the string in case of failure: https://github.com/django/django/commit/91afc00513bd2fa6302ea2be35e1f842cbd5fd38#diff-f83d2617ed57b0c7608c5f5581fa6e7dL81 --- multisite/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index df5205a..bf918fe 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -122,7 +122,7 @@ def fallback_view(self, request): if callable(fallback): view = fallback else: - view = get_callable(fallback) + view = get_callable(fallback, can_fail=True) if not callable(view): raise ImproperlyConfigured( 'settings.MULTISITE_FALLBACK is not callable: %s' % From 110c7e51cf17c7038fa2108bbfca1a8f26428bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Mon, 16 Nov 2015 09:25:58 -0500 Subject: [PATCH 094/196] Bump up version to 1.1.2 --- multisite/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/multisite/__init__.py b/multisite/__init__.py index 1da12fb..7bb0401 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.1.1" +VERSION = "1.1.2" diff --git a/setup.py b/setup.py index dedd27d..c61b9a2 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.1.1', + version='1.1.2', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 7f2f55b3cb1b17537d28ca8fd5a0bd255619d924 Mon Sep 17 00:00:00 2001 From: DemonVex Date: Sun, 29 Nov 2015 16:37:02 +0700 Subject: [PATCH 095/196] Add Python 3.3 support --- multisite/admin.py | 2 ++ multisite/forms.py | 2 ++ multisite/hacks.py | 2 ++ multisite/hosts.py | 2 ++ .../management/commands/update_public_suffix_list.py | 4 +++- multisite/managers.py | 1 + multisite/middleware.py | 9 +++++++-- multisite/models.py | 12 ++++++++---- multisite/template/loaders/cached.py | 1 + multisite/template/loaders/filesystem.py | 1 + multisite/template_loader.py | 1 + multisite/tests.py | 2 ++ multisite/threadlocals.py | 10 ++++++---- setup.py | 1 + 14 files changed, 39 insertions(+), 11 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index 2f72874..b5da51e 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.contrib import admin from django.contrib.admin.views.main import ChangeList from django.contrib.sites.models import Site diff --git a/multisite/forms.py b/multisite/forms.py index 0eeeca1..1a90814 100644 --- a/multisite/forms.py +++ b/multisite/forms.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.contrib.sites.admin import SiteAdmin from django.core.exceptions import ValidationError diff --git a/multisite/hacks.py b/multisite/hacks.py index d4e3552..d354691 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import sys from warnings import warn diff --git a/multisite/hosts.py b/multisite/hosts.py index 80bdd45..477a3ae 100644 --- a/multisite/hosts.py +++ b/multisite/hosts.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.utils.functional import SimpleLazyObject from django import VERSION as django_version diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index 5939ac4..07ac9c3 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -1,3 +1,5 @@ +from __future__ import print_function, unicode_literals + import logging import os import tempfile @@ -41,4 +43,4 @@ def setup_logging(self, verbosity): def log(self, msg, level=2): if self.verbosity >= level: - print msg + print(msg) diff --git a/multisite/managers.py b/multisite/managers.py index d8be80b..2731fc0 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -* +from __future__ import unicode_literals from warnings import warn diff --git a/multisite/middleware.py b/multisite/middleware.py index bf918fe..05644e7 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals + import os import tempfile -from urlparse import urlsplit, urlunsplit +try: + from urlparse import urlsplit, urlunsplit +except ImportError: + from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.contrib.sites.models import Site, SITE_CACHE @@ -44,7 +49,7 @@ def __init__(self): def get_cache_key(self, netloc): """Returns a cache key based on ``netloc``.""" - netloc = md5_constructor(netloc) + netloc = md5_constructor(netloc.encode('utf-8')) return 'multisite.alias.%s.%s' % (self.key_prefix, netloc.hexdigest()) diff --git a/multisite/models.py b/multisite/models.py index 73664c6..5f4e96a 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -1,4 +1,8 @@ +from __future__ import unicode_literals + import operator +from builtins import range +from functools import reduce from django.contrib.sites.models import Site from django.core.exceptions import ValidationError @@ -74,7 +78,7 @@ def _expand_netloc(cls, host, port=None): bits = host.split('.') result = [] - for i in xrange(0, (len(bits) + 1)): + for i in range(0, (len(bits) + 1)): if i == 0: host = '.'.join(bits[i:]) else: @@ -182,12 +186,12 @@ def clean_fields(self, exclude=None, *args, **kwargs): errors = {} try: super(Alias, self).clean_fields(exclude=exclude, *args, **kwargs) - except ValidationError, e: + except ValidationError as e: errors = e.update_error_dict(errors) try: self.clean_domain() - except ValidationError, e: + except ValidationError as e: errors = e.update_error_dict(errors) if errors: @@ -204,7 +208,7 @@ def validate_unique(self, exclude=None): errors = {} try: super(Alias, self).validate_unique(exclude=exclude) - except ValidationError, e: + except ValidationError as e: errors = e.update_error_dict(errors) if exclude is not None and 'domain' not in exclude: diff --git a/multisite/template/loaders/cached.py b/multisite/template/loaders/cached.py index 359aa6a..f01dffc 100644 --- a/multisite/template/loaders/cached.py +++ b/multisite/template/loaders/cached.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals from collections import defaultdict import hashlib diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index 5c4de1c..dcb4904 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals from django.core.exceptions import SuspiciousFileOperation from django.conf import settings diff --git a/multisite/template_loader.py b/multisite/template_loader.py index 1270a68..bf0321b 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals from .template.loaders.filesystem import Loader diff --git a/multisite/tests.py b/multisite/tests.py index 4f3fd22..8b03ee9 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import os import tempfile import warnings diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index a9ac320..b2a7cb8 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -* +from __future__ import unicode_literals +from builtins import int from contextlib import contextmanager from warnings import warn @@ -41,7 +43,7 @@ def __init__(self, default=None, *args, **kwargs): ``default``, if specified, determines the default SITE_ID, if that is unset. """ - if default is not None and not isinstance(default, (int, long)): + if default is not None and not isinstance(default, int): raise ValueError("%r is not a valid default." % default) self.default = default self.reset() @@ -58,21 +60,21 @@ def __int__(self): return self.site_id def __lt__(self, other): - if isinstance(other, (int, long)): + if isinstance(other, int): return self.__int__() < other elif isinstance(other, SiteID): return self.__int__() < other.__int__() return True def __le__(self, other): - if isinstance(other, (int, long)): + if isinstance(other, int): return self.__int__() <= other elif isinstance(other, SiteID): return self.__int__() <= other.__int__() return True def __eq__(self, other): - if isinstance(other, (int, long)): + if isinstance(other, int): return self.__int__() == other elif isinstance(other, SiteID): return self.__int__() == other.__int__() diff --git a/setup.py b/setup.py index c61b9a2..2d50f68 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ def long_description(): 'Programming Language :: Python', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries', From 031b4801f77522bbd0cfa58de3e175a6179f0233 Mon Sep 17 00:00:00 2001 From: DemonVex Date: Wed, 2 Dec 2015 09:23:21 +0700 Subject: [PATCH 096/196] Use "six" package instead of "future" --- multisite/models.py | 8 ++++++-- multisite/threadlocals.py | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/multisite/models.py b/multisite/models.py index 5f4e96a..ac4f0e4 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import operator -from builtins import range from functools import reduce from django.contrib.sites.models import Site @@ -14,6 +13,11 @@ from .hacks import use_framework_for_site_cache +try: + xrange +except NameError: # python3 + xrange = range + _site_domain = Site._meta.get_field('domain') use_framework_for_site_cache() @@ -78,7 +82,7 @@ def _expand_netloc(cls, host, port=None): bits = host.split('.') result = [] - for i in range(0, (len(bits) + 1)): + for i in xrange(0, (len(bits) + 1)): if i == 0: host = '.'.join(bits[i:]) else: diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index b2a7cb8..12a9117 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -* from __future__ import unicode_literals -from builtins import int +from django.utils import six from contextlib import contextmanager from warnings import warn @@ -43,7 +43,7 @@ def __init__(self, default=None, *args, **kwargs): ``default``, if specified, determines the default SITE_ID, if that is unset. """ - if default is not None and not isinstance(default, int): + if default is not None and not isinstance(default, six.integer_types): raise ValueError("%r is not a valid default." % default) self.default = default self.reset() @@ -60,21 +60,21 @@ def __int__(self): return self.site_id def __lt__(self, other): - if isinstance(other, int): + if isinstance(other, six.integer_types): return self.__int__() < other elif isinstance(other, SiteID): return self.__int__() < other.__int__() return True def __le__(self, other): - if isinstance(other, int): + if isinstance(other, six.integer_types): return self.__int__() <= other elif isinstance(other, SiteID): return self.__int__() <= other.__int__() return True def __eq__(self, other): - if isinstance(other, int): + if isinstance(other, six.integer_types): return self.__int__() == other elif isinstance(other, SiteID): return self.__int__() == other.__int__() From 5e8d5163bfaf4a9f274cae95007dd269e2775dcd Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 4 Dec 2015 15:22:57 -0400 Subject: [PATCH 097/196] Use `cache` instead of `get_cache` for Django >= 1.7 django.core.cache.get_cache() has been deprecated in Django 1.7 in favour of django.core.cache.caches. The problem here is that `get_cache()` takes additional kwargs while `caches` doesn't (since it's dict-like). Multisite was taking advantage of this by supplying a KEY_PREFIX kwarg if CACHE_MULTISITE_kEY_PREFIX was defined in settings. As far as I can tell, though, the `key_prefix` property in multisite.hacks.SiteCache and multisite.middleware.DynamicSiteMiddleware takes care of the CACHE_MULTISITE_KEY_PREFIX properly, so it's not necessary to supply a KEY_PREFIX to `get_cache()`. --- multisite/hacks.py | 11 +++++++++-- multisite/middleware.py | 13 +++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index d4e3552..76e486e 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -36,7 +36,14 @@ class SiteCache(object): """Wrapper for SITE_CACHE that assigns a key_prefix.""" def __init__(self, cache=None): - from django.core.cache import get_cache + try: + from django.core.cache import caches + except ImportError: + # Django < 1.7 compatibility + from django.core.cache import get_cache + else: + def get_cache(cache_alias): + return caches[cache_alias] if cache is None: cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') @@ -45,7 +52,7 @@ def __init__(self, cache=None): 'CACHE_MULTISITE_KEY_PREFIX', settings.CACHES[cache_alias].get('KEY_PREFIX', '') ) - cache = get_cache(cache_alias, KEY_PREFIX=self.key_prefix) + cache = get_cache(cache_alias) self._warn_cache_backend(cache, cache_alias) else: self._key_prefix = getattr( diff --git a/multisite/middleware.py b/multisite/middleware.py index bf918fe..dba977f 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -6,7 +6,16 @@ from django.conf import settings from django.contrib.sites.models import Site, SITE_CACHE from django.core import mail -from django.core.cache import get_cache + +try: + from django.core.cache import caches +except ImportError: + # Django < 1.7 compatibility + from django.core.cache import get_cache +else: + def get_cache(cache_alias): + return caches[cache_alias] + from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import get_callable from django.db.models.signals import pre_save, post_delete, post_init @@ -36,7 +45,7 @@ def __init__(self): settings.CACHES[self.cache_alias].get('KEY_PREFIX', '') ) - self.cache = get_cache(self.cache_alias, KEY_PREFIX=self.key_prefix) + self.cache = get_cache(self.cache_alias) post_init.connect(self.site_domain_cache_hook, sender=Site, dispatch_uid='multisite_post_init') pre_save.connect(self.site_domain_changed_hook, sender=Site) From cf58283acd3693f10059c8ab810ad8ab047d461e Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 7 Dec 2015 14:04:43 -0400 Subject: [PATCH 098/196] Bump up version to 1.2.0 --- CHANGELOG.rst | 6 ++++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 40d5bc9..1bca29d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Release Notes ============= +1.2.0 +----- + +* We now support Django 1.9 +* Following deprecation in django, all get_cache methods have been replaced caches. + 1.1.0 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index 7bb0401..674f7ae 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.1.2" +VERSION = "1.2.0" diff --git a/setup.py b/setup.py index 2d50f68..92a9e9b 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.1.2', + version='1.2.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From d6956eb4ba72876e71253b49b2f9cc009ff0dde9 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Wed, 9 Dec 2015 16:41:45 -0400 Subject: [PATCH 099/196] Use python unittest instead of django.utils.unittest django.utils.unittest was removed in Django 1.9 --- multisite/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/tests.py b/multisite/tests.py index 8b03ee9..85067f3 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -2,6 +2,7 @@ import os import tempfile +from unittest import skipUnless, skipIf import warnings @@ -13,7 +14,6 @@ from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory -from django.utils.unittest import skipUnless, skipIf from hacks import use_framework_for_site_cache From 4310dadda2697051f8e0420672d788776579651c Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Wed, 9 Dec 2015 16:45:07 -0400 Subject: [PATCH 100/196] Replace post_syncdb signal with post_migrate signal post_syncdb has been deprecated since Django 1.7 and was removed in Django 1.9 The only caveat is that the post_migrate signal doesn't send a `created_models` argument like post_syncdb did. I've tried to preserve the old behaviour when `created_models` is present in the args sent to `db_table_created_hook`. --- multisite/models.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/multisite/models.py b/multisite/models.py index ac4f0e4..51c069c 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -8,7 +8,11 @@ from django.core.validators import validate_ipv4_address from django.db import connections, models, router from django.db.models import Q -from django.db.models.signals import pre_save, post_save, post_syncdb +from django.db.models.signals import pre_save, post_save +try: + from django.db.models.signals import post_migrate +except ImportError: + from django.db.models.signals import post_syncdb as post_migrate from django.utils.translation import ugettext_lazy as _ from .hacks import use_framework_for_site_cache @@ -315,9 +319,15 @@ def site_created_hook(cls, sender, instance, raw, created, cls.sync(site=instance) @classmethod - def db_table_created_hook(cls, created_models, *args, **kwargs): + def db_table_created_hook(cls, *args, **kwargs): """Syncs canonical Alias objects for all existing Site objects.""" - if cls in created_models: + if kwargs.get('created_models'): + # For post_syncdb support in Django < 1.7: + # As before, only sync_all if Alias was in + # the list of created models + if cls in kwargs['created_models']: + Alias.canonical.sync_all() + else: Alias.canonical.sync_all() @@ -326,4 +336,4 @@ def db_table_created_hook(cls, created_models, *args, **kwargs): post_save.connect(Alias.site_created_hook, sender=Site) # Hook to handle syncdb creating the Alias table -post_syncdb.connect(Alias.db_table_created_hook) +post_migrate.connect(Alias.db_table_created_hook) From ca622b077a7f5f513608bdf3c34314097a041e2f Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Wed, 9 Dec 2015 17:10:37 -0400 Subject: [PATCH 101/196] Add comment for try..except import --- multisite/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/multisite/models.py b/multisite/models.py index 51c069c..6e73035 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -12,6 +12,7 @@ try: from django.db.models.signals import post_migrate except ImportError: + # Django < 1.7 compatibility from django.db.models.signals import post_syncdb as post_migrate from django.utils.translation import ugettext_lazy as _ From 136fd26925bf646ae0050ae04e2cb300e1e5171c Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Thu, 10 Dec 2015 14:25:15 -0400 Subject: [PATCH 102/196] Bump up version to 1.2.1 --- CHANGELOG.rst | 6 ++++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1bca29d..04a9b67 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Release Notes ============= +1.2.1 +----- + +* Remove django.utils.unittest (deprecated in 1.9) +* Use post_migrate instead of post_syncdb in > 1.7 + 1.2.0 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index 674f7ae..683a1f3 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.2.0" +VERSION = "1.2.1" diff --git a/setup.py b/setup.py index 92a9e9b..155cb8a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.0', + version='1.2.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 0acb281cbd335d7c1d3b857238eec08123d53e37 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 11 Dec 2015 16:15:52 -0400 Subject: [PATCH 103/196] Change return type of get_template_sources() in filesystem loader Django has changed some template loading code in 1.9, and it's now expected that a django.template.Origin object is returned from get_template_sources() --- multisite/template/loaders/filesystem.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index dcb4904..1e2b373 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -4,6 +4,11 @@ from django.core.exceptions import SuspiciousFileOperation from django.conf import settings from django.contrib.sites.models import Site +try: + from django.template import Origin +except ImportError: + # Django < 1.9 + pass from django.template.loaders.filesystem import Loader as FilesystemLoader from django.utils._os import safe_join @@ -24,7 +29,7 @@ def get_template_sources(self, template_name, template_dirs=None): for template_dir in new_template_dirs: try: - yield safe_join(template_dir, template_name) + name = safe_join(template_dir, template_name) except UnicodeDecodeError: # The template dir name was a bytestring that wasn't valid UTF-8. raise @@ -33,3 +38,14 @@ def get_template_sources(self, template_name, template_dirs=None): # template_dir (it might be inside another one, so this isn't # fatal). pass + + # Template loading was changed in Django 1.9, and + # django now expects an Origin object to be returned + try: + yield Origin( + name=name, + template_name=template_name, + loader=self + ) + except NameError: + yield name From 64d1bbf964235a2dae553d2333e8f2b772362b3e Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 14 Dec 2015 11:17:35 -0400 Subject: [PATCH 104/196] Use `continue` instead of `pass` when catching non-fatal errors If a `ValueError` or `SuspiciousFileOperation` was caught, `get_template_sources` was still trying to yield Origin/`name` even though `name` did not get defined. We want the behaviour of `continue` and not yield anything in this case, as django itself has done in django.template.loaders.filesystem.Loader.get_template_sources() --- multisite/template/loaders/filesystem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index 1e2b373..bc2a22f 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -37,7 +37,7 @@ def get_template_sources(self, template_name, template_dirs=None): # The joined path was located outside of this particular # template_dir (it might be inside another one, so this isn't # fatal). - pass + continue # Template loading was changed in Django 1.9, and # django now expects an Origin object to be returned From d2d38fb089e26a7df8f834071d6b9c886bd5b55d Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Thu, 17 Dec 2015 12:10:24 -0400 Subject: [PATCH 105/196] Change `Origin` import to support django < 1.9 If django is < 1.9, define a function that simply returns the name of the template. This is a bit cleaner, since we don't need to catch a `NameError` in `get_template_sources` anymore. --- multisite/template/loaders/filesystem.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index bc2a22f..5f3231c 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -7,8 +7,9 @@ try: from django.template import Origin except ImportError: - # Django < 1.9 - pass + # Django < 1.9 only expects the name string, not an Origin object + def Origin(name="", *args, **kwargs): + return name from django.template.loaders.filesystem import Loader as FilesystemLoader from django.utils._os import safe_join @@ -39,13 +40,8 @@ def get_template_sources(self, template_name, template_dirs=None): # fatal). continue - # Template loading was changed in Django 1.9, and - # django now expects an Origin object to be returned - try: - yield Origin( - name=name, - template_name=template_name, - loader=self - ) - except NameError: - yield name + yield Origin( + name=name, + template_name=template_name, + loader=self + ) From 9d159f368af5da58f5dfc08091e5fea5506c391f Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Thu, 17 Dec 2015 12:43:56 -0400 Subject: [PATCH 106/196] Bump up version to 1.2.2 --- CHANGELOG.rst | 5 +++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 04a9b67..27155be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,11 @@ Release Notes ============= +1.2.2 +----- + +* Fix for 1.9: change the return type of filesystem template loader's get_template_sources() + 1.2.1 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index 683a1f3..bc2cbb6 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.2.1" +VERSION = "1.2.2" diff --git a/setup.py b/setup.py index 155cb8a..0971016 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.1', + version='1.2.2', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 7a228763877d2827d9f1ef1b321135304b9e9d2b Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Thu, 17 Dec 2015 17:23:08 -0400 Subject: [PATCH 107/196] Fix a 'unique constraint' exception raised during tests Django 1.9 has made the `domain` field of Sites unique. This was causing an exception when trying to save a second site with a blank domain in AliasTest.test_hooks. --- multisite/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/multisite/tests.py b/multisite/tests.py index 85067f3..1cdc765 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -726,6 +726,7 @@ def test_hooks(self): site.domain = '' self.assertRaises(Alias.MultipleObjectsReturned, site.save) Alias.aliases.all().delete() + Site.objects.get(domain='').delete() # domain is unique in Django1.9 site.save() self.assertFalse(Alias.objects.filter(site=site).exists()) # Change Site from an empty domain name From c06e0cea37302562bb9271d74b8c3cba256981a2 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Tue, 5 Jan 2016 11:12:32 -0400 Subject: [PATCH 108/196] Bump up version to 1.2.3 --- CHANGELOG.rst | 4 ++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 27155be..63f597d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Release Notes ============= +1.2.3 +----- +* Fix a broken test, due to a django uniqueness constraint in 1.9 + 1.2.2 ----- diff --git a/multisite/__init__.py b/multisite/__init__.py index bc2cbb6..c5e8b2e 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "1.2.2" +VERSION = "l.2.3" diff --git a/setup.py b/setup.py index 0971016..d5a2c1c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.2', + version='1.2.3', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 30c96d2a476e708b383d6bdd1e1f4db7a736cc95 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Mon, 11 Jan 2016 10:22:03 +0000 Subject: [PATCH 109/196] Move validation of domain to save so it is called after the pre_save site_domain_changed_hook --- multisite/models.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/multisite/models.py b/multisite/models.py index 6e73035..bce1fc9 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -189,6 +189,11 @@ def __unicode__(self): def save_base(self, *args, **kwargs): self.full_clean() + # For canonical Alias, domains must match Site domains. + if self.is_canonical and self.domain != self.site.domain: + raise ValidationError( + {'domain': ['Does not match %r' % self.site]} + ) super(Alias, self).save_base(*args, **kwargs) def clean_fields(self, exclude=None, *args, **kwargs): @@ -198,21 +203,9 @@ def clean_fields(self, exclude=None, *args, **kwargs): except ValidationError as e: errors = e.update_error_dict(errors) - try: - self.clean_domain() - except ValidationError as e: - errors = e.update_error_dict(errors) - if errors: raise ValidationError(errors) - def clean_domain(self): - # For canonical Alias, domains must match Site domains. - if self.is_canonical and self.domain != self.site.domain: - raise ValidationError( - {'domain': ['Does not match %r' % self.site]} - ) - def validate_unique(self, exclude=None): errors = {} try: From 36ceb4afd3e20e05be7f5616017a529bde6ff4ab Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 12 Jan 2016 10:57:48 +0000 Subject: [PATCH 110/196] Remove clean_fields, fix validate_unique on domain so error is not duplicated --- multisite/models.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/multisite/models.py b/multisite/models.py index bce1fc9..d268aa8 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -190,22 +190,16 @@ def __unicode__(self): def save_base(self, *args, **kwargs): self.full_clean() # For canonical Alias, domains must match Site domains. + # This needs to be validated here so that it is executed *after* the + # Site pre-save signal updates the domain (an AliasInline modelform + # on SiteAdmin will be saved (and it's clean methods run before + # the Site is saved) if self.is_canonical and self.domain != self.site.domain: raise ValidationError( {'domain': ['Does not match %r' % self.site]} ) super(Alias, self).save_base(*args, **kwargs) - def clean_fields(self, exclude=None, *args, **kwargs): - errors = {} - try: - super(Alias, self).clean_fields(exclude=exclude, *args, **kwargs) - except ValidationError as e: - errors = e.update_error_dict(errors) - - if errors: - raise ValidationError(errors) - def validate_unique(self, exclude=None): errors = {} try: @@ -219,7 +213,7 @@ def validate_unique(self, exclude=None): field_error = self.unique_error_message(self.__class__, (field_name,)) if field_name not in errors or \ - field_error not in errors[field_name]: + str(field_error) not in [str(err) for err in errors[field_name]]: qset = self.__class__.objects.filter( **{field_name + '__iexact': getattr(self, field_name)} ) From 930a6da9da4c795a7f8d49c6fc4615715f0560a5 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Wed, 13 Jan 2016 11:02:32 -0400 Subject: [PATCH 111/196] Bump up version to 1.2.4 --- CHANGELOG.rst | 4 ++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 63f597d..34b943c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Release Notes ============= +1.2.4 +----- +* Fix domain validation so it's called after the pre_save signal + 1.2.3 ----- * Fix a broken test, due to a django uniqueness constraint in 1.9 diff --git a/multisite/__init__.py b/multisite/__init__.py index c5e8b2e..8fcd00a 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.2.3" +VERSION = "l.2.4" diff --git a/setup.py b/setup.py index d5a2c1c..1006025 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.3', + version='1.2.4', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From e752be44d80b48b71cb9a50134d86712fe1f24e3 Mon Sep 17 00:00:00 2001 From: John Bazik Date: Mon, 1 Feb 2016 17:04:36 -0500 Subject: [PATCH 112/196] Avoid template loader details for better django version compatibility. --- multisite/template/loaders/filesystem.py | 44 +++++------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index 5f3231c..46fe392 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -1,47 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.core.exceptions import SuspiciousFileOperation +import os from django.conf import settings from django.contrib.sites.models import Site -try: - from django.template import Origin -except ImportError: - # Django < 1.9 only expects the name string, not an Origin object - def Origin(name="", *args, **kwargs): - return name from django.template.loaders.filesystem import Loader as FilesystemLoader -from django.utils._os import safe_join class Loader(FilesystemLoader): def get_template_sources(self, template_name, template_dirs=None): - if not template_dirs: - template_dirs = settings.TEMPLATE_DIRS - domain = Site.objects.get_current().domain - default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 'default') - - new_template_dirs = [] - for template_dir in template_dirs: - new_template_dirs.append(safe_join(template_dir, domain)) - if default_dir: - new_template_dirs.append(safe_join(template_dir, default_dir)) - - for template_dir in new_template_dirs: - try: - name = safe_join(template_dir, template_name) - except UnicodeDecodeError: - # The template dir name was a bytestring that wasn't valid UTF-8. - raise - except (ValueError, SuspiciousFileOperation): - # The joined path was located outside of this particular - # template_dir (it might be inside another one, so this isn't - # fatal). - continue - - yield Origin( - name=name, - template_name=template_name, - loader=self - ) + default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', + 'default') + for tname in (os.path.join(domain, template_name), + os.path.join(default_dir, template_name)): + for item in super(Loader, self).get_template_sources(tname, + template_dirs): + yield item From d645915a84b8d4d184736c0b22dbc0faf2f6288b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Tue, 26 Jan 2016 09:34:21 -0500 Subject: [PATCH 113/196] Update documentation. Django 1.8 has deprecated all of the TEMPLATE_ variables. https://docs.djangoproject.com/en/dev/releases/1.8/#template-related-settings --- README.rst | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 4a4060a..0deb705 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,24 @@ Replace your SITE_ID in settings.py to:: from multisite import SiteID SITE_ID = SiteID() -Add to settings.py TEMPLATE_LOADERS:: +Add to your settings.py TEMPLATES loaders in the OPTIONS section:: + + TEMPLATES = [ + ... + { + ... + 'OPTIONS': { + 'loaders': ( + 'multisite.template_loader.Loader', + 'django.template.loaders.app_directories.Loader', + ) + } + ... + } + ... + ] + +Or for Django 1.7 and earlier, add to settings.py TEMPLATES_LOADERS:: TEMPLATE_LOADERS = ( 'multisite.template_loader.Loader', From 216552dad8eda6416a39caad8e353610393d8072 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 2 May 2016 09:42:25 -0400 Subject: [PATCH 114/196] Bump up version to 1.2.5 --- CHANGELOG.rst | 4 ++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 34b943c..fa7878f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Release Notes ============= +1.2.5 +---- +* Make template loading more resilient to changes in django (thanks to jbazik for the contribution) + 1.2.4 ----- * Fix domain validation so it's called after the pre_save signal diff --git a/multisite/__init__.py b/multisite/__init__.py index 8fcd00a..85f0e25 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.2.4" +VERSION = "l.2.5" diff --git a/setup.py b/setup.py index 1006025..5951a9c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.4', + version='1.2.5', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 444104361d69ada537f8e06b0152de4acfeb8380 Mon Sep 17 00:00:00 2001 From: jordiecometrica Date: Mon, 13 Jun 2016 14:40:22 -0400 Subject: [PATCH 115/196] restrict version of tldextract (#27) Version 2.0 breaks API: https://github.com/john-kurkowski/tldextract/blob/master/CHANGELOG.md#20rc1-2016-04-04 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5951a9c..557dfae 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def long_description(): 'multisite.template', 'multisite.template.loaders'], install_requires=['Django>=1.6', - 'tldextract>=1.1.3'], + 'tldextract>=1.1.3, <2.0'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', From 0e0b5f544257395211fc66ddbaf96d4433290f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Wed, 15 Jun 2016 08:43:25 -0400 Subject: [PATCH 116/196] Bump up version to 1.2.6 --- CHANGELOG.rst | 4 ++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fa7878f..4faec1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ Release Notes ============= +1.2.6 +---- +* Pin the tldextract dependency to version < 2.0, which breaks API. + 1.2.5 ---- * Make template loading more resilient to changes in django (thanks to jbazik for the contribution) diff --git a/multisite/__init__.py b/multisite/__init__.py index 85f0e25..b05bcc0 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.2.5" +VERSION = "l.2.6" diff --git a/setup.py b/setup.py index 557dfae..c9518a5 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.5', + version='1.2.6', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From bb3424aa79130620c0a1e617f80f4cb34c600a9d Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 11 Jul 2016 13:03:28 +0100 Subject: [PATCH 117/196] Fix "error reading TLD cache file" error The cache_file argument to TLDExtract was an empty tempfile, so pickle complained about an EOF error when loading it. That was then logged as an "error reading TLD cache file" error by tldextract. Contrary to an empty cache_file, tldextract deals with a non-existent cache_file just fine, so we don't really need that intermediate step of creating a tempfile anyway. Use the TLDExtract `update` method to directly update multisite's public suffix list --- .../commands/update_public_suffix_list.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index 07ac9c3..ee16249 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -20,17 +20,8 @@ def handle_noargs(self, **options): ) self.log("Updating {filename}".format(filename=filename)) - with tempfile.NamedTemporaryFile(dir=os.path.dirname(filename)) as f: - tmpname = f.name - - extract = tldextract.TLDExtract(fetch=True, cache_file=tmpname) - extract._get_tld_extractor() - self.log( - "Downloaded new data to {filename}".format(filename=tmpname) - ) - os.rename(tmpname, filename) - f.delete = False # No need to delete f any more. - + extract = tldextract.TLDExtract(fetch=True, cache_file=filename) + extract.update(fetch_now=True) self.log("Done.") def setup_logging(self, verbosity): From ceb5728848c69d8a5cfe9c60a628e3d17e200f8a Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 11 Jul 2016 14:31:30 +0100 Subject: [PATCH 118/196] Update minimum tldextract requirement The `update` method of TLDExtract was added in 1.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c9518a5..03349fa 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def long_description(): 'multisite.template', 'multisite.template.loaders'], install_requires=['Django>=1.6', - 'tldextract>=1.1.3, <2.0'], + 'tldextract>=1.2, <2.0'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', From ee289f95822e1b4d83856eceb33459c421f19ade Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 11 Jul 2016 16:58:13 +0100 Subject: [PATCH 119/196] remove `fetch` arg for tldextract.TLDExtract fetch is deprecated in 1.7.5, and is True by default in older versions of tldextract anyway --- multisite/management/commands/update_public_suffix_list.py | 2 +- multisite/middleware.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index ee16249..cb85ca5 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -20,7 +20,7 @@ def handle_noargs(self, **options): ) self.log("Updating {filename}".format(filename=filename)) - extract = tldextract.TLDExtract(fetch=True, cache_file=filename) + extract = tldextract.TLDExtract(cache_file=filename) extract.update(fetch_now=True) self.log("Done.") diff --git a/multisite/middleware.py b/multisite/middleware.py index bfeb0ba..e8541af 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -223,8 +223,7 @@ def __init__(self): def tldextract(self, url): import tldextract if self._tldextract is None: - self._tldextract = tldextract.TLDExtract(fetch=True, - cache_file=self.psl_cache) + self._tldextract = tldextract.TLDExtract(cache_file=self.psl_cache) return self._tldextract(url) def match_cookies(self, request, response): From f68be87c3425fb8c4075a528d64f9993a5b83015 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Mon, 11 Jul 2016 17:00:57 +0100 Subject: [PATCH 120/196] Remove upper limit for tldextract requirement --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 03349fa..2f8cfe8 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def long_description(): 'multisite.template', 'multisite.template.loaders'], install_requires=['Django>=1.6', - 'tldextract>=1.2, <2.0'], + 'tldextract>=1.2'], classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', From c7a0a16019213a8171b8aa5ab29d861345c79047 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 15 Jul 2016 15:09:29 +0100 Subject: [PATCH 121/196] Bump up version to 1.3.0 --- CHANGELOG.rst | 5 +++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4faec1b..9f3a555 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,11 @@ Release Notes ============= +1.3.0 +----- +* Fix tempfile issue with update_public_suffix_list command +* Support for tldextract version >= 2.0 + 1.2.6 ---- * Pin the tldextract dependency to version < 2.0, which breaks API. diff --git a/multisite/__init__.py b/multisite/__init__.py index b05bcc0..4825856 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.2.6" +VERSION = "l.3.0" diff --git a/setup.py b/setup.py index 2f8cfe8..34591ab 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.2.6', + version='1.3.0' description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 4c029dbcc803a324d909c62fc97700b9f39bf634 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 15 Jul 2016 15:10:28 +0100 Subject: [PATCH 122/196] Added tag version-1.3.0 for changeset 16618d8dfaa8 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 1459eac..22ef1c3 100644 --- a/.hgtags +++ b/.hgtags @@ -9,3 +9,4 @@ ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 1f497c216209683af3bc20bb15bb29ef305f7ca8 version-1.1.0 +16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 From d00da8d033a84f08b0ad58051769dc6985bb6009 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 15 Jul 2016 15:12:39 +0100 Subject: [PATCH 123/196] Missing comma --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 34591ab..577efd3 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.3.0' + version='1.3.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From ebf33335d60c2c8e80a523d7ceec13906d075273 Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Fri, 15 Jul 2016 15:12:57 +0100 Subject: [PATCH 124/196] Added tag version-1.3.0 for changeset efe5daef3c88 --- .hgtags | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.hgtags b/.hgtags index 22ef1c3..8fe82a1 100644 --- a/.hgtags +++ b/.hgtags @@ -10,3 +10,5 @@ ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 1f497c216209683af3bc20bb15bb29ef305f7ca8 version-1.1.0 16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 +16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 +efe5daef3c883dc309e64e6fa1ab88bb69098ab5 version-1.3.0 From ec24a3bf56c8c1f2460a758280a1011894e167ac Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Fri, 15 Jul 2016 18:07:42 +0200 Subject: [PATCH 125/196] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0deb705..a1d5582 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ Quickstart Replace your SITE_ID in settings.py to:: from multisite import SiteID - SITE_ID = SiteID() + SITE_ID = SiteID(default=1) Add to your settings.py TEMPLATES loaders in the OPTIONS section:: From de236878afeca6c23f0acdfea2a7fb9c03643339 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 19 Jul 2016 16:15:19 +0100 Subject: [PATCH 126/196] Replace deprecated ExtractResult().tld with .suffix --- multisite/middleware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index e8541af..adeb718 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -235,7 +235,7 @@ def process_response(self, request, response): return response # No cookies to edit parsed = self.tldextract(request.get_host()) - if not parsed.tld: + if not parsed.suffix: return response # IP address or local path if not parsed.domain: return response # Only TLD @@ -248,7 +248,7 @@ def process_response(self, request, response): else: subdomains = [''] + subdomains[-self.depth:] - domain = '.'.join(subdomains + [parsed.domain, parsed.tld]) + domain = '.'.join(subdomains + [parsed.domain, parsed.suffix]) for morsel in matched: morsel['domain'] = domain From 240196255d96975e6d3970f663419ba4c5336402 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Fri, 22 Jul 2016 09:08:57 +0200 Subject: [PATCH 127/196] Bugfix: respect CACHE_MULTISITE_ALIAS This typo will result in usage the 'default' cache and ignore CACHE_MULTISITE_ALIAS --- multisite/hacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index b83bf57..299dcdb 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -48,7 +48,7 @@ def get_cache(cache_alias): return caches[cache_alias] if cache is None: - cache_alias = getattr(settings, 'CACHE_SITES_ALIAS', 'default') + cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') self._key_prefix = getattr( settings, 'CACHE_MULTISITE_KEY_PREFIX', From 93e637c38f63c31c6e8f11a6fd2d169de82a0ced Mon Sep 17 00:00:00 2001 From: Matthieu Hughes Date: Tue, 2 Aug 2016 14:54:21 -0400 Subject: [PATCH 128/196] Bump up version to 1.3.1 --- CHANGELOG.rst | 6 ++++++ multisite/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f3a555..ca6df79 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Release Notes ============= +1.3.1 +----- +* Add default for SiteID in the README (PR #31) +* Respect the CACHE_MULTISITE_ALIAS in SiteCache (PR #34) +* Replace deprecated ExtractResult().tld with .suffic (PR #32) + 1.3.0 ----- * Fix tempfile issue with update_public_suffix_list command diff --git a/multisite/__init__.py b/multisite/__init__.py index 4825856..4f88ebc 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.3.0" +VERSION = "l.3.1" diff --git a/setup.py b/setup.py index 577efd3..c15829d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.3.0', + version='1.3.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 07f45ee4f9dd8d5cf0e17eb7666353b04e82a7c2 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 01:08:38 -0500 Subject: [PATCH 129/196] Enable "python setup.py test" by using setuptools over distutils. --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c15829d..c2e1037 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from distutils.core import setup +from setuptools import setup import os _dir_ = os.path.dirname(__file__) @@ -27,6 +27,7 @@ def long_description(): 'multisite.template.loaders'], install_requires=['Django>=1.6', 'tldextract>=1.2'], + test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', From 04e742e8ff8127e06519913ba1ce67cee6ebc6f0 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 20:29:01 -0500 Subject: [PATCH 130/196] Repair and modernize tests to run standalone. We have been doing maintainence this by accepting pull requests, testing them once under our dev environment, and praying. I hope that this change, especially with the additional documentation, helps make it easier to do more reliable tests. --- README.rst | 20 ++++++++++++ multisite/tests.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/README.rst b/README.rst index a1d5582..20420da 100644 --- a/README.rst +++ b/README.rst @@ -136,3 +136,23 @@ run:: .. _cross-domain cookies: http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path .. _Public Suffix List: http://publicsuffix.org/ + + +Tests +----- + +Before testing, it is a good idea to set up a virtualenv. This way, the package can +be tested under different combinations of python and django versions:: + + virtualenv .venv + . .venv/bin/activate + +While in an activated venv, you can pick specific versions of dependencies like this:: + + pip install django==1.7 + +To use a different python version, erase the folder and rebuild with `virtualenv -P` + +To run the tests:: + + python setup.py test diff --git a/multisite/tests.py b/multisite/tests.py index 1cdc765..47ef077 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1,10 +1,26 @@ +""" +Tests for django-multisite. + +To run this, use: +$ python -m multisite.tests +or +$ python setup.py test +from the parent directory. + +This file uses relative imports and so cannot be run standalone. +""" + from __future__ import unicode_literals import os import tempfile +import unittest from unittest import skipUnless, skipIf import warnings +# this has to be set before (most of) django is loaded or else +# the imports crash with django.core.exceptions.ImproperlyConfigured +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') import django from django.conf import settings @@ -14,6 +30,21 @@ from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory +# monkey-patch over version differences in django +from django.test.utils import setup_test_environment, teardown_test_environment +if django.VERSION >= (1,9): + from django.test.utils import setup_databases, teardown_databases +elif django.VERSION >= (1,6): + # {setup,teardown}_databases() were methods back then + # but all they use of their owner is the optins settings verbosity, interactive, keepdb, etc + # so we make a mock owner and call the method on it + from django.test.runner import setup_databases, DiscoverRunner as _DiscoverRunner + def teardown_databases(old_config, verbosity): + class O: pass + o = _DiscoverRunner() + o.verbosity = verbosity + o.interactive = True + return _DiscoverRunner.teardown_databases(o,old_config) from hacks import use_framework_for_site_cache @@ -903,3 +934,49 @@ def test_subdomain_depth(self): request = self.factory.get('/', host='www.us.app.example.com') cookies = middleware.process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.app.example.com') + + +if django.VERSION >= (1,7): + # Django demands it. + # You *will* comply. + django.setup() # XXX? + +# Run tests with the necessary Django-global fixtures in place. +# This mimics what `django manage.py test` does. +# +# Long story: Django screwed up. +# They put fixture-ish code ({setup,teardown}_{test_environment,databases}()) +# into their test runner (django.test.runner.DiscoverRunner.run_tests(test_labels, extra_tests=None), +# where test_labels is a list of strings naming the tests to load +# *but can be None to mean 'all tests recursively'*,and extra_tests +# is a TestSuite, if given) and then failed to make it API-compatible +# with unittest's design (.run(tests), +# where tests is a single TestCase, or a TestSuite) +# which means `python setup.py test` can't use it as a test_runner, +# even if the setuptools people had documented clearly how to (which +# they haven't: https://packaging.python.org/distributing/#setup-args ? +# https://setuptools.readthedocs.io/en/latest/setuptools.html#test-build-package-and-run-a-unittest-suite ?) +# +# They screwed up so bad that someone went ahead and wrote an entire +# Django plugin just +# so they could say `python setup.py test`. +# +# These setUp/tearDown methods crimp the relevant lines from run_tests() +# so that necessary cruft is in place before trying to run the tests. +# +# Why doesn't django.test.TestCase do this in a {setUp,tearDown}Class()? + +verbosity = 1 +interactive = True + +def setUpModule(): + global db + setup_test_environment() + db = setup_databases(verbosity, interactive) + +def tearDownModule(): + teardown_databases(db, verbosity) + teardown_test_environment() + +if __name__ == '__main__': + unittest.main() From a3701904d254aad94c2ce382c8632bb831612f51 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 21:45:50 -0500 Subject: [PATCH 131/196] Repair under Django 1.6 / python2 This is an improved @jordiecometrica's b57a470ffa796bbf75cf83fd870445763f2ec1a0 which special-cases the 1.6 branch. Note: our setup.py says we're only supporting back to Django 1.6. An equally good way to handle this would be to bump that to 1.7, and given that not even 1.7 is getting security patches anymore, maybe we should just go straight to 1.8. But for now, be conservative. --- multisite/middleware.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index adeb718..0ad04d0 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -8,6 +8,7 @@ except ImportError: from urllib.parse import urlsplit, urlunsplit +import django from django.conf import settings from django.contrib.sites.models import Site, SITE_CACHE from django.core import mail @@ -136,7 +137,10 @@ def fallback_view(self, request): if callable(fallback): view = fallback else: - view = get_callable(fallback, can_fail=True) + if django.VERSION > (1,7): + view = get_callable(fallback, can_fail=True) + else: + view = get_callable(fallback) if not callable(view): raise ImproperlyConfigured( 'settings.MULTISITE_FALLBACK is not callable: %s' % From 0b8304b593bfdfdb9e8fe11a39b8575826a89d54 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 22:10:52 -0500 Subject: [PATCH 132/196] test_versions, to prevent bitrot --- README.rst | 24 +++++++++++++----------- test_versions | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100755 test_versions diff --git a/README.rst b/README.rst index 20420da..ef18776 100644 --- a/README.rst +++ b/README.rst @@ -101,8 +101,6 @@ domains, such as:: Cross-domain cookies -------------------- -*New in version 0.3.0.* - In order to support `cross-domain cookies`_, for purposes like single-sign-on, prepend the following to the top of @@ -141,18 +139,22 @@ run:: Tests ----- -Before testing, it is a good idea to set up a virtualenv. This way, the package can -be tested under different combinations of python and django versions:: - - virtualenv .venv - . .venv/bin/activate +To run the tests:: -While in an activated venv, you can pick specific versions of dependencies like this:: + python setup.py test - pip install django==1.7 +Before deploying a change, you should run:: -To use a different python version, erase the folder and rebuild with `virtualenv -P` + test_versions -To run the tests:: +to verify it has not broken anything. This script runs the tests +under every supported combination of Django and Python, by creating +virtualenvs. If a test breaks, it will quit, leaving the virtualenv +intact in .venv-python2, or .venv-python3, depending on which space +it broke in. You can rerun the broken version manually with:: + . .venv-python2/bin/activate # or .venv-python3 python setup.py test + +(of course, as new versions are supported and old are retired, + please keep test_versions up to date) diff --git a/test_versions b/test_versions new file mode 100755 index 0000000..90ffdc1 --- /dev/null +++ b/test_versions @@ -0,0 +1,24 @@ +#!/bin/sh +# systematically run tests under all combinations +# of pythons and djangos that we support. + +# ensure we are running in a known location: +# the location of the current file +cd $(dirname $0) + +for PYTHON in python2 python3; do + # *nuke* the virtualenv, if it exists + if [ -e .venv-$PYTHON ]; then + rm -r .venv-$PYTHON; + fi + virtualenv -p $PYTHON .venv-$PYTHON + . .venv-$PYTHON/bin/activate + for DJANGO in 1.6 1.7 1.8 1.9 1.10; do + pip install django==$DJANGO + echo $PYTHON/django$DJANGO + python setup.py test || exit $? + echo "-----------------------------------------------------------" + echo + done + deactivate +done From 11400767df3990b5a9949c46f2466af630a6ec42 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 22:33:03 -0500 Subject: [PATCH 133/196] Repair tests for Django 1.9 --- multisite/tests.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 47ef077..714577c 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -18,12 +18,19 @@ from unittest import skipUnless, skipIf import warnings + +import django +from django.conf import settings + # this has to be set before (most of) django is loaded or else # the imports crash with django.core.exceptions.ImproperlyConfigured os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') -import django -from django.conf import settings +if django.VERSION >= (1,7): + # Django demands it. + # You *will* comply. + django.setup() + from django.contrib.sites.models import Site from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, ValidationError) @@ -32,7 +39,7 @@ from django.test.client import RequestFactory as DjangoRequestFactory # monkey-patch over version differences in django from django.test.utils import setup_test_environment, teardown_test_environment -if django.VERSION >= (1,9): +if django.VERSION >= (1,10): from django.test.utils import setup_databases, teardown_databases elif django.VERSION >= (1,6): # {setup,teardown}_databases() were methods back then @@ -936,10 +943,6 @@ def test_subdomain_depth(self): self.assertEqual(cookies['a']['domain'], '.app.example.com') -if django.VERSION >= (1,7): - # Django demands it. - # You *will* comply. - django.setup() # XXX? # Run tests with the necessary Django-global fixtures in place. # This mimics what `django manage.py test` does. From 85a263cddc25adf32347fb5afab980560dbcc269 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Tue, 24 Jan 2017 23:32:14 -0500 Subject: [PATCH 134/196] Repairs for Django 1.10 on python2. In light of https://github.com/ecometrica/django-multisite/issues/37 this is certainly not the only Django 1.10 issue. For now, the tests are passing, but clearly we need to add some. --- multisite/middleware.py | 14 ++++++++++---- multisite/tests.py | 25 +++++++++++-------------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index 0ad04d0..1807352 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -137,11 +137,17 @@ def fallback_view(self, request): if callable(fallback): view = fallback else: - if django.VERSION > (1,7): - view = get_callable(fallback, can_fail=True) - else: + try: view = get_callable(fallback) - if not callable(view): + if django.VERSION < (1,8): + # older django's get_callable falls through on error, + # returning the input as output + # which notably is definitely not a callable here + if not callable(view): + raise ImportError() + except ImportError: + # newer django forces this to be an error, which is tidier. + # we rewrite the error to be a bit more helpful to our users. raise ImproperlyConfigured( 'settings.MULTISITE_FALLBACK is not callable: %s' % fallback diff --git a/multisite/tests.py b/multisite/tests.py index 714577c..5debf58 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -37,21 +37,18 @@ from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory -# monkey-patch over version differences in django from django.test.utils import setup_test_environment, teardown_test_environment -if django.VERSION >= (1,10): - from django.test.utils import setup_databases, teardown_databases -elif django.VERSION >= (1,6): - # {setup,teardown}_databases() were methods back then - # but all they use of their owner is the optins settings verbosity, interactive, keepdb, etc - # so we make a mock owner and call the method on it - from django.test.runner import setup_databases, DiscoverRunner as _DiscoverRunner - def teardown_databases(old_config, verbosity): - class O: pass - o = _DiscoverRunner() - o.verbosity = verbosity - o.interactive = True - return _DiscoverRunner.teardown_databases(o,old_config) +from django.test.runner import setup_databases +from django.test.runner import DiscoverRunner +def teardown_databases(old_config, verbosity): + """ + Wrap DiscoverRunner.teardown_databases() to a first-class function, + like its partner setup_databases() + """ + # The only time teardown_databases() speaks to self is to get + # settings: verbosity, interactive, keepdb, etc + # and we can fake that with a mock object. + return DiscoverRunner(verbosity=verbosity, interactive=interactive).teardown_databases(old_config) from hacks import use_framework_for_site_cache From 113dc87fcd69ecb86b8dd7a993a4eeff80989c2d Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 00:07:39 -0500 Subject: [PATCH 135/196] Insist on absolute imports, since python3 does. --- multisite/admin.py | 1 + multisite/forms.py | 2 ++ multisite/hacks.py | 1 + multisite/hosts.py | 1 + multisite/management/commands/update_public_suffix_list.py | 4 +++- multisite/managers.py | 1 + multisite/middleware.py | 1 + multisite/migrations/0001_initial.py | 1 + multisite/models.py | 1 + multisite/template/loaders/cached.py | 1 + multisite/template/loaders/filesystem.py | 1 + multisite/template_loader.py | 1 + multisite/tests.py | 3 ++- multisite/threadlocals.py | 1 + 14 files changed, 18 insertions(+), 2 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index b5da51e..8bd3167 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from __future__ import absolute_import from django.contrib import admin from django.contrib.admin.views.main import ChangeList diff --git a/multisite/forms.py b/multisite/forms.py index 1a90814..a100888 100644 --- a/multisite/forms.py +++ b/multisite/forms.py @@ -1,4 +1,6 @@ from __future__ import unicode_literals +from __future__ import absolute_import + from django.contrib.sites.admin import SiteAdmin from django.core.exceptions import ValidationError diff --git a/multisite/hacks.py b/multisite/hacks.py index 299dcdb..897007b 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from __future__ import absolute_import import sys from warnings import warn diff --git a/multisite/hosts.py b/multisite/hosts.py index 477a3ae..18e1e9d 100644 --- a/multisite/hosts.py +++ b/multisite/hosts.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from __future__ import absolute_import from django.utils.functional import SimpleLazyObject diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index cb85ca5..ba15cd6 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -1,4 +1,6 @@ -from __future__ import print_function, unicode_literals +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import absolute_import import logging import os diff --git a/multisite/managers.py b/multisite/managers.py index 2731fc0..919d7ff 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -* from __future__ import unicode_literals +from __future__ import absolute_import from warnings import warn diff --git a/multisite/middleware.py b/multisite/middleware.py index 1807352..e0b379d 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from __future__ import absolute_import import os import tempfile diff --git a/multisite/migrations/0001_initial.py b/multisite/migrations/0001_initial.py index 900e3f4..d2d0f85 100644 --- a/multisite/migrations/0001_initial.py +++ b/multisite/migrations/0001_initial.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from __future__ import absolute_import from django.db import models, migrations import multisite.models diff --git a/multisite/models.py b/multisite/models.py index d268aa8..85320da 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from __future__ import absolute_import import operator from functools import reduce diff --git a/multisite/template/loaders/cached.py b/multisite/template/loaders/cached.py index f01dffc..5d8ff6d 100644 --- a/multisite/template/loaders/cached.py +++ b/multisite/template/loaders/cached.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from __future__ import absolute_import from collections import defaultdict import hashlib diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index 46fe392..cc0d957 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from __future__ import absolute_import import os from django.conf import settings diff --git a/multisite/template_loader.py b/multisite/template_loader.py index bf0321b..5461d86 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from __future__ import absolute_import from .template.loaders.filesystem import Loader diff --git a/multisite/tests.py b/multisite/tests.py index 5debf58..858f06a 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -11,6 +11,7 @@ """ from __future__ import unicode_literals +from __future__ import absolute_import import os import tempfile @@ -50,7 +51,7 @@ def teardown_databases(old_config, verbosity): # and we can fake that with a mock object. return DiscoverRunner(verbosity=verbosity, interactive=interactive).teardown_databases(old_config) -from hacks import use_framework_for_site_cache +from .hacks import use_framework_for_site_cache try: from django.test.utils import override_settings diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 12a9117..19b62ae 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -* from __future__ import unicode_literals +from __future__ import absolute_import from django.utils import six from contextlib import contextmanager From 4939d2ded4a5ea1a451db62908a52994a13e6841 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 00:17:52 -0500 Subject: [PATCH 136/196] ValueError -> TypeError, since this line is explicitly checking a type. --- multisite/tests.py | 4 ++-- multisite/threadlocals.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 858f06a..1454693 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -554,8 +554,8 @@ def test_init(self): self.assertEqual(int(SiteDomain(default=self.domain)), self.site.id) self.assertRaises(Site.DoesNotExist, int, SiteDomain(default='invalid')) - self.assertRaises(ValueError, SiteDomain, default=None) - self.assertRaises(ValueError, SiteDomain, default=1) + self.assertRaises(TypeError, SiteDomain, default=None) + self.assertRaises(TypeError, SiteDomain, default=1) def test_deferred_site(self): domain = 'example.org' diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 19b62ae..330e34b 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -132,7 +132,7 @@ def __init__(self, default, *args, **kwargs): that is unset. """ if not isinstance(default, basestring): - raise ValueError("%r is not a valid default domain." % default) + raise TypeError("%r is not a valid default domain." % default) self.default_domain = default self.default = None self.reset() From 67983fabb2fe42e7e3eddf7388239bd964cf241a Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 00:38:36 -0500 Subject: [PATCH 137/196] Make it very obvious what platform is broken, when it's broken. --- test_versions | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test_versions b/test_versions index 90ffdc1..34707fc 100755 --- a/test_versions +++ b/test_versions @@ -12,13 +12,20 @@ for PYTHON in python2 python3; do rm -r .venv-$PYTHON; fi virtualenv -p $PYTHON .venv-$PYTHON + + echo + echo ">>> Switched to $PYTHON <<<" + echo + . .venv-$PYTHON/bin/activate for DJANGO in 1.6 1.7 1.8 1.9 1.10; do pip install django==$DJANGO echo $PYTHON/django$DJANGO - python setup.py test || exit $? - echo "-----------------------------------------------------------" - echo + if ! python setup.py test; then + echo + echo "Failed under $PYTHON and django-$DJANGO" + exit 1 + fi done deactivate done From d42777673540de746c1ef8ad70292eb6ae8ddf64 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 00:46:00 -0500 Subject: [PATCH 138/196] Python3 fixes. --- multisite/tests.py | 11 +++++++---- multisite/threadlocals.py | 8 +++++++- test_versions | 5 +++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 1454693..25523fe 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -13,6 +13,7 @@ from __future__ import unicode_literals from __future__ import absolute_import +import sys import os import tempfile import unittest @@ -25,7 +26,7 @@ # this has to be set before (most of) django is loaded or else # the imports crash with django.core.exceptions.ImproperlyConfigured -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'multisite.test_settings') if django.VERSION >= (1,7): # Django demands it. @@ -634,6 +635,8 @@ def test_create(self): domain=site1.domain, site=site1, is_canonical=False ) + # FIXME + @skipIf(sys.version_info.major == 3, "For some reason Django repr's this to under python3") def test_repr(self): site = Site.objects.create(domain='example.com') self.assertEqual(repr(Alias.objects.get(site=site)), @@ -850,7 +853,7 @@ def test_no_matched_cookies(self): self.assertEqual(self.middleware.match_cookies(request, response), []) cookies = self.middleware.process_response(request, response).cookies - self.assertEqual(cookies.values(), []) + self.assertEqual(list(cookies.values()), []) # Add some cookies with their domains already set response.set_cookie(key='a', value='a', domain='.example.org') @@ -858,7 +861,7 @@ def test_no_matched_cookies(self): self.assertEqual(self.middleware.match_cookies(request, response), []) cookies = self.middleware.process_response(request, response).cookies - self.assertEqual(cookies.values(), [cookies['a'], cookies['b']]) + self.assertEqual(list(cookies.values()), [cookies['a'], cookies['b']]) self.assertEqual(cookies['a']['domain'], '.example.org') self.assertEqual(cookies['b']['domain'], '.example.co.uk') @@ -870,7 +873,7 @@ def test_matched_cookies(self): [response.cookies['a']]) # No new cookies should be introduced cookies = self.middleware.process_response(request, response).cookies - self.assertEqual(cookies.values(), [cookies['a']]) + self.assertEqual(list(cookies.values()), [cookies['a']]) def test_ip_address(self): response = HttpResponse() diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 330e34b..300dbe6 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -2,6 +2,8 @@ from __future__ import unicode_literals from __future__ import absolute_import +import sys + from django.utils import six from contextlib import contextmanager from warnings import warn @@ -131,7 +133,11 @@ def __init__(self, default, *args, **kwargs): ``default`` is the default domain name, resolved to SITE_ID, if that is unset. """ - if not isinstance(default, basestring): + # make sure they passed us a string; doing this is the single hardest py2/py3 compat headache. + # http://python-future.org/compatible_idioms.html#basestring and + # https://github.com/PythonCharmers/python-future/blob/master/src/past/types/basestring.py + # are not super informative, so just fall back on a literal version check: + if not isinstance(default, basestring if sys.version_info.major == 2 else str): raise TypeError("%r is not a valid default domain." % default) self.default_domain = default self.default = None diff --git a/test_versions b/test_versions index 34707fc..3f1b460 100755 --- a/test_versions +++ b/test_versions @@ -19,6 +19,11 @@ for PYTHON in python2 python3; do . .venv-$PYTHON/bin/activate for DJANGO in 1.6 1.7 1.8 1.9 1.10; do + if [ $PYTHON == python3 -a \( $DJANGO = 1.6 -o $DJANGO = 1.7 \) ]; then + echo "Django-$DJANGO is unsupported on python3" + echo + continue + fi pip install django==$DJANGO echo $PYTHON/django$DJANGO if ! python setup.py test; then From f57be9cf6dbd5dcdd3185d2a50841e3c4c53ab4e Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 01:01:11 -0500 Subject: [PATCH 139/196] Update changelog I have decided that supporting python3 and making reliable tests is worth a full minor version bump. That's not to say that this version *is* the final 1.4. --- CHANGELOG.rst | 14 ++++++++++++++ setup.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ca6df79..97d1c69 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,31 +2,45 @@ Release Notes ============= +1.4.0 +----- + +* Support Django 1.10 (PR #38) +* Support Python 3 +* Use setuptools over distutils, and integrate the tests with them +* Package `test_versions` to help ensure old versions do not rot + 1.3.1 ----- + * Add default for SiteID in the README (PR #31) * Respect the CACHE_MULTISITE_ALIAS in SiteCache (PR #34) * Replace deprecated ExtractResult().tld with .suffic (PR #32) 1.3.0 ----- + * Fix tempfile issue with update_public_suffix_list command * Support for tldextract version >= 2.0 1.2.6 ---- + * Pin the tldextract dependency to version < 2.0, which breaks API. 1.2.5 ---- + * Make template loading more resilient to changes in django (thanks to jbazik for the contribution) 1.2.4 ----- + * Fix domain validation so it's called after the pre_save signal 1.2.3 ----- + * Fix a broken test, due to a django uniqueness constraint in 1.9 1.2.2 diff --git a/setup.py b/setup.py index c2e1037..e867bd7 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ def long_description(): setup(name='django-multisite', - version='1.3.1', + version='1.4.0', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', From 6f3302af2b64baeb64fe06d61280c559e39ca3df Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 12:05:29 -0500 Subject: [PATCH 140/196] README syntax --- README.rst | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index ef18776..02815bf 100644 --- a/README.rst +++ b/README.rst @@ -143,18 +143,17 @@ To run the tests:: python setup.py test -Before deploying a change, you should run:: +Before deploying a change, to verify it has not broken anything you should run:: test_versions -to verify it has not broken anything. This script runs the tests -under every supported combination of Django and Python, by creating -virtualenvs. If a test breaks, it will quit, leaving the virtualenv -intact in .venv-python2, or .venv-python3, depending on which space -it broke in. You can rerun the broken version manually with:: +This runs the tests under every supported combination of Django and Python, +isolated by creating virtualenvs. If a test breaks, it will quit, with the +virtualenv intact in .venv-python2, or .venv-python3, depending on what broke. +You can investigate the broken version manually with:: . .venv-python2/bin/activate # or .venv-python3 python setup.py test (of course, as new versions are supported and old are retired, - please keep test_versions up to date) +please keep test_versions up to date) From c03ae06390634a3b7d15ce29d2e3f83f7075d182 Mon Sep 17 00:00:00 2001 From: nguenthe Date: Wed, 25 Jan 2017 14:33:04 -0500 Subject: [PATCH 141/196] Remove years-dead tests In #39 we're going to be moving to a minimum of django-1.8 soon, but we haven't supported 1.5 for years now. It's time to kill these. --- multisite/tests.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 25523fe..cfe42fb 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -241,19 +241,6 @@ def test_testserver(self): self.assertEqual(self.middleware.process_request(request), None) self.assertEqual(settings.SITE_ID, site.pk) - @skipIf(django.VERSION >= (1, 5, 0), "Starting Django 1.5, there are no " - "function based views.") - def test_string_function(self): - # Function based - settings.MULTISITE_FALLBACK = 'django.views.generic.simple.redirect_to' - settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', - 'permanent': False} - request = self.factory.get('/') - response = self.middleware.process_request(request) - self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], - settings.MULTISITE_FALLBACK_KWARGS['url']) - def test_string_class(self): # Class based settings.MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView' @@ -265,19 +252,6 @@ def test_string_class(self): self.assertEqual(response['Location'], settings.MULTISITE_FALLBACK_KWARGS['url']) - @skipIf(django.VERSION >= (1, 5, 0), "Starting Django 1.5, there are no " - "function based views.") - def test_function_view(self): - from django.views.generic.simple import redirect_to - settings.MULTISITE_FALLBACK = redirect_to - settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', - 'permanent': False} - request = self.factory.get('/') - response = self.middleware.process_request(request) - self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], - settings.MULTISITE_FALLBACK_KWARGS['url']) - def test_class_view(self): from django.views.generic.base import RedirectView settings.MULTISITE_FALLBACK = RedirectView.as_view( From b435c13bcea7c362496fa882750864f2a36c64a2 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 15:22:49 -0500 Subject: [PATCH 142/196] Create middlewares per-test, to prevent accidental state leakage corrupting the test. This is in line with what Django does for their own middlewares: https://github.com/django/django/blob/c688336ebcc1bddc65f2d48e15b981b6caa7ef1a/tests/csrf_tests/tests.py#L73 --- multisite/tests.py | 87 +++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index cfe42fb..6c675e9 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -113,68 +113,66 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) - self.middleware = DynamicSiteMiddleware() - def tearDown(self): settings.SITE_ID.reset() def test_valid_domain(self): # Make the request request = self.factory.get('/') - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) # Request again - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) def test_valid_domain_port(self): # Make the request with a specific port request = self.factory.get('/', host=self.host + ':8000') - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) # Request again - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) def test_case_sensitivity(self): # Make the request in all uppercase request = self.factory.get('/', host=self.host.upper()) - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) def test_change_domain(self): # Make the request request = self.factory.get('/') - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) # Another request with a different site site2 = Site.objects.create(domain='anothersite.example') request = self.factory.get('/', host=site2.domain) - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, site2.pk) def test_unknown_host(self): # Unknown host request = self.factory.get('/', host='unknown') self.assertRaises(Http404, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) # Unknown host:port request = self.factory.get('/', host='unknown:8000') self.assertRaises(Http404, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) def test_invalid_host(self): # Invalid host request = self.factory.get('/', host='') self.assertRaises(SuspiciousOperation, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) # Invalid host:port request = self.factory.get('/', host=':8000') self.assertRaises(SuspiciousOperation, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) def test_no_sites(self): @@ -183,7 +181,7 @@ def test_no_sites(self): # Make the request request = self.factory.get('/') self.assertRaises(Http404, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) def test_redirect(self): @@ -192,7 +190,7 @@ def test_redirect(self): self.assertTrue(alias.redirect_to_canonical) # Make the request request = self.factory.get('/path', host=host) - response = self.middleware.process_request(request) + response = DynamicSiteMiddleware().process_request(request) self.assertEqual(response.status_code, 301) self.assertEqual(response['Location'], "http://%s/path" % self.host) @@ -203,7 +201,7 @@ def test_no_redirect(self): redirect_to_canonical=False) # Make the request request = self.factory.get('/path', host=host) - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) @@ -223,22 +221,20 @@ def setUp(self): Site.objects.all().delete() - self.middleware = DynamicSiteMiddleware() - def tearDown(self): settings.SITE_ID.reset() def test_404(self): request = self.factory.get('/') self.assertRaises(Http404, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) def test_testserver(self): host = 'testserver' site = Site.objects.create(domain=host) request = self.factory.get('/', host=host) - self.assertEqual(self.middleware.process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, site.pk) def test_string_class(self): @@ -247,7 +243,7 @@ def test_string_class(self): settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', 'permanent': False} request = self.factory.get('/') - response = self.middleware.process_request(request) + response = DynamicSiteMiddleware().process_request(request) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], settings.MULTISITE_FALLBACK_KWARGS['url']) @@ -258,7 +254,7 @@ def test_class_view(self): url='http://example.com/', permanent=False ) request = self.factory.get('/') - response = self.middleware.process_request(request) + response = DynamicSiteMiddleware().process_request(request) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], 'http://example.com/') @@ -266,7 +262,7 @@ def test_invalid(self): settings.MULTISITE_FALLBACK = '' request = self.factory.get('/') self.assertRaises(ImproperlyConfigured, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) @skipUnless(Site._meta.installed, @@ -293,25 +289,23 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) - self.middleware = DynamicSiteMiddleware() - def test_site_domain_changed(self): # Test to ensure that the cache is cleared properly - cache_key = self.middleware.get_cache_key(self.host) - self.assertEqual(self.middleware.cache.get(cache_key), None) + cache_key = DynamicSiteMiddleware().get_cache_key(self.host) + self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key), None) # Make the request request = self.factory.get('/') - self.assertEqual(self.middleware.process_request(request), None) - self.assertEqual(self.middleware.cache.get(cache_key).site_id, + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key).site_id, self.site.pk) # Change the domain name self.site.domain = 'example.org' self.site.save() - self.assertEqual(self.middleware.cache.get(cache_key), None) + self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key), None) # Make the request again, which will now be invalid request = self.factory.get('/') self.assertRaises(Http404, - self.middleware.process_request, request) + DynamicSiteMiddleware().process_request, request) self.assertEqual(settings.SITE_ID, 0) @@ -800,11 +794,10 @@ def test_resolve(self): class TestCookieDomainMiddleware(TestCase): def setUp(self): self.factory = RequestFactory(host='example.com') - self.middleware = CookieDomainMiddleware() def test_init(self): - self.assertEqual(self.middleware.depth, 0) - self.assertEqual(self.middleware.psl_cache, + self.assertEqual(CookieDomainMiddleware().depth, 0) + self.assertEqual(CookieDomainMiddleware().psl_cache, os.path.join(tempfile.gettempdir(), 'multisite_tld.dat')) @@ -824,17 +817,17 @@ def test_no_matched_cookies(self): # No cookies request = self.factory.get('/') response = HttpResponse() - self.assertEqual(self.middleware.match_cookies(request, response), + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), []) - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(list(cookies.values()), []) # Add some cookies with their domains already set response.set_cookie(key='a', value='a', domain='.example.org') response.set_cookie(key='b', value='b', domain='.example.co.uk') - self.assertEqual(self.middleware.match_cookies(request, response), + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), []) - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(list(cookies.values()), [cookies['a'], cookies['b']]) self.assertEqual(cookies['a']['domain'], '.example.org') self.assertEqual(cookies['b']['domain'], '.example.co.uk') @@ -843,10 +836,10 @@ def test_matched_cookies(self): request = self.factory.get('/') response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) - self.assertEqual(self.middleware.match_cookies(request, response), + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), [response.cookies['a']]) # No new cookies should be introduced - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(list(cookies.values()), [cookies['a']]) def test_ip_address(self): @@ -854,7 +847,7 @@ def test_ip_address(self): response.set_cookie(key='a', value='a', domain=None) # IP addresses should not be mutated request = self.factory.get('/', host='192.0.43.10') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') def test_localpath(self): @@ -862,11 +855,11 @@ def test_localpath(self): response.set_cookie(key='a', value='a', domain=None) # Local domains should not be mutated request = self.factory.get('/', host='localhost') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') # Even local subdomains request = self.factory.get('/', host='localhost.localdomain') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') def test_simple_tld(self): @@ -874,11 +867,11 @@ def test_simple_tld(self): response.set_cookie(key='a', value='a', domain=None) # Top-level domains shouldn't get mutated request = self.factory.get('/', host='ai') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') # Domains inside a TLD are OK request = self.factory.get('/', host='www.ai') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.www.ai') def test_effective_tld(self): @@ -886,11 +879,11 @@ def test_effective_tld(self): response.set_cookie(key='a', value='a', domain=None) # Effective top-level domains with a webserver shouldn't get mutated request = self.factory.get('/', host='com.ai') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') # Domains within an effective TLD are OK request = self.factory.get('/', host='nic.com.ai') - cookies = self.middleware.process_response(request, response).cookies + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.nic.com.ai') def test_subdomain_depth(self): From d778950bf5e31d0194f924e4d80344b3987a4386 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 15:47:03 -0500 Subject: [PATCH 143/196] Scrap these redundant tearDown methods. @override_settings already resets the settings when done. --- multisite/tests.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 6c675e9..9a286f9 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -113,9 +113,6 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) - def tearDown(self): - settings.SITE_ID.reset() - def test_valid_domain(self): # Make the request request = self.factory.get('/') @@ -221,9 +218,6 @@ def setUp(self): Site.objects.all().delete() - def tearDown(self): - settings.SITE_ID.reset() - def test_404(self): request = self.factory.get('/') self.assertRaises(Http404, @@ -935,7 +929,8 @@ def test_subdomain_depth(self): # These setUp/tearDown methods crimp the relevant lines from run_tests() # so that necessary cruft is in place before trying to run the tests. # -# Why doesn't django.test.TestCase do this in a {setUp,tearDown}Class()? +# Why doesn't django.test.TestCase do this in a {setUp,tearDown}Class(), +# but instead expects that you'll use their `manage.py test` runner? verbosity = 1 interactive = True From 960d7568eee97b9ba9deb0fc95dc6f9ae2628dbc Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 18:05:08 -0500 Subject: [PATCH 144/196] Test the loading of the extension via MIDDLEWARE/MIDDLEWARE_CLASSES Thanks to @JordanReiter for doing the critical legwork on this: https://github.com/ecometrica/django-multisite/pull/38/files --- multisite/test_settings.py | 34 ++++++++++++++++++++++++++++++++-- multisite/tests.py | 26 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 765a64b..0d9c599 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -1,5 +1,6 @@ import django +SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" DATABASES = { 'default': { @@ -13,7 +14,36 @@ 'multisite', ] -if django.VERSION[:2] < (1, 6): +from multisite import SiteID +SITE_ID = SiteID(default=1) + +MIDDLEWARE = [ + 'multisite.middleware.DynamicSiteMiddleware', +] +if django.VERSION < (1,10,0): + # we are backwards compatible, but the settings file format has changed post-1.10: + # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware + MIDDLEWARE_CLASSES = list(MIDDLEWARE) + del MIDDLEWARE + +# The cache connection to use for django-multisite. +# Default: 'default' +CACHE_MULTISITE_ALIAS = 'multisite' + +# The cache key prefix that django-multisite should use. +# Default: '' (Empty string) +CACHE_MULTISITE_KEY_PREFIX = '' + +CACHES = { + 'multisite': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 60 * 60 * 24, # 24 hours + }, +} + + +if django.VERSION < (1, 6): + # FIXME: is this still relevant? are we still supporting this? + # See https://github.com/ecometrica/django-multisite/issues/39 TEST_RUNNER = 'discover_runner.DiscoverRunner' -SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" \ No newline at end of file diff --git a/multisite/tests.py b/multisite/tests.py index 9a286f9..9ec2194 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -95,9 +95,21 @@ def test_get_current_site(self): self.assertEqual(current_site.id, settings.SITE_ID) +from django.http import HttpResponse +from django.conf.urls import url + +# Because we are a middleware package, we have no views available to test with easily +# So create one: +# (This is only used by test_integration) +urlpatterns = [ + url(r'^domain/$', lambda request, *args, **kwargs: HttpResponse(str(Site.objects.get_current()))) +] + @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( + ALLOWED_SITES=['*'], + ROOT_URLCONF=__name__, #this means that urlpatterns above is used when .get() is called below. SITE_ID=SiteID(default=0), CACHE_MULTISITE_ALIAS='multisite', CACHES={ @@ -173,6 +185,7 @@ def test_invalid_host(self): self.assertEqual(settings.SITE_ID, 0) def test_no_sites(self): + # FIXME: this needs to go into its own TestCase since it requires modifying the fixture to work properly # Remove all Sites Site.objects.all().delete() # Make the request @@ -201,6 +214,19 @@ def test_no_redirect(self): self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) + def test_integration(self): + """ + Test that the middleware loads and runs properly under settings.MIDDLEWARE. + """ + resp = self.client.get('/domain/', host='example.com') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, "example.com") + + resp = self.client.get('/domain/', host='anothersite.example') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, "anothersite.example") + + @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') From bf5b92e7ae560d0d42f9cd0dedb9228ae5e77684 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 18:18:37 -0500 Subject: [PATCH 145/196] Update documentation to match reality. --- INSTALL.txt | 10 ---------- README.rst | 19 ++++++++++++++++++- 2 files changed, 18 insertions(+), 11 deletions(-) delete mode 100644 INSTALL.txt diff --git a/INSTALL.txt b/INSTALL.txt deleted file mode 100644 index c63dc3f..0000000 --- a/INSTALL.txt +++ /dev/null @@ -1,10 +0,0 @@ -Thanks for downloading django-multisite. - -To install it, run the following command inside this directory: - - python setup.py install - -Or if you'd prefer you can simply place the included ``django-multisite`` -directory somewhere on your Python path, or symlink to it from -somewhere on your Python path; this is useful if you're working from a -Subversion checkout. diff --git a/README.rst b/README.rst index 02815bf..50104ef 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,15 @@ Get the code via git:: git clone git://github.com/ecometrica/django-multisite.git django-multisite -Add the django-multisite/multisite folder to your PYTHONPATH. +Run:: + + python setup.py install + +Or add the django-multisite/multisite folder to your PYTHONPATH. + +If you wish to contribute, instead run:: + + python setup.py develop Quickstart @@ -16,6 +24,15 @@ Replace your SITE_ID in settings.py to:: from multisite import SiteID SITE_ID = SiteID(default=1) +Add these to your INSTALLED_APPS:: + + INSTALLED_APPS = [ + ... + 'django.contrib.sites', + 'multisite', + ... + ] + Add to your settings.py TEMPLATES loaders in the OPTIONS section:: TEMPLATES = [ From e040172cad657acb21de58556c9a9bd9e0708cff Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 18:36:02 -0500 Subject: [PATCH 146/196] 960d7568eee97b9ba9deb0fc95dc6f9ae2628dbc wasn't quite working. --- multisite/tests.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 9ec2194..76a2261 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -124,6 +124,7 @@ def setUp(self): Site.objects.all().delete() self.site = Site.objects.create(domain=self.host) + self.site2 = Site.objects.create(domain='anothersite.example') def test_valid_domain(self): # Make the request @@ -155,10 +156,9 @@ def test_change_domain(self): self.assertEqual(DynamicSiteMiddleware().process_request(request), None) self.assertEqual(settings.SITE_ID, self.site.pk) # Another request with a different site - site2 = Site.objects.create(domain='anothersite.example') - request = self.factory.get('/', host=site2.domain) + request = self.factory.get('/', host=self.site2.domain) self.assertEqual(DynamicSiteMiddleware().process_request(request), None) - self.assertEqual(settings.SITE_ID, site2.pk) + self.assertEqual(settings.SITE_ID, self.site2.pk) def test_unknown_host(self): # Unknown host @@ -218,11 +218,11 @@ def test_integration(self): """ Test that the middleware loads and runs properly under settings.MIDDLEWARE. """ - resp = self.client.get('/domain/', host='example.com') + resp = self.client.get('/domain/', HTTP_HOST=self.host) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.content, "example.com") - resp = self.client.get('/domain/', host='anothersite.example') + resp = self.client.get('/domain/', HTTP_HOST=self.site2.domain) self.assertEqual(resp.status_code, 200) self.assertEqual(resp.content, "anothersite.example") From f54915e24188a870d92670f7af0e2951c4356839 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 18:48:31 -0500 Subject: [PATCH 147/196] Repair the caching test. b435c13bcea7c362496fa882750864f2a36c64a2 was too enthusiastic: avoiding all middleware state necessarily breaks a stateful thing like caching. So put it back, but constrained to the test. --- multisite/tests.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 76a2261..5c177ed 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -311,21 +311,22 @@ def setUp(self): def test_site_domain_changed(self): # Test to ensure that the cache is cleared properly - cache_key = DynamicSiteMiddleware().get_cache_key(self.host) - self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key), None) + middleware = DynamicSiteMiddleware() + cache_key = middleware.get_cache_key(self.host) + self.assertEqual(middleware.cache.get(cache_key), None) # Make the request request = self.factory.get('/') - self.assertEqual(DynamicSiteMiddleware().process_request(request), None) - self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key).site_id, + self.assertEqual(middleware.process_request(request), None) + self.assertEqual(middleware.cache.get(cache_key).site_id, self.site.pk) # Change the domain name self.site.domain = 'example.org' self.site.save() - self.assertEqual(DynamicSiteMiddleware().cache.get(cache_key), None) + self.assertEqual(middleware.cache.get(cache_key), None) # Make the request again, which will now be invalid request = self.factory.get('/') self.assertRaises(Http404, - DynamicSiteMiddleware().process_request, request) + middleware.process_request, request) self.assertEqual(settings.SITE_ID, 0) From 084e0a3c2cfb921825dd5b76c68314485b60c011 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 18:56:16 -0500 Subject: [PATCH 148/196] Explain why the tests are checking for SiteID == 0 I at first thought this was irrelevant, but then I realized there's [this sketchy line](https://github.com/ecometrica/django-multisite/blob/113dc87fcd69ecb86b8dd7a993a4eeff80989c2d/multisite/middleware.py#L189) which forces the SiteID to the default value on..some..errors. Except now test_invalid_host is broken because it's *not* hitting this line. Was it only working before by accident? --- multisite/tests.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 5c177ed..81b9e10 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -165,11 +165,15 @@ def test_unknown_host(self): request = self.factory.get('/', host='unknown') self.assertRaises(Http404, DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) + + def test_unknown_hostport(self): # Unknown host:port request = self.factory.get('/', host='unknown:8000') self.assertRaises(Http404, DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) def test_invalid_host(self): @@ -177,11 +181,16 @@ def test_invalid_host(self): request = self.factory.get('/', host='') self.assertRaises(SuspiciousOperation, DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) + + + def test_invalid_hostport(self): # Invalid host:port request = self.factory.get('/', host=':8000') self.assertRaises(SuspiciousOperation, DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) def test_no_sites(self): @@ -192,6 +201,7 @@ def test_no_sites(self): request = self.factory.get('/') self.assertRaises(Http404, DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) def test_redirect(self): @@ -220,11 +230,13 @@ def test_integration(self): """ resp = self.client.get('/domain/', HTTP_HOST=self.host) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.content, "example.com") + self.assertEqual(resp.content, self.site.domain) + self.assertEqual(settings.SITE_ID, self.site.pk) resp = self.client.get('/domain/', HTTP_HOST=self.site2.domain) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.content, "anothersite.example") + self.assertEqual(resp.content, self.site2.domain) + self.assertEqual(settings.SITE_ID, self.site2.pk) From b78a2a78c000e69fb81c1ebd93d64612b21f9933 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 19:11:15 -0500 Subject: [PATCH 149/196] Repair tests. I am not at all confident about this. After making the last batch of changes, these tests started failing. As far as I can tell, this test is expecting that an invalid hostname will trigger https://github.com/ecometrica/django-multisite/blob/113dc87fcd69ecb86b8dd7a993a4eeff80989c2d/multisite/middleware.py#L189 however the code there clearly does not flow like that: an invalid hostname fails with SuspiciousOperation at request.get_host(). Note too that before this recent batch of changes these tests were one single test; maybe that has something to do with why it didn't show up as obviously. --- multisite/tests.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 81b9e10..2b9c746 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -181,17 +181,12 @@ def test_invalid_host(self): request = self.factory.get('/', host='') self.assertRaises(SuspiciousOperation, DynamicSiteMiddleware().process_request, request) - # The middleware resets SiteID to its default value, as given above, on error. - self.assertEqual(settings.SITE_ID, 0) - def test_invalid_hostport(self): # Invalid host:port request = self.factory.get('/', host=':8000') self.assertRaises(SuspiciousOperation, DynamicSiteMiddleware().process_request, request) - # The middleware resets SiteID to its default value, as given above, on error. - self.assertEqual(settings.SITE_ID, 0) def test_no_sites(self): # FIXME: this needs to go into its own TestCase since it requires modifying the fixture to work properly From 562da70d084ffbdf7cc24ff39c35bbc190fcb064 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 19:19:39 -0500 Subject: [PATCH 150/196] These test settings are necessary to make the caching tests work. But, as noted, maybe it would be better to merge these entirely into @override_settings calls. --- multisite/test_settings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 0d9c599..780de1e 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -28,14 +28,15 @@ # The cache connection to use for django-multisite. # Default: 'default' -CACHE_MULTISITE_ALIAS = 'multisite' +CACHE_MULTISITE_ALIAS = 'default' # The cache key prefix that django-multisite should use. # Default: '' (Empty string) CACHE_MULTISITE_KEY_PREFIX = '' +# FIXME: made redundant by override_settings in some of the tests; this should be harmonized. CACHES = { - 'multisite': { + 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'TIMEOUT': 60 * 60 * 24, # 24 hours }, From b3206d607a9ff4422146deadffa2709010628694 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 19:20:14 -0500 Subject: [PATCH 151/196] Remove redundant and misleading third-assertEqual() argument The third argument overrides the error message, and it was just setting it equal to the first thing. It's much, much, much more useful to let unittest do its thing in its own way. --- multisite/tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 2b9c746..ace353a 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -440,8 +440,7 @@ def test_default_key_prefix(self): self.cache._cache._get_cache_key(self.site.id), 'sites.{}.{}'.format( settings.CACHES['default']['KEY_PREFIX'], self.site.id - ), - self.cache._cache._get_cache_key(self.site.id) + ) ) @override_settings( From d15555bf3c13df090db4ec5b9b3565447daddb66 Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Mon, 6 Feb 2017 19:58:58 -0500 Subject: [PATCH 152/196] Weakly patch over a setting that broke one of our tests. The reason this is here at all, by the way, is that I copied the instructions from our own README into our own settings.py, because you'd hope that the settings we're telling people to use would actually be tested. --- multisite/test_settings.py | 8 +++++--- multisite/tests.py | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 780de1e..75a0f41 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -30,9 +30,11 @@ # Default: 'default' CACHE_MULTISITE_ALIAS = 'default' -# The cache key prefix that django-multisite should use. -# Default: '' (Empty string) -CACHE_MULTISITE_KEY_PREFIX = '' +# FIXME: this breaks test_default_key_prefix +# see https://github.com/ecometrica/django-multisite/issues/43 +## The cache key prefix that django-multisite should use. +## Default: '' (Empty string) +#CACHE_MULTISITE_KEY_PREFIX = '' # FIXME: made redundant by override_settings in some of the tests; this should be harmonized. CACHES = { diff --git a/multisite/tests.py b/multisite/tests.py index ace353a..822d19e 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -432,6 +432,10 @@ def test_multisite_key_prefix(self): }} ) def test_default_key_prefix(self): + """ + If CACHE_MULTISITE_KEY_PREFIX is undefined, + the caching system should use CACHES[current]['KEY_PREFIX']. + """ self._initialize_cache() # Populate cache self.assertEqual(Site.objects.get_current(), self.site) From 7452c20d785bdffcd93d447ad3f6cc133fc8992e Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Mon, 23 Jan 2017 10:21:43 -0500 Subject: [PATCH 153/196] Changes for Django 1.10+ compatibility Backwards incompatible changes to Django 1.10 require Middleware classes to either be rewritten or to inherit from django.utils.deprecation.MiddlewareMixin. Code is rewritten to maintain compatibility with previous versions while working for Django 1.10+. --- multisite/middleware.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index e0b379d..fce6feb 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -23,6 +23,12 @@ def get_cache(cache_alias): return caches[cache_alias] +try: + # Django > 1.10 uses MiddlewareMixin + from django.utils.deprecation import MiddlewareMixin +except ImportError: + MiddlewareMixin = object + from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import get_callable from django.db.models.signals import pre_save, post_delete, post_init @@ -38,8 +44,9 @@ def get_cache(cache_alias): from .models import Alias -class DynamicSiteMiddleware(object): - def __init__(self): +class DynamicSiteMiddleware(MiddlewareMixin): + def __init__(self, *args, **kwargs): + super(DynamicSiteMiddleware, self).__init__(*args, **kwargs) if not hasattr(settings.SITE_ID, 'set'): raise TypeError('Invalid type for settings.SITE_ID: %s' % type(settings.SITE_ID).__name__) From 4b6e3090c219bb27c7ffc7355c8ec253b355d06c Mon Sep 17 00:00:00 2001 From: Nick Guenther Date: Tue, 7 Feb 2017 01:10:13 -0500 Subject: [PATCH 154/196] Use a special testing prefix for the caches, to avoid breaking prod. While here, make its tests more basic and explicit and obvious. "Caches are not cleared after each test, and can insert data into the cache of a live system if you run your tests in production" - https://docs.djangoproject.com/en/1.10/topics/testing/overview/#other-test-conditions --- multisite/test_settings.py | 9 ++++----- multisite/tests.py | 23 ++++------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 75a0f41..a8e1e41 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -28,19 +28,18 @@ # The cache connection to use for django-multisite. # Default: 'default' -CACHE_MULTISITE_ALIAS = 'default' +CACHE_MULTISITE_ALIAS = 'test_multisite' -# FIXME: this breaks test_default_key_prefix -# see https://github.com/ecometrica/django-multisite/issues/43 ## The cache key prefix that django-multisite should use. -## Default: '' (Empty string) +# This has to be unset for the tests as currently written to test it. #CACHE_MULTISITE_KEY_PREFIX = '' # FIXME: made redundant by override_settings in some of the tests; this should be harmonized. CACHES = { - 'default': { + CACHE_MULTISITE_ALIAS: { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'TIMEOUT': 60 * 60 * 24, # 24 hours + 'KEY_PREFIX': 'looselycoupled', }, } diff --git a/multisite/tests.py b/multisite/tests.py index 822d19e..527535b 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -425,12 +425,6 @@ def test_multisite_key_prefix(self): self.cache._cache._get_cache_key(self.site.id) ) - @override_settings( - CACHES={'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'KEY_PREFIX': 'test1' - }} - ) def test_default_key_prefix(self): """ If CACHE_MULTISITE_KEY_PREFIX is undefined, @@ -442,18 +436,12 @@ def test_default_key_prefix(self): self.assertEqual(self.cache[self.site.id], self.site) self.assertEqual( self.cache._cache._get_cache_key(self.site.id), - 'sites.{}.{}'.format( - settings.CACHES['default']['KEY_PREFIX'], self.site.id - ) + "sites.looselycoupled.2", # FIXME: this 2 is not stable ) @override_settings( - CACHE_MULTISITE_KEY_PREFIX="__test__", - CACHES={'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'KEY_PREFIX': 'test1' - }} - ) + CACHE_MULTISITE_KEY_PREFIX="virtuouslyvirtual", + ) def test_multisite_key_prefix_takes_priority_over_default(self): self._initialize_cache() # Populate cache @@ -461,10 +449,7 @@ def test_multisite_key_prefix_takes_priority_over_default(self): self.assertEqual(self.cache[self.site.id], self.site) self.assertEqual( self.cache._cache._get_cache_key(self.site.id), - 'sites.{}.{}'.format( - settings.CACHE_MULTISITE_KEY_PREFIX, self.site.id - ), - self.cache._cache._get_cache_key(self.site.id) + "sites.virtuouslyvirtual.2", # FIXME: this 2 is not stable ) From 0788dc53a3e8cc3f8c572d60625a2102f12ce652 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 15:38:38 +0100 Subject: [PATCH 155/196] Run tests with pytest --- multisite/tests.py | 88 +++++++++------------------------------------- pytest.ini | 6 ++++ setup.cfg | 2 ++ setup.py | 2 ++ 4 files changed, 27 insertions(+), 71 deletions(-) create mode 100644 pytest.ini create mode 100644 setup.cfg diff --git a/multisite/tests.py b/multisite/tests.py index 527535b..cc6595c 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -13,44 +13,21 @@ from __future__ import unicode_literals from __future__ import absolute_import +import pytest import sys import os import tempfile -import unittest from unittest import skipUnless, skipIf import warnings - -import django from django.conf import settings -# this has to be set before (most of) django is loaded or else -# the imports crash with django.core.exceptions.ImproperlyConfigured -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'multisite.test_settings') - -if django.VERSION >= (1,7): - # Django demands it. - # You *will* comply. - django.setup() - from django.contrib.sites.models import Site from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, ValidationError) from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory -from django.test.utils import setup_test_environment, teardown_test_environment -from django.test.runner import setup_databases -from django.test.runner import DiscoverRunner -def teardown_databases(old_config, verbosity): - """ - Wrap DiscoverRunner.teardown_databases() to a first-class function, - like its partner setup_databases() - """ - # The only time teardown_databases() speaks to self is to get - # settings: verbosity, interactive, keepdb, etc - # and we can fake that with a mock object. - return DiscoverRunner(verbosity=verbosity, interactive=interactive).teardown_databases(old_config) from .hacks import use_framework_for_site_cache @@ -60,6 +37,7 @@ def teardown_databases(old_config, verbosity): from override_settings import override_settings from . import SiteDomain, SiteID, threadlocals +from .hosts import ALLOWED_HOSTS from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias from .threadlocals import SiteIDHook @@ -76,7 +54,7 @@ def get(self, path, data={}, host=None, **extra): return super(RequestFactory, self).get(path=path, data=data, HTTP_HOST=host, **extra) - +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( @@ -105,6 +83,7 @@ def test_get_current_site(self): url(r'^domain/$', lambda request, *args, **kwargs: HttpResponse(str(Site.objects.get_current()))) ] +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( @@ -234,7 +213,7 @@ def test_integration(self): self.assertEqual(settings.SITE_ID, self.site2.pk) - +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings( @@ -292,6 +271,7 @@ def test_invalid(self): DynamicSiteMiddleware().process_request, request) +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings(SITE_ID=0,) @@ -300,6 +280,7 @@ def test_invalid_settings(self): self.assertRaises(TypeError, DynamicSiteMiddleware) +@pytest.mark.django_db @override_settings( SITE_ID=SiteID(default=0), CACHE_MULTISITE_ALIAS='multisite', @@ -337,6 +318,7 @@ def test_site_domain_changed(self): self.assertEqual(settings.SITE_ID, 0) +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') @override_settings(SITE_ID=SiteID(),) @@ -436,7 +418,7 @@ def test_default_key_prefix(self): self.assertEqual(self.cache[self.site.id], self.site) self.assertEqual( self.cache._cache._get_cache_key(self.site.id), - "sites.looselycoupled.2", # FIXME: this 2 is not stable + "sites.looselycoupled.{}".format(self.site.id) ) @override_settings( @@ -449,10 +431,11 @@ def test_multisite_key_prefix_takes_priority_over_default(self): self.assertEqual(self.cache[self.site.id], self.site) self.assertEqual( self.cache._cache._get_cache_key(self.site.id), - "sites.virtuouslyvirtual.2", # FIXME: this 2 is not stable + "sites.virtuouslyvirtual.{}".format(self.site.id) ) +@pytest.mark.django_db class TestSiteID(TestCase): def setUp(self): Site.objects.all().delete() @@ -527,6 +510,7 @@ def test_context_manager(self): self.assertEqual(self.site_id.site_id, None) +@pytest.mark.django_db @skipUnless(Site._meta.installed, 'django.contrib.sites is not in settings.INSTALLED_APPS') class TestSiteDomain(TestCase): @@ -551,6 +535,7 @@ def test_deferred_site(self): site.id) +@pytest.mark.django_db class TestSiteIDHook(TestCase): def test_deprecation_warning(self): with warnings.catch_warnings(record=True) as w: @@ -568,6 +553,7 @@ def test_default_value(self): self.assertEqual(int(site_id), 1) +@pytest.mark.django_db class AliasTest(TestCase): def setUp(self): Alias.objects.all().delete() @@ -803,11 +789,14 @@ def test_resolve(self): alias) + +@pytest.mark.django_db @override_settings( MULTISITE_COOKIE_DOMAIN_DEPTH=0, MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, ) class TestCookieDomainMiddleware(TestCase): + def setUp(self): self.factory = RequestFactory(host='example.com') @@ -925,46 +914,3 @@ def test_subdomain_depth(self): request = self.factory.get('/', host='www.us.app.example.com') cookies = middleware.process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.app.example.com') - - - -# Run tests with the necessary Django-global fixtures in place. -# This mimics what `django manage.py test` does. -# -# Long story: Django screwed up. -# They put fixture-ish code ({setup,teardown}_{test_environment,databases}()) -# into their test runner (django.test.runner.DiscoverRunner.run_tests(test_labels, extra_tests=None), -# where test_labels is a list of strings naming the tests to load -# *but can be None to mean 'all tests recursively'*,and extra_tests -# is a TestSuite, if given) and then failed to make it API-compatible -# with unittest's design (.run(tests), -# where tests is a single TestCase, or a TestSuite) -# which means `python setup.py test` can't use it as a test_runner, -# even if the setuptools people had documented clearly how to (which -# they haven't: https://packaging.python.org/distributing/#setup-args ? -# https://setuptools.readthedocs.io/en/latest/setuptools.html#test-build-package-and-run-a-unittest-suite ?) -# -# They screwed up so bad that someone went ahead and wrote an entire -# Django plugin just -# so they could say `python setup.py test`. -# -# These setUp/tearDown methods crimp the relevant lines from run_tests() -# so that necessary cruft is in place before trying to run the tests. -# -# Why doesn't django.test.TestCase do this in a {setUp,tearDown}Class(), -# but instead expects that you'll use their `manage.py test` runner? - -verbosity = 1 -interactive = True - -def setUpModule(): - global db - setup_test_environment() - db = setup_databases(verbosity, interactive) - -def tearDownModule(): - teardown_databases(db, verbosity) - teardown_test_environment() - -if __name__ == '__main__': - unittest.main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e812a41 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +django_find_project = false +DJANGO_SETTINGS_MODULE = multisite.test_settings +python_files = tests.py test_*.py *_tests.pyc +addopts = --reuse-db +python_paths = multisite diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9af7e6f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[aliases] +test=pytest \ No newline at end of file diff --git a/setup.py b/setup.py index e867bd7..47a54ad 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,8 @@ def long_description(): 'multisite.template.loaders'], install_requires=['Django>=1.6', 'tldextract>=1.2'], + setup_requires=['pytest-runner'], + tests_require=['pytest', 'pytest-django'], test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', From 93410a2656ef170bc0ec611ae920dea8db9d539d Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 15:39:05 +0100 Subject: [PATCH 156/196] Add explicit __repr__ method and __str__ for python2/3 compatibility --- multisite/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/multisite/models.py b/multisite/models.py index 85320da..878092c 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -15,6 +15,7 @@ except ImportError: # Django < 1.7 compatibility from django.db.models.signals import post_syncdb as post_migrate +from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from .hacks import use_framework_for_site_cache @@ -149,6 +150,7 @@ def validate_true_or_none(value): raise ValidationError(u'%r must be True or None' % value) +@python_2_unicode_compatible class Alias(models.Model): """ Model for domain-name aliases for Site objects. @@ -185,9 +187,12 @@ class Meta: unique_together = [('is_canonical', 'site')] verbose_name_plural = _('aliases') - def __unicode__(self): + def __str__(self): return "%s -> %s" % (self.domain, self.site.domain) + def __repr__(self): + return '' % str(self) + def save_base(self, *args, **kwargs): self.full_clean() # For canonical Alias, domains must match Site domains. From 550213cc75a06a7c35a26cfb9292508cd4149ce1 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 15:40:28 +0100 Subject: [PATCH 157/196] Return multisite fallback for hosts not in ALLOWED_HOSTS Since version 1.11, Django has become more insistent about checking ALLOWED_HOSTS including in tests. --- multisite/middleware.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index fce6feb..68c20a7 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -12,6 +12,7 @@ import django from django.conf import settings from django.contrib.sites.models import Site, SITE_CACHE +from django.core.exceptions import DisallowedHost from django.core import mail try: @@ -178,7 +179,12 @@ def redirect_to_canonical(self, request, alias): return HttpResponsePermanentRedirect(url) def process_request(self, request): - netloc = request.get_host().lower() + try: + netloc = request.get_host().lower() + except DisallowedHost: + settings.SITE_ID.reset() + return self.fallback_view(request) + cache_key = self.get_cache_key(netloc) # Find the Alias in the cache From d1db2c8ecac2e3d50e1a3042f88a63685e262439 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 17:22:54 +0100 Subject: [PATCH 158/196] Support multiple Python and Django versions; setup tox and travis to test versions --- .gitignore | 4 + .travis.yml | 12 ++ CHANGELOG.rst | 5 +- MANIFEST.in | 1 + README.rst | 38 ++-- multisite/middleware.py | 8 +- multisite/test_settings.py | 31 +++- .../test_templates/example.com/example.html | 1 + .../multisite_templates/test.html | 1 + multisite/tests.py | 166 +++++++++++++----- setup.py | 19 +- test_versions | 36 ---- tox.ini | 28 +++ 13 files changed, 235 insertions(+), 115 deletions(-) create mode 100644 .travis.yml create mode 100644 multisite/test_templates/example.com/example.html create mode 100644 multisite/test_templates/multisite_templates/test.html delete mode 100755 test_versions create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index 85a1fe4..1a55763 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ *.pyc *.pyo +.cache/ +.eggs/ .installed.cfg bin develop-eggs +django_multisite.egg-info/ dist downloads eggs parts MANIFEST multisite/*.egg-info +.tox/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f571973 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +sudo: false +language: python +python: + - "2.7" + - "3.3" + - "3.4" + - "3.5" + - "3.6" + +install: + - pip install tox-travis +script: tox diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 97d1c69..6b5b1f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,10 +5,11 @@ Release Notes 1.4.0 ----- -* Support Django 1.10 (PR #38) +* Support Django 1.10 (PR #38) and 1.11 * Support Python 3 * Use setuptools over distutils, and integrate the tests with them -* Package `test_versions` to help ensure old versions do not rot +* Use pytest and tox for testing +* Set up CI with travis 1.3.1 ----- diff --git a/MANIFEST.in b/MANIFEST.in index fd52153..7b05902 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include INSTALL.txt include README.rst +graft multisite diff --git a/README.rst b/README.rst index 50104ef..ff33617 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,6 @@ +.. image:: https://travis-ci.org/rebkwok/django-multisite.svg?branch=master + :target: https://travis-ci.org/rebkwok/django-multisite + README ====== @@ -39,9 +42,10 @@ Add to your settings.py TEMPLATES loaders in the OPTIONS section:: ... { ... + 'DIRS': {...} 'OPTIONS': { 'loaders': ( - 'multisite.template_loader.Loader', + 'multisite.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ) } @@ -53,7 +57,7 @@ Add to your settings.py TEMPLATES loaders in the OPTIONS section:: Or for Django 1.7 and earlier, add to settings.py TEMPLATES_LOADERS:: TEMPLATE_LOADERS = ( - 'multisite.template_loader.Loader', + 'multisite.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ) @@ -109,10 +113,21 @@ settings.py:: MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', 'permanent': False} -Create a directory settings.TEMPLATE_DIRS directory with the names of +Templates +--------- +If required, create template subdirectories for domain level templates (in a +location specified in settings.TEMPLATES['DIRS'], or in settings.TEMPLATE_DIRS +for Django <=1.7). + +Multisite's template loader will look for templates in folders with the names of domains, such as:: - mkdir templates/example.com + templates/example.com + + +The template loader will also look for templates in a folder specified by the +optional MULTISITE_DEFAULT_TEMPLATE_DIR setting, e.g.:: + templates/multisite_templates Cross-domain cookies @@ -160,17 +175,12 @@ To run the tests:: python setup.py test -Before deploying a change, to verify it has not broken anything you should run:: +Or:: - test_versions + pytest -This runs the tests under every supported combination of Django and Python, -isolated by creating virtualenvs. If a test breaks, it will quit, with the -virtualenv intact in .venv-python2, or .venv-python3, depending on what broke. -You can investigate the broken version manually with:: +Before deploying a change, to verify it has not broken anything by running:: - . .venv-python2/bin/activate # or .venv-python3 - python setup.py test + tox -(of course, as new versions are supported and old are retired, -please keep test_versions up to date) +This runs the tests under every supported combination of Django and Python. \ No newline at end of file diff --git a/multisite/middleware.py b/multisite/middleware.py index 68c20a7..6e5bcbc 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -31,7 +31,13 @@ def get_cache(cache_alias): MiddlewareMixin = object from django.core.exceptions import ImproperlyConfigured -from django.core.urlresolvers import get_callable + +try: + from django.urls import get_callable +except ImportError: + # Django < 1.10 compatibility + from django.core.urlresolvers import get_callable + from django.db.models.signals import pre_save, post_delete, post_init from django.http import Http404, HttpResponsePermanentRedirect diff --git a/multisite/test_settings.py b/multisite/test_settings.py index a8e1e41..676a602 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -1,4 +1,6 @@ import django +import os +from multisite import SiteID SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" @@ -14,7 +16,7 @@ 'multisite', ] -from multisite import SiteID + SITE_ID = SiteID(default=1) MIDDLEWARE = [ @@ -34,8 +36,8 @@ # This has to be unset for the tests as currently written to test it. #CACHE_MULTISITE_KEY_PREFIX = '' -# FIXME: made redundant by override_settings in some of the tests; this should be harmonized. CACHES = { + 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, # required for Django <= 1.8 CACHE_MULTISITE_ALIAS: { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'TIMEOUT': 60 * 60 * 24, # 24 hours @@ -44,8 +46,25 @@ } -if django.VERSION < (1, 6): - # FIXME: is this still relevant? are we still supporting this? - # See https://github.com/ecometrica/django-multisite/issues/39 - TEST_RUNNER = 'discover_runner.DiscoverRunner' +if django.VERSION < (1, 8): + TEMPLATE_LOADERS = ['multisite.template.loaders.filesystem.Loader'] + TEMPLATE_DIRS = [os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates')] +else: + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates') + ], + 'OPTIONS': { + 'loaders': [ + 'multisite.template.loaders.filesystem.Loader', + ] + }, + } +] + +TEST_RUNNER = 'django.test.runner.DiscoverRunner' diff --git a/multisite/test_templates/example.com/example.html b/multisite/test_templates/example.com/example.html new file mode 100644 index 0000000..0566fcb --- /dev/null +++ b/multisite/test_templates/example.com/example.html @@ -0,0 +1 @@ +Test example.com template \ No newline at end of file diff --git a/multisite/test_templates/multisite_templates/test.html b/multisite/test_templates/multisite_templates/test.html new file mode 100644 index 0000000..4d92dbe --- /dev/null +++ b/multisite/test_templates/multisite_templates/test.html @@ -0,0 +1 @@ +Test! \ No newline at end of file diff --git a/multisite/tests.py b/multisite/tests.py index cc6595c..6a8e4eb 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -13,19 +13,20 @@ from __future__ import unicode_literals from __future__ import absolute_import +import django import pytest import sys import os import tempfile -from unittest import skipUnless, skipIf +from unittest import skipUnless import warnings from django.conf import settings from django.contrib.sites.models import Site -from django.core.exceptions import (ImproperlyConfigured, SuspiciousOperation, - ValidationError) -from django.http import Http404, HttpResponse +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.http import Http404 +from django.template.loader import get_template from django.test import TestCase from django.test.client import RequestFactory as DjangoRequestFactory @@ -36,7 +37,7 @@ except ImportError: from override_settings import override_settings -from . import SiteDomain, SiteID, threadlocals +from multisite import SiteDomain, SiteID, threadlocals from .hosts import ALLOWED_HOSTS from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias @@ -95,6 +96,7 @@ def test_get_current_site(self): 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} }, MULTISITE_FALLBACK=None, + ALLOWED_HOSTS=ALLOWED_HOSTS ) class DynamicSiteMiddlewareTest(TestCase): def setUp(self): @@ -142,30 +144,30 @@ def test_change_domain(self): def test_unknown_host(self): # Unknown host request = self.factory.get('/', host='unknown') - self.assertRaises(Http404, - DynamicSiteMiddleware().process_request, request) + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) def test_unknown_hostport(self): # Unknown host:port request = self.factory.get('/', host='unknown:8000') - self.assertRaises(Http404, - DynamicSiteMiddleware().process_request, request) + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) # The middleware resets SiteID to its default value, as given above, on error. self.assertEqual(settings.SITE_ID, 0) def test_invalid_host(self): # Invalid host request = self.factory.get('/', host='') - self.assertRaises(SuspiciousOperation, - DynamicSiteMiddleware().process_request, request) + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) def test_invalid_hostport(self): # Invalid host:port request = self.factory.get('/', host=':8000') - self.assertRaises(SuspiciousOperation, - DynamicSiteMiddleware().process_request, request) + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) def test_no_sites(self): # FIXME: this needs to go into its own TestCase since it requires modifying the fixture to work properly @@ -204,12 +206,12 @@ def test_integration(self): """ resp = self.client.get('/domain/', HTTP_HOST=self.host) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.content, self.site.domain) + self.assertContains(resp, self.site.domain) self.assertEqual(settings.SITE_ID, self.site.pk) resp = self.client.get('/domain/', HTTP_HOST=self.site2.domain) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.content, self.site2.domain) + self.assertContains(resp, self.site2.domain) self.assertEqual(settings.SITE_ID, self.site2.pk) @@ -288,6 +290,7 @@ def test_invalid_settings(self): 'multisite': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} }, MULTISITE_FALLBACK=None, + ALLOWED_HOSTS=ALLOWED_HOSTS ) class CacheTest(TestCase): def setUp(self): @@ -605,8 +608,6 @@ def test_create(self): domain=site1.domain, site=site1, is_canonical=False ) - # FIXME - @skipIf(sys.version_info.major == 3, "For some reason Django repr's this to under python3") def test_repr(self): site = Site.objects.create(domain='example.com') self.assertEqual(repr(Alias.objects.get(site=site)), @@ -794,11 +795,19 @@ def test_resolve(self): @override_settings( MULTISITE_COOKIE_DOMAIN_DEPTH=0, MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, + ALLOWED_HOSTS=ALLOWED_HOSTS ) class TestCookieDomainMiddleware(TestCase): def setUp(self): self.factory = RequestFactory(host='example.com') + Site.objects.all().delete() + # create sites so we populate ALLOWED_HOSTS + Site.objects.create(domain='example.com') + Site.objects.create(domain='test.example.com') + Site.objects.create(domain='app.test1.example.com') + Site.objects.create(domain='app.test2.example.com') + Site.objects.create(domain='new.app.test3.example.com') def test_init(self): self.assertEqual(CookieDomainMiddleware().depth, 0) @@ -833,7 +842,15 @@ def test_no_matched_cookies(self): self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), []) cookies = CookieDomainMiddleware().process_response(request, response).cookies - self.assertEqual(list(cookies.values()), [cookies['a'], cookies['b']]) + + if sys.version_info.major < 3: # for testing under Python 2.X + self.assertItemsEqual( + list(cookies.values()), [cookies['a'], cookies['b']] + ) + else: + self.assertCountEqual( + list(cookies.values()), [cookies['a'], cookies['b']] + ) self.assertEqual(cookies['a']['domain'], '.example.org') self.assertEqual(cookies['b']['domain'], '.example.co.uk') @@ -850,51 +867,69 @@ def test_matched_cookies(self): def test_ip_address(self): response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) + allowed = [host for host in ALLOWED_HOSTS] + ['192.0.43.10'] # IP addresses should not be mutated - request = self.factory.get('/', host='192.0.43.10') - cookies = CookieDomainMiddleware().process_response(request, response).cookies + with override_settings(ALLOWED_HOSTS=allowed): + request = self.factory.get('/', host='192.0.43.10') + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') def test_localpath(self): response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) - # Local domains should not be mutated - request = self.factory.get('/', host='localhost') - cookies = CookieDomainMiddleware().process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '') - # Even local subdomains - request = self.factory.get('/', host='localhost.localdomain') - cookies = CookieDomainMiddleware().process_response(request, response).cookies + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['localhost', 'localhost.localdomain'] + with override_settings(ALLOWED_HOSTS=allowed): + # Local domains should not be mutated + request = self.factory.get('/', host='localhost') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Even local subdomains + request = self.factory.get('/', host='localhost.localdomain') + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') def test_simple_tld(self): response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) - # Top-level domains shouldn't get mutated - request = self.factory.get('/', host='ai') - cookies = CookieDomainMiddleware().process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '') - # Domains inside a TLD are OK - request = self.factory.get('/', host='www.ai') - cookies = CookieDomainMiddleware().process_response(request, response).cookies + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['ai', 'www.ai'] + with override_settings(ALLOWED_HOSTS=allowed): + # Top-level domains shouldn't get mutated + request = self.factory.get('/', host='ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains inside a TLD are OK + request = self.factory.get('/', host='www.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.www.ai') def test_effective_tld(self): response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) - # Effective top-level domains with a webserver shouldn't get mutated - request = self.factory.get('/', host='com.ai') - cookies = CookieDomainMiddleware().process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '') - # Domains within an effective TLD are OK - request = self.factory.get('/', host='nic.com.ai') - cookies = CookieDomainMiddleware().process_response(request, response).cookies + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['com.ai', 'nic.com.ai'] + with override_settings(ALLOWED_HOSTS=allowed): + # Effective top-level domains with a webserver shouldn't get mutated + request = self.factory.get('/', host='com.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains within an effective TLD are OK + request = self.factory.get('/', host='nic.com.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.nic.com.ai') def test_subdomain_depth(self): response = HttpResponse() response.set_cookie(key='a', value='a', domain=None) - with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=1): + + allowed = [host for host in ALLOWED_HOSTS] + ['com'] + with override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=1, ALLOWED_HOSTS=allowed + ): # At depth 1: middleware = CookieDomainMiddleware() # Top-level domains are ignored @@ -906,11 +941,48 @@ def test_subdomain_depth(self): cookies = middleware.process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '') # But subdomains will get matched - request = self.factory.get('/', host='www.example.com') + request = self.factory.get('/', host='test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.test.example.com') + # And sub-subdomains will get matched to 1 level deep + cookies['a']['domain'] = '' + request = self.factory.get('/', host='app.test1.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.test1.example.com') + + def test_subdomain_depth_2(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=2): + # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to + # 2 levels deep + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='app.test2.example.com') cookies = middleware.process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '.www.example.com') - # And sub-subdomains will get matched + self.assertEqual(cookies['a']['domain'], '.app.test2.example.com') cookies['a']['domain'] = '' - request = self.factory.get('/', host='www.us.app.example.com') + request = self.factory.get('/', host='new.app.test3.example.com') cookies = middleware.process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '.app.example.com') + self.assertEqual(cookies['a']['domain'], '.app.test3.example.com') + + +@override_settings(MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates') +class TemplateLoaderTests(TestCase): + + def test_get_template_multisite_default_dir(self): + template = get_template("test.html") + if django.VERSION < (1, 8): # <1.7 render() requires Context instance + from django.template.context import Context + self.assertEqual(template.render(context=Context()), "Test!") + else: + self.assertEqual(template.render(), "Test!") + + def test_domain_template(self): + template = get_template("example.html") + if django.VERSION < (1, 8): # <1.7 render() requires Context instance + from django.template.context import Context + self.assertEqual( + template.render(context=Context()), "Test example.com template" + ) + else: + self.assertEqual(template.render(), "Test example.com template") diff --git a/setup.py b/setup.py index 47a54ad..11d4cb3 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup +from setuptools import find_packages, setup import os _dir_ = os.path.dirname(__file__) @@ -10,6 +10,8 @@ def long_description(): return f.read() +files = ["multisite/test_templates/*"] + setup(name='django-multisite', version='1.4.0', description='Serve multiple sites from a single Django application', @@ -19,16 +21,13 @@ def long_description(): maintainer='Ecometrica', maintainer_email='dev@ecometrica.com', url='http://github.com/ecometrica/django-multisite', - packages=['multisite', - 'multisite.management', - 'multisite.management.commands', - 'multisite.migrations', - 'multisite.template', - 'multisite.template.loaders'], + packages=find_packages(), + include_package_data=True, + package_data={'multisite': files}, install_requires=['Django>=1.6', 'tldextract>=1.2'], setup_requires=['pytest-runner'], - tests_require=['pytest', 'pytest-django'], + tests_require=['pytest', 'pytest-django', 'pytest-pythonpath', 'tox'], test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', @@ -37,9 +36,11 @@ def long_description(): 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries', diff --git a/test_versions b/test_versions deleted file mode 100755 index 3f1b460..0000000 --- a/test_versions +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh -# systematically run tests under all combinations -# of pythons and djangos that we support. - -# ensure we are running in a known location: -# the location of the current file -cd $(dirname $0) - -for PYTHON in python2 python3; do - # *nuke* the virtualenv, if it exists - if [ -e .venv-$PYTHON ]; then - rm -r .venv-$PYTHON; - fi - virtualenv -p $PYTHON .venv-$PYTHON - - echo - echo ">>> Switched to $PYTHON <<<" - echo - - . .venv-$PYTHON/bin/activate - for DJANGO in 1.6 1.7 1.8 1.9 1.10; do - if [ $PYTHON == python3 -a \( $DJANGO = 1.6 -o $DJANGO = 1.7 \) ]; then - echo "Django-$DJANGO is unsupported on python3" - echo - continue - fi - pip install django==$DJANGO - echo $PYTHON/django$DJANGO - if ! python setup.py test; then - echo - echo "Failed under $PYTHON and django-$DJANGO" - exit 1 - fi - done - deactivate -done diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6d96b0e --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +# Tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +setenv= + PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} +usedevelop = True +envlist = + py36-django{1.11} + py35-django{1.11,1.10,1.9,1.8} + py34-django{1.11,1.10,1.9,1.8,1.7} + py33-django{1.8,1.7} + py27-django{1.11,1.10,1.9,1.8,1.7} + +[testenv] +commands = pytest -s --pyargs multisite +deps = + pytest + pytest-django + pytest-pythonpath + + django1.11: Django==1.11 + django1.10: Django>=1.10,<1.11 + django1.9: Django>=1.9,<1.10 + django1.8: Django>=1.8,<1.9 + django1.7: Django>=1.7,<1.8 From de7e31a81ead70e9fdb602b37b45cb2854cf4d4c Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 21:26:26 +0100 Subject: [PATCH 159/196] Remove some workarounds for old django versions <1.7 which are no longer supported --- multisite/hacks.py | 12 ++++-------- multisite/middleware.py | 20 ++++++-------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 897007b..8c2e225 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -39,14 +39,10 @@ class SiteCache(object): """Wrapper for SITE_CACHE that assigns a key_prefix.""" def __init__(self, cache=None): - try: - from django.core.cache import caches - except ImportError: - # Django < 1.7 compatibility - from django.core.cache import get_cache - else: - def get_cache(cache_alias): - return caches[cache_alias] + from django.core.cache import caches + + def get_cache(cache_alias): + return caches[cache_alias] if cache is None: cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') diff --git a/multisite/middleware.py b/multisite/middleware.py index 6e5bcbc..a68672f 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -15,14 +15,7 @@ from django.core.exceptions import DisallowedHost from django.core import mail -try: - from django.core.cache import caches -except ImportError: - # Django < 1.7 compatibility - from django.core.cache import get_cache -else: - def get_cache(cache_alias): - return caches[cache_alias] +from django.core.cache import caches try: # Django > 1.10 uses MiddlewareMixin @@ -41,16 +34,15 @@ def get_cache(cache_alias): from django.db.models.signals import pre_save, post_delete, post_init from django.http import Http404, HttpResponsePermanentRedirect -try: - # Deprecated in Django 1.5 - from django.utils.hashcompat import md5_constructor -except ImportError: - # The above has been removed in Django 1.6 - from hashlib import md5 as md5_constructor +from hashlib import md5 as md5_constructor from .models import Alias +def get_cache(cache_alias): + return caches[cache_alias] + + class DynamicSiteMiddleware(MiddlewareMixin): def __init__(self, *args, **kwargs): super(DynamicSiteMiddleware, self).__init__(*args, **kwargs) From 960a442ce0e0d69b6142cb5f6e7763eb0f235e5b Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 21:58:42 +0100 Subject: [PATCH 160/196] Update README to clarify CACHE_MULTISITE_KEY_PREFIX defaults; updates to test_settings --- README.rst | 7 ++++--- multisite/test_settings.py | 12 ++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index ff33617..0a9956b 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -.. image:: https://travis-ci.org/rebkwok/django-multisite.svg?branch=master - :target: https://travis-ci.org/rebkwok/django-multisite +.. image:: https://travis-ci.org/ecometrica/django-multisite.svg?branch=master + :target: https://travis-ci.org/ecometrica/django-multisite README ====== @@ -77,7 +77,8 @@ safely cleared:: CACHE_MULTISITE_ALIAS = 'multisite' # The cache key prefix that django-multisite should use. - # Default: '' (Empty string) + # If not set, defaults to the KEY_PREFIX used in the defined + # CACHE_MULTISITE_ALIAS or the default cache (empty string if not set) CACHE_MULTISITE_KEY_PREFIX = '' If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 676a602..50259ea 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -29,15 +29,11 @@ del MIDDLEWARE # The cache connection to use for django-multisite. -# Default: 'default' CACHE_MULTISITE_ALIAS = 'test_multisite' -## The cache key prefix that django-multisite should use. -# This has to be unset for the tests as currently written to test it. -#CACHE_MULTISITE_KEY_PREFIX = '' - CACHES = { - 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, # required for Django <= 1.8 + # default cache required for Django <= 1.8 + 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, CACHE_MULTISITE_ALIAS: { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 'TIMEOUT': 60 * 60 * 24, # 24 hours @@ -49,7 +45,7 @@ if django.VERSION < (1, 8): TEMPLATE_LOADERS = ['multisite.template.loaders.filesystem.Loader'] TEMPLATE_DIRS = [os.path.join(os.path.abspath(os.path.dirname(__file__)), - 'test_templates')] + 'test_templates')] else: TEMPLATES=[ { @@ -64,7 +60,7 @@ ] }, } -] + ] TEST_RUNNER = 'django.test.runner.DiscoverRunner' From 5747facbddaac3df3b892aa14fa58c7a392b5116 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 18 Apr 2017 22:39:17 +0100 Subject: [PATCH 161/196] Add pip install instruction to README --- README.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 0a9956b..b41f3d4 100644 --- a/README.rst +++ b/README.rst @@ -4,11 +4,16 @@ README ====== -Get the code via git:: +Install with pip:: + + pip install django-multisite + + +Or get the code via git:: git clone git://github.com/ecometrica/django-multisite.git django-multisite -Run:: +Then run:: python setup.py install @@ -61,7 +66,7 @@ Or for Django 1.7 and earlier, add to settings.py TEMPLATES_LOADERS:: 'django.template.loaders.app_directories.Loader', ) -Edit to settings.py MIDDLEWARE_CLASSES:: +Edit settings.py MIDDLEWARE_CLASSES:: MIDDLEWARE_CLASSES = ( ... From aa64f16d64e7dcb4399f410e2ca255f7b71a8c0c Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 10:06:54 +0100 Subject: [PATCH 162/196] Test for old template loader setting --- multisite/tests.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/multisite/tests.py b/multisite/tests.py index 6a8e4eb..ff28817 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -986,3 +986,30 @@ def test_domain_template(self): ) else: self.assertEqual(template.render(), "Test example.com template") + + def test_get_template_old_settings(self): + # tests that we can still get to the template filesystem loader with + # the old setting configuration + with override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join( + os.path.abspath(os.path.dirname(__file__)), + 'test_templates') + ], + 'OPTIONS': { + 'loaders': [ + 'multisite.template_loader.Loader', + ] + }, + } + ] + ): + template = get_template("test.html") + if django.VERSION < (1, 8): # <1.7 render() requires Context instance + from django.template.context import Context + self.assertEqual(template.render(context=Context()), "Test!") + else: + self.assertEqual(template.render(), "Test!") From 9cec9c2dc619eaeb6a7a10651c6dd5503cc6f8e4 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 10:10:08 +0100 Subject: [PATCH 163/196] Add coverage and coveralls --- .coveragerc | 3 +++ .travis.yml | 9 ++++++++- CHANGELOG.rst | 1 + README.rst | 5 ++++- setup.py | 3 ++- tox.ini | 4 +++- 6 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0af74aa --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source=multisite +omit=multisite/tests.py,multisite/migrations/*,multisite/south_migrations/* \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index f571973..326065b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,4 +9,11 @@ python: install: - pip install tox-travis -script: tox + - pip install coverage + - pip install python-coveralls + +script: + - tox + +after_success: + - coveralls \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b5b1f7..96c0cd7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ Release Notes * Use setuptools over distutils, and integrate the tests with them * Use pytest and tox for testing * Set up CI with travis +* Set up coverage and coveralls.io 1.3.1 ----- diff --git a/README.rst b/README.rst index b41f3d4..96dc992 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,8 @@ .. image:: https://travis-ci.org/ecometrica/django-multisite.svg?branch=master - :target: https://travis-ci.org/ecometrica/django-multisite + :target: https://travis-ci.org/ecometrica/django-multisite?branch=master +.. image:: https://coveralls.io/repos/github/ecometrica/django-multisite/badge.svg?branch=master + :target: https://coveralls.io/github/ecometrica/django-multisite?branch=master + README ====== diff --git a/setup.py b/setup.py index 11d4cb3..c7aa082 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,8 @@ def long_description(): install_requires=['Django>=1.6', 'tldextract>=1.2'], setup_requires=['pytest-runner'], - tests_require=['pytest', 'pytest-django', 'pytest-pythonpath', 'tox'], + tests_require=['coverage', 'pytest', 'pytest-cov', 'pytest-django', + 'pytest-pythonpath', 'tox'], test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', diff --git a/tox.ini b/tox.ini index 6d96b0e..a92b75f 100644 --- a/tox.ini +++ b/tox.ini @@ -15,9 +15,11 @@ envlist = py27-django{1.11,1.10,1.9,1.8,1.7} [testenv] -commands = pytest -s --pyargs multisite +commands = pytest --cov --cov-config .coveragerc --pyargs multisite deps = + coverage pytest + pytest-cov pytest-django pytest-pythonpath From b365d8cf86d5c5019c1b0bbee0487c41083010cf Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 15:32:18 +0100 Subject: [PATCH 164/196] Document MULTISTE_EXTRA_HOSTS in README and add tests --- CHANGELOG.rst | 1 + README.rst | 11 +++++++++++ multisite/test_settings.py | 1 + multisite/tests.py | 39 +++++++++++++++++++++++++++++++++++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 96c0cd7..a2283fe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,7 @@ Release Notes * Use pytest and tox for testing * Set up CI with travis * Set up coverage and coveralls.io +* Document MULTISITE_EXTRA_HOSTS in README 1.3.1 ----- diff --git a/README.rst b/README.rst index 96dc992..6640113 100644 --- a/README.rst +++ b/README.rst @@ -104,6 +104,17 @@ If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* } +Multisite determines the ALLOWED_HOSTS by checking all Alias domains. You can +also set the MULTISITE_EXTRA_HOSTS to include additional hosts. This can +include wildcards.:: + + MULTISITE_EXTRA_HOSTS = ['example.com'] + # will match the single additional host + + MULTISITE_EXTRA_HOSTS = ['.example.com'] + # will match any host ending '.example.com' + + Domain fallbacks ---------------- diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 50259ea..2237539 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -41,6 +41,7 @@ }, } +MULTISITE_EXTRA_HOSTS = ['.extrahost.com'] if django.VERSION < (1, 8): TEMPLATE_LOADERS = ['multisite.template.loaders.filesystem.Loader'] diff --git a/multisite/tests.py b/multisite/tests.py index ff28817..238c680 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -795,7 +795,7 @@ def test_resolve(self): @override_settings( MULTISITE_COOKIE_DOMAIN_DEPTH=0, MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, - ALLOWED_HOSTS=ALLOWED_HOSTS + ALLOWED_HOSTS=ALLOWED_HOSTS, ) class TestCookieDomainMiddleware(TestCase): @@ -965,6 +965,43 @@ def test_subdomain_depth_2(self): cookies = middleware.process_response(request, response).cookies self.assertEqual(cookies['a']['domain'], '.app.test3.example.com') + def test_wildcard_subdomains(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + ['.test.example.com'] + with override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=2, ALLOWED_HOSTS=allowed + ): + # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to + # 2 levels deep against the wildcard + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='foo.test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.foo.test.example.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.bar.test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.bar.test.example.com') + + def test_multisite_extra_hosts(self): + # MULTISITE_EXTRA_HOSTS is set to ['.extrahost.com'] in + # test_settings.py. We can't override it here using override_settings. + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='test.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.bar.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + @override_settings(MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates') class TemplateLoaderTests(TestCase): From b234d662026a8d798292a23c71653f596ca40fd9 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 15:45:54 +0100 Subject: [PATCH 165/196] Remove get_cache It was a leftover from a workaround for Django versions <1.7 which we no longer support and isn't required. Use caches[cache_alias] directly. --- multisite/hacks.py | 5 +---- multisite/middleware.py | 6 +----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 8c2e225..963cb4a 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -41,9 +41,6 @@ class SiteCache(object): def __init__(self, cache=None): from django.core.cache import caches - def get_cache(cache_alias): - return caches[cache_alias] - if cache is None: cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') self._key_prefix = getattr( @@ -51,7 +48,7 @@ def get_cache(cache_alias): 'CACHE_MULTISITE_KEY_PREFIX', settings.CACHES[cache_alias].get('KEY_PREFIX', '') ) - cache = get_cache(cache_alias) + cache = caches[cache_alias] self._warn_cache_backend(cache, cache_alias) else: self._key_prefix = getattr( diff --git a/multisite/middleware.py b/multisite/middleware.py index a68672f..dd35d2b 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -39,10 +39,6 @@ from .models import Alias -def get_cache(cache_alias): - return caches[cache_alias] - - class DynamicSiteMiddleware(MiddlewareMixin): def __init__(self, *args, **kwargs): super(DynamicSiteMiddleware, self).__init__(*args, **kwargs) @@ -58,7 +54,7 @@ def __init__(self, *args, **kwargs): settings.CACHES[self.cache_alias].get('KEY_PREFIX', '') ) - self.cache = get_cache(self.cache_alias) + self.cache = caches[self.cache_alias] post_init.connect(self.site_domain_cache_hook, sender=Site, dispatch_uid='multisite_post_init') pre_save.connect(self.site_domain_changed_hook, sender=Site) From 77f103bc01efcdedff8d62d66fb3810a5f2437ba Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 17:33:57 +0100 Subject: [PATCH 166/196] Remove Django 1.6 from setup.py --- CHANGELOG.rst | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a2283fe..d5cb4d1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ Release Notes * Support Django 1.10 (PR #38) and 1.11 * Support Python 3 +* Remove support for Django <1.7 * Use setuptools over distutils, and integrate the tests with them * Use pytest and tox for testing * Set up CI with travis diff --git a/setup.py b/setup.py index c7aa082..1790dfa 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ def long_description(): packages=find_packages(), include_package_data=True, package_data={'multisite': files}, - install_requires=['Django>=1.6', + install_requires=['Django>=1.7', 'tldextract>=1.2'], setup_requires=['pytest-runner'], tests_require=['coverage', 'pytest', 'pytest-cov', 'pytest-django', From 7298c6b6daa3cefa7cc5165949686add228277fa Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 19 Apr 2017 17:34:54 +0100 Subject: [PATCH 167/196] Keep test_settings minimal for integration with other projects If these tests are run from within another django project with ./manage.py test, the outer project's settings file is used, and some tests will fail because they were expecting settings specified in test_settings.py. Keep test_settings.py to minimal settings and use override_settings in tests. --- multisite/test_settings.py | 36 ----------------- multisite/tests.py | 81 ++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/multisite/test_settings.py b/multisite/test_settings.py index 2237539..0f437b7 100644 --- a/multisite/test_settings.py +++ b/multisite/test_settings.py @@ -1,5 +1,4 @@ import django -import os from multisite import SiteID SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" @@ -28,40 +27,5 @@ MIDDLEWARE_CLASSES = list(MIDDLEWARE) del MIDDLEWARE -# The cache connection to use for django-multisite. -CACHE_MULTISITE_ALIAS = 'test_multisite' - -CACHES = { - # default cache required for Django <= 1.8 - 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, - CACHE_MULTISITE_ALIAS: { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'TIMEOUT': 60 * 60 * 24, # 24 hours - 'KEY_PREFIX': 'looselycoupled', - }, -} - -MULTISITE_EXTRA_HOSTS = ['.extrahost.com'] - -if django.VERSION < (1, 8): - TEMPLATE_LOADERS = ['multisite.template.loaders.filesystem.Loader'] - TEMPLATE_DIRS = [os.path.join(os.path.abspath(os.path.dirname(__file__)), - 'test_templates')] -else: - TEMPLATES=[ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(os.path.abspath(os.path.dirname(__file__)), - 'test_templates') - ], - 'OPTIONS': { - 'loaders': [ - 'multisite.template.loaders.filesystem.Loader', - ] - }, - } - ] - TEST_RUNNER = 'django.test.runner.DiscoverRunner' diff --git a/multisite/tests.py b/multisite/tests.py index 238c680..e9015e7 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -38,7 +38,7 @@ from override_settings import override_settings from multisite import SiteDomain, SiteID, threadlocals -from .hosts import ALLOWED_HOSTS +from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias from .threadlocals import SiteIDHook @@ -93,6 +93,7 @@ def test_get_current_site(self): SITE_ID=SiteID(default=0), CACHE_MULTISITE_ALIAS='multisite', CACHES={ + 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} }, MULTISITE_FALLBACK=None, @@ -410,6 +411,15 @@ def test_multisite_key_prefix(self): self.cache._cache._get_cache_key(self.site.id) ) + @override_settings( + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'KEY_PREFIX': 'looselycoupled' + } + }, + ) def test_default_key_prefix(self): """ If CACHE_MULTISITE_KEY_PREFIX is undefined, @@ -796,6 +806,7 @@ def test_resolve(self): MULTISITE_COOKIE_DOMAIN_DEPTH=0, MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, ALLOWED_HOSTS=ALLOWED_HOSTS, + MULTISITE_EXTRA_HOSTS=['.extrahost.com'] ) class TestCookieDomainMiddleware(TestCase): @@ -985,25 +996,55 @@ def test_wildcard_subdomains(self): self.assertEqual(cookies['a']['domain'], '.bar.test.example.com') def test_multisite_extra_hosts(self): - # MULTISITE_EXTRA_HOSTS is set to ['.extrahost.com'] in - # test_settings.py. We can't override it here using override_settings. - response = HttpResponse() - response.set_cookie(key='a', value='a', domain=None) - middleware = CookieDomainMiddleware() - request = self.factory.get('/', host='test.extrahost.com') - cookies = middleware.process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '.extrahost.com') - cookies['a']['domain'] = '' - request = self.factory.get('/', host='foo.extrahost.com') - cookies = middleware.process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '.extrahost.com') - cookies['a']['domain'] = '' - request = self.factory.get('/', host='foo.bar.extrahost.com') - cookies = middleware.process_response(request, response).cookies - self.assertEqual(cookies['a']['domain'], '.extrahost.com') - - -@override_settings(MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates') + # MULTISITE_EXTRA_HOSTS is set to ['.extrahost.com'] but + # ALLOWED_HOSTS seems to be genereated in override_settings before + # the extra hosts is added, so we need to recalculate it here. + allowed = IterableLazyObject(lambda: AllowedHosts()) + with override_settings(ALLOWED_HOSTS=allowed): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='test.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.bar.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + + +if django.VERSION < (1, 8): + TEMPLATE_SETTINGS = { + 'TEMPLATE_LOADERS': ['multisite.template.loaders.filesystem.Loader'], + 'TEMPLATE_DIRS': [os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates')] + } +else: + TEMPLATE_SETTINGS = {'TEMPLATES':[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates') + ], + 'OPTIONS': { + 'loaders': [ + 'multisite.template.loaders.filesystem.Loader', + ] + }, + } + ] + } + + +@override_settings( + MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates', + **TEMPLATE_SETTINGS +) class TemplateLoaderTests(TestCase): def test_get_template_multisite_default_dir(self): From 5e9219126c88217446d988c86ec56f592b043e7a Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 25 Apr 2017 16:45:33 +0100 Subject: [PATCH 168/196] Update CookieDomainMiddleware to use new stype Django 1.10+ middleware --- multisite/middleware.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/multisite/middleware.py b/multisite/middleware.py index dd35d2b..5f37828 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -221,8 +221,9 @@ def site_deleted_hook(self, *args, **kwargs): self.cache.clear() -class CookieDomainMiddleware(object): - def __init__(self): +class CookieDomainMiddleware(MiddlewareMixin): + def __init__(self, *args, **kwargs): + super(CookieDomainMiddleware, self).__init__(*args, **kwargs) self.depth = int(getattr(settings, 'MULTISITE_COOKIE_DOMAIN_DEPTH', 0)) if self.depth < 0: raise ValueError( From afa52705386d708fb67c31983fb048a9fac56c9e Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Wed, 26 Apr 2017 15:16:48 +0100 Subject: [PATCH 169/196] Bump up version to 1.4.0 --- multisite/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multisite/__init__.py b/multisite/__init__.py index 4f88ebc..9f61133 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,3 @@ from .threadlocals import SiteDomain, SiteID -VERSION = "l.3.1" +VERSION = "1.4.0" From a04c53f53133b3682338eac3ca1de25f1778e973 Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:09:32 +0100 Subject: [PATCH 170/196] Add mock to test requirements --- multisite/tests.py | 8 ++++++-- setup.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index e9015e7..bd883c0 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -18,11 +18,15 @@ import sys import os import tempfile -from unittest import skipUnless import warnings +from unittest import skipUnless -from django.conf import settings +try: + from unittest import mock +except ImportError: + import mock +from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ImproperlyConfigured, ValidationError from django.http import Http404 diff --git a/setup.py b/setup.py index 1790dfa..6821b0b 100644 --- a/setup.py +++ b/setup.py @@ -27,8 +27,8 @@ def long_description(): install_requires=['Django>=1.7', 'tldextract>=1.2'], setup_requires=['pytest-runner'], - tests_require=['coverage', 'pytest', 'pytest-cov', 'pytest-django', - 'pytest-pythonpath', 'tox'], + tests_require=['coverage', 'mock', 'pytest', 'pytest-cov', + 'pytest-django', 'pytest-pythonpath', 'tox'], test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', From 04e6dd1d6626eedaa589d8d1990eae8980aad9d9 Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:11:50 +0100 Subject: [PATCH 171/196] Remove try for override_settings It's been part of django since ~1.4 --- multisite/tests.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index bd883c0..de7af04 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -31,15 +31,11 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.http import Http404 from django.template.loader import get_template -from django.test import TestCase +from django.test import TestCase, override_settings from django.test.client import RequestFactory as DjangoRequestFactory from .hacks import use_framework_for_site_cache -try: - from django.test.utils import override_settings -except ImportError: - from override_settings import override_settings from multisite import SiteDomain, SiteID, threadlocals from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject From 3f3c1b58b549ea4f3c93a13c91f22c5dbbccaeea Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:13:07 +0100 Subject: [PATCH 172/196] Add logging, StringIO, and call_command imports Will be used to test output from management commands --- multisite/tests.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index de7af04..2a671ee 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -14,9 +14,10 @@ from __future__ import absolute_import import django +import logging +import os import pytest import sys -import os import tempfile import warnings from unittest import skipUnless @@ -30,14 +31,15 @@ from django.contrib.sites.models import Site from django.core.exceptions import ImproperlyConfigured, ValidationError from django.http import Http404 +from django.core.management import call_command from django.template.loader import get_template from django.test import TestCase, override_settings from django.test.client import RequestFactory as DjangoRequestFactory - -from .hacks import use_framework_for_site_cache - +from django.utils.six import StringIO from multisite import SiteDomain, SiteID, threadlocals + +from .hacks import use_framework_for_site_cache from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias From 8b50002a162b49fee11edae575b2aedff75b28b5 Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:15:06 +0100 Subject: [PATCH 173/196] Move imports to top of file --- multisite/tests.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index 2a671ee..c2d32eb 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -28,10 +28,11 @@ import mock from django.conf import settings +from django.conf.urls import url from django.contrib.sites.models import Site from django.core.exceptions import ImproperlyConfigured, ValidationError -from django.http import Http404 from django.core.management import call_command +from django.http import Http404, HttpResponse from django.template.loader import get_template from django.test import TestCase, override_settings from django.test.client import RequestFactory as DjangoRequestFactory @@ -75,10 +76,6 @@ def test_get_current_site(self): self.assertEqual(current_site, self.site) self.assertEqual(current_site.id, settings.SITE_ID) - -from django.http import HttpResponse -from django.conf.urls import url - # Because we are a middleware package, we have no views available to test with easily # So create one: # (This is only used by test_integration) From 625b5c4e196343651ddc8df6a0653383a4b58ff1 Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:17:08 +0100 Subject: [PATCH 174/196] Remove deprecated NoArgsCommand --- .../commands/update_public_suffix_list.py | 6 ++--- multisite/tests.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index ba15cd6..5ba2d0c 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -7,13 +7,13 @@ import tempfile from django.conf import settings -from django.core.management.base import NoArgsCommand +from django.core.management.base import BaseCommand import tldextract -class Command(NoArgsCommand): - def handle_noargs(self, **options): +class Command(BaseCommand): + def handle(self, **options): self.setup_logging(verbosity=options.get('verbosity', 1)) filename = getattr( diff --git a/multisite/tests.py b/multisite/tests.py index c2d32eb..4c7bbb6 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1090,3 +1090,25 @@ def test_get_template_old_settings(self): self.assertEqual(template.render(context=Context()), "Test!") else: self.assertEqual(template.render(), "Test!") + + +class UpdatePublicSuffixListCommandTestCase(TestCase): + + def setUp(self): + self.cache_file = '/tmp/multisite_tld.dat' + + # patch tldextract to avoid actual requests + self.patcher = mock.patch('tldextract.TLDExtract') + self.tldextract = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def test_command(self): + call_command('update_public_suffix_list') + expected_calls = [ + mock.call(cache_file=self.cache_file), + mock.call().update(fetch_now=True) + ] + self.assertEqual(self.tldextract.mock_calls, expected_calls) + From fe184a147d47aee77575fb26f1962439aa3fc4fc Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:17:26 +0100 Subject: [PATCH 175/196] Add test of management command and tldextract output --- .../commands/update_public_suffix_list.py | 11 +++++------ multisite/tests.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py index 5ba2d0c..88e6fdb 100644 --- a/multisite/management/commands/update_public_suffix_list.py +++ b/multisite/management/commands/update_public_suffix_list.py @@ -29,11 +29,10 @@ def handle(self, **options): def setup_logging(self, verbosity): self.verbosity = int(verbosity) - # Mute tldextract's logger - logger = logging.getLogger('tldextract') + # Connect to tldextract's logger + self.logger = logging.getLogger('tldextract') if self.verbosity < 2: - logger.setLevel(logging.CRITICAL) + self.logger.setLevel(logging.CRITICAL) - def log(self, msg, level=2): - if self.verbosity >= level: - print(msg) + def log(self, msg): + self.logger.info(msg) diff --git a/multisite/tests.py b/multisite/tests.py index 4c7bbb6..101fed9 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1097,6 +1097,14 @@ class UpdatePublicSuffixListCommandTestCase(TestCase): def setUp(self): self.cache_file = '/tmp/multisite_tld.dat' + # save the tldextract logger output to a buffer to test output + self.out = StringIO() + self.logger = logging.getLogger('tldextract') + self.logger.setLevel(logging.DEBUG) + stdout_handler = logging.StreamHandler(self.out) + stdout_handler.setLevel(logging.DEBUG) + self.logger.addHandler(stdout_handler) + # patch tldextract to avoid actual requests self.patcher = mock.patch('tldextract.TLDExtract') self.tldextract = self.patcher.start() @@ -1104,6 +1112,9 @@ def setUp(self): def tearDown(self): self.patcher.stop() + def tldextract_update_side_effect(self, *args, **kwargs): + self.logger.debug('TLDExtract.update called') + def test_command(self): call_command('update_public_suffix_list') expected_calls = [ @@ -1112,3 +1123,11 @@ def test_command(self): ] self.assertEqual(self.tldextract.mock_calls, expected_calls) + def test_command_output(self): + # make sure that the logger receives output from the method + self.tldextract().update.side_effect = self.tldextract_update_side_effect + + call_command('update_public_suffix_list', verbosity=3) + update_message = 'Updating {}'.format(self.cache_file) + self.assertIn(update_message, self.out.getvalue()) + self.assertIn('TLDExtract.update called', self.out.getvalue()) From c0e04d58a2d5633778195b62b3625588fb9fb8af Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Sun, 25 Jun 2017 20:27:13 +0100 Subject: [PATCH 176/196] Add mock to tox requirements --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index a92b75f..dfd87e1 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ envlist = commands = pytest --cov --cov-config .coveragerc --pyargs multisite deps = coverage + mock pytest pytest-cov pytest-django From 3d28b61c18254e88b154a827b31fe6c60d5b514a Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Mon, 26 Jun 2017 10:51:05 +0100 Subject: [PATCH 177/196] Limit mock dependency in tox to python 2.7 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index dfd87e1..f332733 100644 --- a/tox.ini +++ b/tox.ini @@ -18,12 +18,12 @@ envlist = commands = pytest --cov --cov-config .coveragerc --pyargs multisite deps = coverage - mock pytest pytest-cov pytest-django pytest-pythonpath + py27: mock django1.11: Django==1.11 django1.10: Django>=1.10,<1.11 django1.9: Django>=1.9,<1.10 From b356f21dfb0686fb6dfad6144d6fd86e35b9cd26 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Fri, 22 Dec 2017 12:26:15 +0000 Subject: [PATCH 178/196] Update setup.py to specify Django<2.0 --- .travis.yml | 1 - CHANGELOG.rst | 6 +++++- setup.py | 5 ++--- tox.ini | 1 - 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 326065b..dd43318 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ sudo: false language: python python: - "2.7" - - "3.3" - "3.4" - "3.5" - "3.6" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d5cb4d1..5218b99 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,9 +2,13 @@ Release Notes ============= -1.4.0 +1.4.1 ----- +* Specify Django <2.0 in setup.py +* Drop support for python 3.3 +1.4.0 +----- * Support Django 1.10 (PR #38) and 1.11 * Support Python 3 * Remove support for Django <1.7 diff --git a/setup.py b/setup.py index 6821b0b..cf51ac5 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def long_description(): files = ["multisite/test_templates/*"] setup(name='django-multisite', - version='1.4.0', + version='1.4.1', description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', @@ -24,7 +24,7 @@ def long_description(): packages=find_packages(), include_package_data=True, package_data={'multisite': files}, - install_requires=['Django>=1.7', + install_requires=['Django>=1.7,<2.0', 'tldextract>=1.2'], setup_requires=['pytest-runner'], tests_require=['coverage', 'mock', 'pytest', 'pytest-cov', @@ -38,7 +38,6 @@ def long_description(): 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', diff --git a/tox.ini b/tox.ini index f332733..bebd745 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,6 @@ envlist = py36-django{1.11} py35-django{1.11,1.10,1.9,1.8} py34-django{1.11,1.10,1.9,1.8,1.7} - py33-django{1.8,1.7} py27-django{1.11,1.10,1.9,1.8,1.7} [testenv] From ed03de35a8889574bb3cb562a5efd2c279ddb7fb Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Thu, 18 Jan 2018 15:59:46 -0500 Subject: [PATCH 179/196] rel.to removed in Django 2.0 --- multisite/admin.py | 19 +++++++++++++------ multisite/managers.py | 5 ++++- multisite/models.py | 5 ++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index 8bd3167..b638bee 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -65,7 +65,10 @@ def get_filters(self, request, *args, **kwargs): user_sites = frozenset(profile.sites.values_list("pk", "domain")) for filter_spec in filter_specs: try: - rel_to = filter_spec.field.rel.to + try: + rel_to = filter_spec.field.remote_field.to + except AttributeError: + rel_to = filter_spec.field.rel.to except AttributeError: new_filter_specs.append(filter_spec) continue @@ -193,12 +196,16 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): else: sites = user_sites - if hasattr(db_field.rel.to, "site"): - kwargs["queryset"] = db_field.rel.to._default_manager.filter( + try: + rel_to = db_field.remote_field.to + except AttributeError: + rel_to = db_field.rel.to + if hasattr(rel_to, "site"): + kwargs["queryset"] = rel_to._default_manager.filter( site__in=user_sites ) - if hasattr(db_field.rel.to, "sites"): - kwargs["queryset"] = db_field.rel.to._default_manager.filter( + if hasattr(rel_to, "sites"): + kwargs["queryset"] = rel_to._default_manager.filter( sites__in=user_sites ) if db_field.name == "site" or db_field.name == "sites": @@ -206,7 +213,7 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): if hasattr(self, "multisite_indirect_foreign_key_path") and \ db_field.name in self.multisite_indirect_foreign_key_path.keys(): fkey = self.multisite_indirect_foreign_key_path[db_field.name] - kwargs["queryset"] = db_field.rel.to._default_manager.filter( + kwargs["queryset"] = rel_to._default_manager.filter( **{fkey: user_sites} ) diff --git a/multisite/managers.py b/multisite/managers.py index 919d7ff..4ebe9e8 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -83,7 +83,10 @@ def _validate_single_field_name(self, model, field_name): def _get_related_model(self, model, fieldname): """Given a model and the name of a ForeignKey or ManyToManyField column as a string, returns the associated model.""" - return model._meta.get_field_by_name(fieldname)[0].rel.to + try: + return model._meta.get_field(fieldname).remote_field.to + except AttributeError: + return model._meta.get_field(fieldname).rel.to class PathAssistedCurrentSiteManager(models.CurrentSiteManager): diff --git a/multisite/models.py b/multisite/models.py index 878092c..c8b7444 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -126,7 +126,10 @@ def sync_many(self, *args, **kwargs): def sync_missing(self): """Create missing canonical Alias objects based on Site.domain.""" aliases = self.get_queryset() - sites = self.model._meta.get_field('site').rel.to + try: + sites = self.model._meta.get_field('site').remote_field.to + except AttributeError: + sites = self.model._meta.get_field('site').rel.to for site in sites.objects.exclude(aliases__in=aliases): Alias.sync(site=site) From 5fa24868ad720af28c9930fda3801ac16340aa13 Mon Sep 17 00:00:00 2001 From: Jordan Reiter Date: Thu, 18 Jan 2018 16:21:38 -0500 Subject: [PATCH 180/196] Instead of ForeignObjectRel.to, use model. Also renamed rel_to to remote_model for further clarity --- multisite/admin.py | 20 ++++++++++---------- multisite/managers.py | 2 +- multisite/models.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/multisite/admin.py b/multisite/admin.py index b638bee..820ff2a 100644 --- a/multisite/admin.py +++ b/multisite/admin.py @@ -66,13 +66,13 @@ def get_filters(self, request, *args, **kwargs): for filter_spec in filter_specs: try: try: - rel_to = filter_spec.field.remote_field.to + remote_model = filter_spec.field.remote_field.model except AttributeError: - rel_to = filter_spec.field.rel.to + remote_model = filter_spec.field.rel.to except AttributeError: new_filter_specs.append(filter_spec) continue - if rel_to is not Site: + if remote_model is not Site: new_filter_specs.append(filter_spec) continue lookup_choices = frozenset(filter_spec.lookup_choices) & user_sites @@ -197,15 +197,15 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): sites = user_sites try: - rel_to = db_field.remote_field.to + remote_model = db_field.remote_field.model except AttributeError: - rel_to = db_field.rel.to - if hasattr(rel_to, "site"): - kwargs["queryset"] = rel_to._default_manager.filter( + remote_model = db_field.rel.to + if hasattr(remote_model, "site"): + kwargs["queryset"] = remote_model._default_manager.filter( site__in=user_sites ) - if hasattr(rel_to, "sites"): - kwargs["queryset"] = rel_to._default_manager.filter( + if hasattr(remote_model, "sites"): + kwargs["queryset"] = remote_model._default_manager.filter( sites__in=user_sites ) if db_field.name == "site" or db_field.name == "sites": @@ -213,7 +213,7 @@ def handle_multisite_foreign_keys(self, db_field, request, **kwargs): if hasattr(self, "multisite_indirect_foreign_key_path") and \ db_field.name in self.multisite_indirect_foreign_key_path.keys(): fkey = self.multisite_indirect_foreign_key_path[db_field.name] - kwargs["queryset"] = rel_to._default_manager.filter( + kwargs["queryset"] = remote_model._default_manager.filter( **{fkey: user_sites} ) diff --git a/multisite/managers.py b/multisite/managers.py index 4ebe9e8..939991f 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -84,7 +84,7 @@ def _get_related_model(self, model, fieldname): """Given a model and the name of a ForeignKey or ManyToManyField column as a string, returns the associated model.""" try: - return model._meta.get_field(fieldname).remote_field.to + return model._meta.get_field(fieldname).remote_field.model except AttributeError: return model._meta.get_field(fieldname).rel.to diff --git a/multisite/models.py b/multisite/models.py index c8b7444..15cd88b 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -127,7 +127,7 @@ def sync_missing(self): """Create missing canonical Alias objects based on Site.domain.""" aliases = self.get_queryset() try: - sites = self.model._meta.get_field('site').remote_field.to + sites = self.model._meta.get_field('site').remote_field.model except AttributeError: sites = self.model._meta.get_field('site').rel.to for site in sites.objects.exclude(aliases__in=aliases): From 7020dcb055c8b2e7ad77472d2dce6da479797b02 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 10:21:04 +0100 Subject: [PATCH 181/196] Updates to support Django 2.0 --- multisite/migrations/0001_initial.py | 2 +- multisite/models.py | 4 +++- multisite/template/loaders/filesystem.py | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/multisite/migrations/0001_initial.py b/multisite/migrations/0001_initial.py index d2d0f85..bc2d16e 100644 --- a/multisite/migrations/0001_initial.py +++ b/multisite/migrations/0001_initial.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('domain', models.CharField(help_text='Either "domain" or "domain:port"', unique=True, max_length=100, verbose_name='domain name')), ('is_canonical', models.NullBooleanField(default=None, validators=[multisite.models.validate_true_or_none], editable=False, help_text='Does this domain name match the one in site?', verbose_name='is canonical?')), ('redirect_to_canonical', models.BooleanField(default=True, help_text='Should this domain name redirect to the one in site?', verbose_name='redirect to canonical?')), - ('site', models.ForeignKey(related_name='aliases', to='sites.Site')), + ('site', models.ForeignKey(related_name='aliases', to='sites.Site', on_delete=models.CASCADE)), ], options={ 'verbose_name_plural': 'aliases', diff --git a/multisite/models.py b/multisite/models.py index 15cd88b..9b724dd 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -169,7 +169,9 @@ class Alias(models.Model): unique=True, help_text=_('Either "domain" or "domain:port"'), ) - site = models.ForeignKey(Site, related_name='aliases') + site = models.ForeignKey( + Site, related_name='aliases', on_delete=models.CASCADE + ) is_canonical = models.NullBooleanField( _('is canonical?'), default=None, editable=False, diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py index cc0d957..b061754 100644 --- a/multisite/template/loaders/filesystem.py +++ b/multisite/template/loaders/filesystem.py @@ -6,15 +6,20 @@ from django.conf import settings from django.contrib.sites.models import Site from django.template.loaders.filesystem import Loader as FilesystemLoader +from django import VERSION as django_version class Loader(FilesystemLoader): - def get_template_sources(self, template_name, template_dirs=None): + def get_template_sources(self, *args, **kwargs): + template_name = args[0] domain = Site.objects.get_current().domain default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 'default') for tname in (os.path.join(domain, template_name), os.path.join(default_dir, template_name)): - for item in super(Loader, self).get_template_sources(tname, - template_dirs): + if django_version < (2, 0, 0): + args = [tname, None] + else: + args = [tname] + for item in super(Loader, self).get_template_sources(*args, **kwargs): yield item From 5f478202213c9b5dc9fd2f95d832e088fee22d3d Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 10:21:32 +0100 Subject: [PATCH 182/196] Update setup.py and tox to install/test with Django 2.0 --- setup.py | 11 +++++++++-- tox.ini | 9 +++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index cf51ac5..9100255 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,17 @@ +import sys + from setuptools import find_packages, setup import os _dir_ = os.path.dirname(__file__) +if sys.version_info < (3, 4): + install_requires = ['Django>=1.7,<2.0', 'tldextract>=1.2'] +else: + install_requires = ['Django>=1.7,<2.1', 'tldextract>=1.2'] + + def long_description(): """Returns the value of README.rst""" with open(os.path.join(_dir_, 'README.rst')) as f: @@ -24,8 +32,7 @@ def long_description(): packages=find_packages(), include_package_data=True, package_data={'multisite': files}, - install_requires=['Django>=1.7,<2.0', - 'tldextract>=1.2'], + install_requires=install_requires, setup_requires=['pytest-runner'], tests_require=['coverage', 'mock', 'pytest', 'pytest-cov', 'pytest-django', 'pytest-pythonpath', 'tox'], diff --git a/tox.ini b/tox.ini index bebd745..d647352 100644 --- a/tox.ini +++ b/tox.ini @@ -8,9 +8,9 @@ setenv= PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} usedevelop = True envlist = - py36-django{1.11} - py35-django{1.11,1.10,1.9,1.8} - py34-django{1.11,1.10,1.9,1.8,1.7} + py36-django{2.0, 1.11} + py35-django{2.0, 1.11,1.10,1.9,1.8} + py34-django{2.0, 1.11,1.10,1.9,1.8,1.7} py27-django{1.11,1.10,1.9,1.8,1.7} [testenv] @@ -23,7 +23,8 @@ deps = pytest-pythonpath py27: mock - django1.11: Django==1.11 + django2.0: Django>=2.0,<2.1 + django1.11: Django>=1.11,<2.0 django1.10: Django>=1.10,<1.11 django1.9: Django>=1.9,<1.10 django1.8: Django>=1.8,<1.9 From ccdd848c62d7f0bccd0b82283df1c56292b84790 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 10:32:41 +0100 Subject: [PATCH 183/196] Remove old deprecated PathAssistedCurrentSiteManager It's incompatible with the versions of django that we now support --- multisite/managers.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/multisite/managers.py b/multisite/managers.py index 939991f..c4fee11 100644 --- a/multisite/managers.py +++ b/multisite/managers.py @@ -2,8 +2,6 @@ from __future__ import unicode_literals from __future__ import absolute_import -from warnings import warn - from django.db import models from django.contrib.sites import managers from django.db.models.fields import FieldDoesNotExist @@ -87,21 +85,3 @@ def _get_related_model(self, model, fieldname): return model._meta.get_field(fieldname).remote_field.model except AttributeError: return model._meta.get_field(fieldname).rel.to - - -class PathAssistedCurrentSiteManager(models.CurrentSiteManager): - """ - Deprecated: Use multisite.managers.SpanningCurrentSiteManager instead. - """ - def __init__(self, field_path): - warn(('Use multisite.managers.SpanningCurrentSiteManager instead of ' - 'multisite.managers.PathAssistedCurrentSiteManager'), - DeprecationWarning, stacklevel=2) - super(PathAssistedCurrentSiteManager, self).__init__() - self.__field_path = field_path - - def get_queryset(self): - from django.contrib.sites.models import Site - return super(models.CurrentSiteManager, self).get_queryset().filter( - **{self.__field_path: Site.objects.get_current()} - ) From cabfab94788bf602509239f53c6877185f6fd58a Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 10:38:40 +0100 Subject: [PATCH 184/196] Remove template.loaders.cached Undocumented, and broken since Django 1.6, which is no loger supported --- multisite/template/loaders/cached.py | 49 ---------------------------- 1 file changed, 49 deletions(-) delete mode 100644 multisite/template/loaders/cached.py diff --git a/multisite/template/loaders/cached.py b/multisite/template/loaders/cached.py deleted file mode 100644 index 5d8ff6d..0000000 --- a/multisite/template/loaders/cached.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -from __future__ import absolute_import - -from collections import defaultdict -import hashlib - -from django.contrib.sites.models import Site -from django.template.base import TemplateDoesNotExist -from django.template.loader import get_template_from_string -from django.template.loaders.cached import Loader as CachedLoader - - -class Loader(CachedLoader): - """ - This is an adaptation of Django's cached template loader. It differs in - that the cache is domain-based, so you can actually run more than one - site with one process. - - The load_template() method has been adapted from Django 1.6. - """ - - def __init__(self, *args, **kwargs): - super(Loader, self).__init__(*args, **kwargs) - self.template_cache = defaultdict(dict) - - def load_template(self, template_name, template_dirs=None): - domain = Site.objects.get_current().domain - key = template_name - if template_dirs: - # If template directories were specified, use a hash to differentiate - key = '-'.join([template_name, hashlib.sha1('|'.join(template_dirs)).hexdigest()]) - - try: - template = self.template_cache[domain][key] - except KeyError: - template, origin = self.find_template(template_name, template_dirs) - if not hasattr(template, 'render'): - try: - template = get_template_from_string(template, origin, template_name) - except TemplateDoesNotExist: - # If compiling the template we found raises TemplateDoesNotExist, - # back off to returning the source and display name for the template - # we were asked to load. This allows for correct identification (later) - # of the actual template that does not exist. - return template, origin - self.template_cache[domain][key] = template - return template, None - From 70d5b00f713e2119dd00631817002802b382fb1c Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 11:09:03 +0100 Subject: [PATCH 185/196] Pin pytest-django version for Django 1.7 --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d647352..2547b8f 100644 --- a/tox.ini +++ b/tox.ini @@ -19,13 +19,14 @@ deps = coverage pytest pytest-cov - pytest-django pytest-pythonpath py27: mock + django2.0,django1.11,django1.10,django1.9,django1.8: pytest-django django2.0: Django>=2.0,<2.1 django1.11: Django>=1.11,<2.0 django1.10: Django>=1.10,<1.11 django1.9: Django>=1.9,<1.10 django1.8: Django>=1.8,<1.9 + django1.7: pytest-django<3.2.0 django1.7: Django>=1.7,<1.8 From 8037ec528fb8e3e4a20114a59357fde66526017d Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 11:54:48 +0100 Subject: [PATCH 186/196] Remove code for unsupported Django versions (<1.7) --- multisite/hosts.py | 13 +---- multisite/models.py | 15 +----- multisite/south_migrations/0001_initial.py | 49 ------------------- ...__add_field_alias_redirect_to_canonical.py | 34 ------------- multisite/south_migrations/__init__.py | 0 5 files changed, 4 insertions(+), 107 deletions(-) delete mode 100644 multisite/south_migrations/0001_initial.py delete mode 100644 multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py delete mode 100644 multisite/south_migrations/__init__.py diff --git a/multisite/hosts.py b/multisite/hosts.py index 18e1e9d..90525a9 100644 --- a/multisite/hosts.py +++ b/multisite/hosts.py @@ -1,21 +1,12 @@ from __future__ import unicode_literals from __future__ import absolute_import -from django.utils.functional import SimpleLazyObject - -from django import VERSION as django_version +from django.utils.functional import empty, SimpleLazyObject __ALL__ = ('ALLOWED_HOSTS', 'AllowedHosts') - -# In Django 1.3, LazyObject compares _wrapped against None, while in Django -# 1.4 and above, LazyObjects compares _wrapped against an instance of -# `object` stored in `empty`. -_wrapped_default = None -if django_version >= (1, 4, 0): - from django.utils.functional import empty - _wrapped_default = empty +_wrapped_default = empty class IterableLazyObject(SimpleLazyObject): diff --git a/multisite/models.py b/multisite/models.py index 9b724dd..48b493d 100644 --- a/multisite/models.py +++ b/multisite/models.py @@ -10,11 +10,7 @@ from django.db import connections, models, router from django.db.models import Q from django.db.models.signals import pre_save, post_save -try: - from django.db.models.signals import post_migrate -except ImportError: - # Django < 1.7 compatibility - from django.db.models.signals import post_syncdb as post_migrate +from django.db.models.signals import post_migrate from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -320,14 +316,7 @@ def site_created_hook(cls, sender, instance, raw, created, @classmethod def db_table_created_hook(cls, *args, **kwargs): """Syncs canonical Alias objects for all existing Site objects.""" - if kwargs.get('created_models'): - # For post_syncdb support in Django < 1.7: - # As before, only sync_all if Alias was in - # the list of created models - if cls in kwargs['created_models']: - Alias.canonical.sync_all() - else: - Alias.canonical.sync_all() + Alias.canonical.sync_all() # Hooks to handle Site objects being created or changed diff --git a/multisite/south_migrations/0001_initial.py b/multisite/south_migrations/0001_initial.py deleted file mode 100644 index 4a6c6d1..0000000 --- a/multisite/south_migrations/0001_initial.py +++ /dev/null @@ -1,49 +0,0 @@ -# encoding: utf-8 -from south.db import db -from south.v2 import SchemaMigration - - -class Migration(SchemaMigration): - def forwards(self, orm): - """Create Alias table.""" - - # Adding model 'Alias' - db.create_table('multisite_alias', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('site', self.gf('django.db.models.fields.related.ForeignKey')(related_name='aliases', to=orm['sites.Site'])), - ('domain', self.gf('django.db.models.fields.CharField')(unique=True, max_length=100)), - ('is_canonical', self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True)), - )) - db.send_create_signal('multisite', ['Alias']) - - # Adding unique constraint on 'Alias', - # fields ['is_canonical', 'site'] - db.create_unique('multisite_alias', ['is_canonical', 'site_id']) - - def backwards(self, orm): - """Drop Alias table.""" - - # Removing unique constraint on 'Alias', - # fields ['is_canonical', 'site'] - db.delete_unique('multisite_alias', ['is_canonical', 'site_id']) - - # Deleting model 'Alias' - db.delete_table('multisite_alias') - - models = { - 'multisite.alias': { - 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, - 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), - 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) - }, - 'sites.site': { - 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, - 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['multisite'] diff --git a/multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py b/multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py deleted file mode 100644 index 6854175..0000000 --- a/multisite/south_migrations/0002_auto__add_field_alias_redirect_to_canonical.py +++ /dev/null @@ -1,34 +0,0 @@ -# encoding: utf-8 -from south.db import db -from south.v2 import SchemaMigration - - -class Migration(SchemaMigration): - def forwards(self, orm): - """Adding field 'Alias.redirect_to_canonical""" - db.add_column('multisite_alias', 'redirect_to_canonical', - self.gf('django.db.models.fields.BooleanField')(default=True), - keep_default=False) - - def backwards(self, orm): - """Deleting field 'Alias.redirect_to_canonical'""" - db.delete_column('multisite_alias', 'redirect_to_canonical') - - models = { - 'multisite.alias': { - 'Meta': {'unique_together': "[('is_canonical', 'site')]", 'object_name': 'Alias'}, - 'domain': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_canonical': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), - 'redirect_to_canonical': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'site': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': "orm['sites.Site']"}) - }, - 'sites.site': { - 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"}, - 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - } - } - - complete_apps = ['multisite'] diff --git a/multisite/south_migrations/__init__.py b/multisite/south_migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From 3596326a4b095f87da56a2892bb11abef8426269 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 12:24:40 +0100 Subject: [PATCH 187/196] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1a55763..70df889 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ parts MANIFEST multisite/*.egg-info .tox/ +.pytest_cache/ From 0d496bf1115ceca12c308e162f7e17b51f0b7cdc Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 19 Apr 2018 12:39:42 +0100 Subject: [PATCH 188/196] Update README to document development mode see https://github.com/ecometrica/django-multisite/pull/5 --- README.rst | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 6640113..ffa08aa 100644 --- a/README.rst +++ b/README.rst @@ -62,16 +62,16 @@ Add to your settings.py TEMPLATES loaders in the OPTIONS section:: ... ] -Or for Django 1.7 and earlier, add to settings.py TEMPLATES_LOADERS:: +Or for Django <= 1.7, add to settings.py TEMPLATES_LOADERS:: TEMPLATE_LOADERS = ( 'multisite.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ) -Edit settings.py MIDDLEWARE_CLASSES:: +Edit settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: - MIDDLEWARE_CLASSES = ( + MIDDLEWARE = ( ... 'multisite.middleware.DynamicSiteMiddleware', ... @@ -115,6 +115,26 @@ include wildcards.:: # will match any host ending '.example.com' +Development Environments +------------------------ +Multisite returns a valid Alias when in "development mode" (defaulting to the +alias associated with the default SiteID. + +Development mode is either: + - Running tests, i.e. manage.py test + - Running locally in settings.DEBUG = True, where the hostname is a + top-level name, i.e. localhost + +In order to have multisite use aliases in local environments, add entries to +your local etc/hosts file to match aliases in your applications. E.g. :: + + 127.0.0.1 example.com + 127.0.0.1 examplealias.com + +And access your application at example.com:8000 or examplealias.com:8000 instead of +the usual localhost:8000. + + Domain fallbacks ---------------- @@ -156,9 +176,9 @@ Cross-domain cookies In order to support `cross-domain cookies`_, for purposes like single-sign-on, prepend the following to the top of -settings.py MIDDLEWARE_CLASSES:: +settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: - MIDDLEWARE_CLASSES = ( + MIDDLEWARE = ( 'multisite.middleware.CookieDomainMiddleware', ... ) From dda2c3fcbb9b6926a626a0db0534248fbf5d76bf Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Fri, 20 Apr 2018 15:36:55 +0100 Subject: [PATCH 189/196] Bump to version 1.5.0 --- CHANGELOG.rst | 8 ++++++++ multisite/__init__.py | 3 +-- multisite/__version__.py | 1 + setup.py | 11 ++++++++--- 4 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 multisite/__version__.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5218b99..b7e45cb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,14 @@ Release Notes ============= +1.5.0 +----- +* Support Django 2.0 (PR #47 and #60) +* Remove code for Django < 1.7 +* Remove obsolete PathAssistedCurrentSiteManager +* Remove obsolete template.loaders.cached +* Update README to better describe local development setup + 1.4.1 ----- * Specify Django <2.0 in setup.py diff --git a/multisite/__init__.py b/multisite/__init__.py index 9f61133..56c7d5e 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -1,3 +1,2 @@ from .threadlocals import SiteDomain, SiteID - -VERSION = "1.4.0" +from .__version__ import __version__ diff --git a/multisite/__version__.py b/multisite/__version__.py new file mode 100644 index 0000000..77f1c8e --- /dev/null +++ b/multisite/__version__.py @@ -0,0 +1 @@ +__version__ = '1.5.0' diff --git a/setup.py b/setup.py index 9100255..ce287bd 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ +import os import sys from setuptools import find_packages, setup -import os _dir_ = os.path.dirname(__file__) @@ -17,11 +17,16 @@ def long_description(): with open(os.path.join(_dir_, 'README.rst')) as f: return f.read() +here = os.path.abspath(_dir_) +version = {} +with open(os.path.join(here, 'multisite', '__version__.py')) as f: + exec(f.read(), version) + files = ["multisite/test_templates/*"] setup(name='django-multisite', - version='1.4.1', + version=version['__version__'], description='Serve multiple sites from a single Django application', long_description=long_description(), author='Leonid S Shestera', @@ -52,4 +57,4 @@ def long_description(): 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries', 'Topic :: Utilities'], -) + ) From 618ba14cfb4e4ce2cb6510e4b1c2cc96ccf90407 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Fri, 20 Apr 2018 15:54:56 +0100 Subject: [PATCH 190/196] Fix typos in README --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ffa08aa..30f649b 100644 --- a/README.rst +++ b/README.rst @@ -122,8 +122,7 @@ alias associated with the default SiteID. Development mode is either: - Running tests, i.e. manage.py test - - Running locally in settings.DEBUG = True, where the hostname is a - top-level name, i.e. localhost + - Running locally in settings.DEBUG = True, where the hostname is a top-level name, i.e. localhost In order to have multisite use aliases in local environments, add entries to your local etc/hosts file to match aliases in your applications. E.g. :: @@ -167,6 +166,7 @@ domains, such as:: The template loader will also look for templates in a folder specified by the optional MULTISITE_DEFAULT_TEMPLATE_DIR setting, e.g.:: + templates/multisite_templates From 0d1e94a2f43b50b629d9b67ef05c1f0d6304e6c5 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Mon, 3 Sep 2018 14:42:21 +0100 Subject: [PATCH 191/196] Fix KeyError from _get_site_by_id With multiple processes, we can run into a race condition where the site cache is invalidated while another process attempts to access it. This patches _get_site_by_id to retrieve the site from the database if it isn't found by when _get_site_by_id returns --- multisite/hacks.py | 18 ++++++++++++++---- multisite/tests.py | 6 +++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 963cb4a..3cb0199 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -22,6 +22,7 @@ def use_framework_for_site_cache(): # Patch the SiteManager class models.SiteManager.clear_cache = SiteManager_clear_cache + models.SiteManager._get_site_by_id = SiteManager_get_site_by_id # Hooks to update SiteCache post_save.connect(site_cache._site_changed_hook, sender=models.Site) @@ -35,6 +36,18 @@ def SiteManager_clear_cache(self): models.SITE_CACHE.clear() +# Override SiteManager._get_site_by_id +def SiteManager_get_site_by_id(self, site_id): + """Patch _get_site_by_id to return the site from the DB if necessary.""" + models = sys.modules.get(self.__class__.__module__) + if site_id not in models.SITE_CACHE: + site = self.get(pk=site_id) + models.SITE_CACHE[site_id] = site + # there can be a race condition between processes; if we can't find the site + # in the cache at this point, fetch it from the DB + return models.SITE_CACHE[site_id] or self.get(pk=site_id) + + class SiteCache(object): """Wrapper for SITE_CACHE that assigns a key_prefix.""" @@ -119,10 +132,7 @@ def __init__(self, cache): def __getitem__(self, key): """x.__getitem__(y) <==> x[y]""" hash(key) # Raise TypeError if unhashable - result = self._cache.get(key=key) - if result is None: - raise KeyError(key) - return result + return self._cache.get(key=key) def __setitem__(self, key, value): """x.__setitem__(i, y) <==> x[i]=y""" diff --git a/multisite/tests.py b/multisite/tests.py index 101fed9..fae5b30 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -351,7 +351,7 @@ def save(self, *args, **kwargs): settings.SITE_ID.set(self.site.id) def test_get_current(self): - self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + self.assertIsNone(self.cache.__getitem__(self.site.id)) # Populate cache self.assertEqual(Site.objects.get_current(), self.site) self.assertEqual(self.cache[self.site.id], self.site) @@ -367,7 +367,7 @@ def test_get_current(self): version=100)) # Wrong key version 3 # Clear cache self.cache.clear() - self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + self.assertIsNone(self.cache.__getitem__(self.site.id)) self.assertEqual(self.cache.get(key=self.site.id, default='Cleared'), 'Cleared') @@ -394,7 +394,7 @@ def test_delete_site(self): self.assertEqual(Site.objects.get_current().domain, self.site.domain) # Delete site self.site.delete() - self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + self.assertIsNone(self.cache.__getitem__(self.site.id)) @override_settings(CACHE_MULTISITE_KEY_PREFIX="__test__") def test_multisite_key_prefix(self): From 10366f80de6ad38e827c6642ebad3e31324bc047 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 4 Sep 2018 09:50:21 +0100 Subject: [PATCH 192/196] Remove unnecessary cache type warnings --- multisite/hacks.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index 3cb0199..c252c6f 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -2,7 +2,6 @@ from __future__ import absolute_import import sys -from warnings import warn from django.conf import settings from django.db.models.signals import post_save, post_delete @@ -62,28 +61,12 @@ def __init__(self, cache=None): settings.CACHES[cache_alias].get('KEY_PREFIX', '') ) cache = caches[cache_alias] - self._warn_cache_backend(cache, cache_alias) else: self._key_prefix = getattr( settings, 'CACHE_MULTISITE_KEY_PREFIX', cache.key_prefix ) self._cache = cache - def _warn_cache_backend(self, cache, cache_alias): - from django.core.cache.backends.dummy import DummyCache - from django.core.cache.backends.db import DatabaseCache - from django.core.cache.backends.filebased import FileBasedCache - from django.core.cache.backends.locmem import LocMemCache - - if isinstance(cache, (LocMemCache, FileBasedCache)): - warn(("'%s' cache is %s, which may cause stale caches." % - (cache_alias, type(cache).__name__)), - RuntimeWarning, stacklevel=3) - elif isinstance(cache, (DatabaseCache, DummyCache)): - warn(("'%s' is %s, causing extra database queries." % - (cache_alias, type(cache).__name__)), - RuntimeWarning, stacklevel=3) - def _get_cache_key(self, key): return 'sites.%s.%s' % (self.key_prefix, key) From 5ceea6dfa4f01ed667b4d016c9dff93205d02835 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Tue, 4 Sep 2018 09:51:43 +0100 Subject: [PATCH 193/196] Remove deprecated SiteIDHook --- multisite/tests.py | 21 --------------------- multisite/threadlocals.py | 7 ------- 2 files changed, 28 deletions(-) diff --git a/multisite/tests.py b/multisite/tests.py index fae5b30..8f0e8bf 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -44,7 +44,6 @@ from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware from .models import Alias -from .threadlocals import SiteIDHook class RequestFactory(DjangoRequestFactory): @@ -478,14 +477,12 @@ def test_compare_site_ids(self): def test_compare_differing_types(self): self.site_id.set(1) - # SiteIDHook int self.assertNotEqual(self.site_id, '1') self.assertFalse(self.site_id == '1') self.assertTrue(self.site_id < '1') self.assertTrue(self.site_id <= '1') self.assertFalse(self.site_id > '1') self.assertFalse(self.site_id >= '1') - # int SiteIDHook self.assertNotEqual('1', self.site_id) self.assertFalse('1' == self.site_id) self.assertFalse('1' < self.site_id) @@ -547,24 +544,6 @@ def test_deferred_site(self): site.id) -@pytest.mark.django_db -class TestSiteIDHook(TestCase): - def test_deprecation_warning(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') - threadlocals.__warningregistry__ = {} - SiteIDHook() - self.assertTrue(w) - self.assertTrue(issubclass(w[-1].category, DeprecationWarning)) - - def test_default_value(self): - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - site_id = SiteIDHook() - self.assertEqual(site_id.default, 1) - self.assertEqual(int(site_id), 1) - - @pytest.mark.django_db class AliasTest(TestCase): def setUp(self): diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 300dbe6..ec57c5f 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -154,10 +154,3 @@ def get_default(self): qset = Site.objects.only('id') self.default = qset.get(domain=self.default_domain).id return self.default - - -def SiteIDHook(): - """Deprecated: Use multisite.SiteID(default=1) for identical behaviour.""" - warn('Use multisite.SiteID instead of multisite.threadlocals.SiteIDHook', - DeprecationWarning, stacklevel=2) - return SiteID(default=1) From ddff3ec7e30183a56cf6cdc3b19d71d83d9cf1e8 Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Mon, 3 Sep 2018 15:29:52 +0100 Subject: [PATCH 194/196] Drop support for Django 1.7, bump version to 1.6.0 --- CHANGELOG.rst | 7 +++++++ README.rst | 10 +--------- multisite/__version__.py | 2 +- multisite/tests.py | 20 +++----------------- setup.py | 4 ++-- tox.ini | 13 ++++++------- 6 files changed, 20 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b7e45cb..08e4a6a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,13 @@ Release Notes ============= +1.6.0 +----- +* Fix KeyError from _get_site_by_id +* Drop support for Django 1.7 +* Remove unnecessary cache type warnings +* Remove deprecated SiteIDHook + 1.5.0 ----- * Support Django 2.0 (PR #47 and #60) diff --git a/README.rst b/README.rst index 30f649b..67f39b3 100644 --- a/README.rst +++ b/README.rst @@ -62,13 +62,6 @@ Add to your settings.py TEMPLATES loaders in the OPTIONS section:: ... ] -Or for Django <= 1.7, add to settings.py TEMPLATES_LOADERS:: - - TEMPLATE_LOADERS = ( - 'multisite.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ) - Edit settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: MIDDLEWARE = ( @@ -155,8 +148,7 @@ settings.py:: Templates --------- If required, create template subdirectories for domain level templates (in a -location specified in settings.TEMPLATES['DIRS'], or in settings.TEMPLATE_DIRS -for Django <=1.7). +location specified in settings.TEMPLATES['DIRS']. Multisite's template loader will look for templates in folders with the names of domains, such as:: diff --git a/multisite/__version__.py b/multisite/__version__.py index 77f1c8e..bcd8d54 100644 --- a/multisite/__version__.py +++ b/multisite/__version__.py @@ -1 +1 @@ -__version__ = '1.5.0' +__version__ = '1.6.0' diff --git a/multisite/tests.py b/multisite/tests.py index 8f0e8bf..89aef9e 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -1027,21 +1027,11 @@ class TemplateLoaderTests(TestCase): def test_get_template_multisite_default_dir(self): template = get_template("test.html") - if django.VERSION < (1, 8): # <1.7 render() requires Context instance - from django.template.context import Context - self.assertEqual(template.render(context=Context()), "Test!") - else: - self.assertEqual(template.render(), "Test!") + self.assertEqual(template.render(), "Test!") def test_domain_template(self): template = get_template("example.html") - if django.VERSION < (1, 8): # <1.7 render() requires Context instance - from django.template.context import Context - self.assertEqual( - template.render(context=Context()), "Test example.com template" - ) - else: - self.assertEqual(template.render(), "Test example.com template") + self.assertEqual(template.render(), "Test example.com template") def test_get_template_old_settings(self): # tests that we can still get to the template filesystem loader with @@ -1064,11 +1054,7 @@ def test_get_template_old_settings(self): ] ): template = get_template("test.html") - if django.VERSION < (1, 8): # <1.7 render() requires Context instance - from django.template.context import Context - self.assertEqual(template.render(context=Context()), "Test!") - else: - self.assertEqual(template.render(), "Test!") + self.assertEqual(template.render(), "Test!") class UpdatePublicSuffixListCommandTestCase(TestCase): diff --git a/setup.py b/setup.py index ce287bd..b019ea7 100644 --- a/setup.py +++ b/setup.py @@ -7,9 +7,9 @@ if sys.version_info < (3, 4): - install_requires = ['Django>=1.7,<2.0', 'tldextract>=1.2'] + install_requires = ['Django>=1.8,<2.0', 'tldextract>=1.2'] else: - install_requires = ['Django>=1.7,<2.1', 'tldextract>=1.2'] + install_requires = ['Django>=1.8,<2.1', 'tldextract>=1.2'] def long_description(): diff --git a/tox.ini b/tox.ini index 2547b8f..8ccd708 100644 --- a/tox.ini +++ b/tox.ini @@ -8,10 +8,10 @@ setenv= PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} usedevelop = True envlist = - py36-django{2.0, 1.11} - py35-django{2.0, 1.11,1.10,1.9,1.8} - py34-django{2.0, 1.11,1.10,1.9,1.8,1.7} - py27-django{1.11,1.10,1.9,1.8,1.7} + py36-django{2.1,2.0,1.11} + py35-django{2.1,2.0,1.11,1.10,1.9,1.8} + py34-django{2.0,1.11,1.10,1.9,1.8} + py27-django{1.11,1.10,1.9,1.8} [testenv] commands = pytest --cov --cov-config .coveragerc --pyargs multisite @@ -20,13 +20,12 @@ deps = pytest pytest-cov pytest-pythonpath + pytest-django py27: mock - django2.0,django1.11,django1.10,django1.9,django1.8: pytest-django + django2.1: Django>=2.1,<2.2 django2.0: Django>=2.0,<2.1 django1.11: Django>=1.11,<2.0 django1.10: Django>=1.10,<1.11 django1.9: Django>=1.9,<1.10 django1.8: Django>=1.8,<1.9 - django1.7: pytest-django<3.2.0 - django1.7: Django>=1.7,<1.8 From eec65e9cd197971cdd46dbb4cd0946197268db5e Mon Sep 17 00:00:00 2001 From: Becky Smith Date: Thu, 6 Sep 2018 08:48:21 +0100 Subject: [PATCH 195/196] Better patch for _get_site_by_id Avoid the race condition by fetching the site from the cache at the beginning of _get_site_by_id. --- multisite/hacks.py | 17 +++++++++++------ multisite/tests.py | 6 +++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/multisite/hacks.py b/multisite/hacks.py index c252c6f..d0e2002 100644 --- a/multisite/hacks.py +++ b/multisite/hacks.py @@ -37,14 +37,16 @@ def SiteManager_clear_cache(self): # Override SiteManager._get_site_by_id def SiteManager_get_site_by_id(self, site_id): - """Patch _get_site_by_id to return the site from the DB if necessary.""" + """ + Patch _get_site_by_id to retrieve the site from the cache at the + beginning of the method to avoid a race condition. + """ models = sys.modules.get(self.__class__.__module__) - if site_id not in models.SITE_CACHE: + site = models.SITE_CACHE.get(site_id) + if site is None: site = self.get(pk=site_id) models.SITE_CACHE[site_id] = site - # there can be a race condition between processes; if we can't find the site - # in the cache at this point, fetch it from the DB - return models.SITE_CACHE[site_id] or self.get(pk=site_id) + return site class SiteCache(object): @@ -115,7 +117,10 @@ def __init__(self, cache): def __getitem__(self, key): """x.__getitem__(y) <==> x[y]""" hash(key) # Raise TypeError if unhashable - return self._cache.get(key=key) + result = self._cache.get(key=key) + if result is None: + raise KeyError(key) + return result def __setitem__(self, key, value): """x.__setitem__(i, y) <==> x[i]=y""" diff --git a/multisite/tests.py b/multisite/tests.py index 89aef9e..6eaa67b 100644 --- a/multisite/tests.py +++ b/multisite/tests.py @@ -350,7 +350,7 @@ def save(self, *args, **kwargs): settings.SITE_ID.set(self.site.id) def test_get_current(self): - self.assertIsNone(self.cache.__getitem__(self.site.id)) + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) # Populate cache self.assertEqual(Site.objects.get_current(), self.site) self.assertEqual(self.cache[self.site.id], self.site) @@ -366,7 +366,7 @@ def test_get_current(self): version=100)) # Wrong key version 3 # Clear cache self.cache.clear() - self.assertIsNone(self.cache.__getitem__(self.site.id)) + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) self.assertEqual(self.cache.get(key=self.site.id, default='Cleared'), 'Cleared') @@ -393,7 +393,7 @@ def test_delete_site(self): self.assertEqual(Site.objects.get_current().domain, self.site.domain) # Delete site self.site.delete() - self.assertIsNone(self.cache.__getitem__(self.site.id)) + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) @override_settings(CACHE_MULTISITE_KEY_PREFIX="__test__") def test_multisite_key_prefix(self): From a9106fadd9d34726565aba8c4103a9807e1da2b7 Mon Sep 17 00:00:00 2001 From: Zohreh Date: Mon, 25 Mar 2019 14:24:20 -0400 Subject: [PATCH 196/196] compatibility with dajngo 2.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b019ea7..89ee7e7 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ if sys.version_info < (3, 4): install_requires = ['Django>=1.8,<2.0', 'tldextract>=1.2'] else: - install_requires = ['Django>=1.8,<2.1', 'tldextract>=1.2'] + install_requires = ['Django>=1.8,<=2.2', 'tldextract>=1.2'] def long_description():