├── .coveragerc ├── .gitignore ├── .hgtags ├── .travis.yml ├── CHANGELOG.rst ├── MANIFEST.in ├── README.rst ├── multisite ├── __init__.py ├── __version__.py ├── admin.py ├── forms.py ├── hacks.py ├── hosts.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── update_public_suffix_list.py ├── managers.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── template │ ├── __init__.py │ └── loaders │ │ ├── __init__.py │ │ └── filesystem.py ├── template_loader.py ├── test_settings.py ├── test_templates │ ├── example.com │ │ └── example.html │ └── multisite_templates │ │ └── test.html ├── tests.py └── threadlocals.py ├── pytest.ini ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=multisite 3 | omit=multisite/tests.py,multisite/migrations/*,multisite/south_migrations/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .cache/ 4 | .eggs/ 5 | .installed.cfg 6 | bin 7 | develop-eggs 8 | django_multisite.egg-info/ 9 | dist 10 | downloads 11 | eggs 12 | parts 13 | MANIFEST 14 | multisite/*.egg-info 15 | .coverage 16 | .tox/ 17 | .pytest_cache/ 18 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 2 | 444842039a404fe6ffb5124865df2e5ab26e69a3 version-0.2.1 3 | 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 4 | 0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 5 | c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 6 | be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 7 | b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 8 | ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 9 | 3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 10 | 2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 11 | 1f497c216209683af3bc20bb15bb29ef305f7ca8 version-1.1.0 12 | 16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 13 | 16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 14 | efe5daef3c883dc309e64e6fa1ab88bb69098ab5 version-1.3.0 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.5" 7 | - "3.6" 8 | 9 | install: 10 | - pip install tox-travis 11 | - pip install coverage 12 | - pip install python-coveralls 13 | 14 | script: 15 | - tox 16 | 17 | after_success: 18 | - coveralls 19 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Release Notes 3 | ============= 4 | 5 | 1.7.0 6 | ----- 7 | 8 | * Support Django 2.1 and 2.2 (PR #59 - thanks @ribeiroti!) 9 | * Replace queryset with get_queryset in admin (PR #61 - thanks @vit-ivanov!) 10 | 11 | 1.6.0 12 | ----- 13 | * Fix KeyError from _get_site_by_id 14 | * Drop support for Django 1.7 15 | * Remove unnecessary cache type warnings 16 | * Remove deprecated SiteIDHook 17 | 18 | 1.5.0 19 | ----- 20 | * Support Django 2.0 (PR #47 and #60) 21 | * Remove code for Django < 1.7 22 | * Remove obsolete PathAssistedCurrentSiteManager 23 | * Remove obsolete template.loaders.cached 24 | * Update README to better describe local development setup 25 | 26 | 1.4.1 27 | ----- 28 | * Specify Django <2.0 in setup.py 29 | * Drop support for python 3.3 30 | 31 | 1.4.0 32 | ----- 33 | * Support Django 1.10 (PR #38) and 1.11 34 | * Support Python 3 35 | * Remove support for Django <1.7 36 | * Use setuptools over distutils, and integrate the tests with them 37 | * Use pytest and tox for testing 38 | * Set up CI with travis 39 | * Set up coverage and coveralls.io 40 | * Document MULTISITE_EXTRA_HOSTS in README 41 | 42 | 1.3.1 43 | ----- 44 | 45 | * Add default for SiteID in the README (PR #31) 46 | * Respect the CACHE_MULTISITE_ALIAS in SiteCache (PR #34) 47 | * Replace deprecated ExtractResult().tld with .suffic (PR #32) 48 | 49 | 1.3.0 50 | ----- 51 | 52 | * Fix tempfile issue with update_public_suffix_list command 53 | * Support for tldextract version >= 2.0 54 | 55 | 1.2.6 56 | ---- 57 | 58 | * Pin the tldextract dependency to version < 2.0, which breaks API. 59 | 60 | 1.2.5 61 | ---- 62 | 63 | * Make template loading more resilient to changes in django (thanks to jbazik for the contribution) 64 | 65 | 1.2.4 66 | ----- 67 | 68 | * Fix domain validation so it's called after the pre_save signal 69 | 70 | 1.2.3 71 | ----- 72 | 73 | * Fix a broken test, due to a django uniqueness constraint in 1.9 74 | 75 | 1.2.2 76 | ----- 77 | 78 | * Fix for 1.9: change the return type of filesystem template loader's get_template_sources() 79 | 80 | 1.2.1 81 | ----- 82 | 83 | * Remove django.utils.unittest (deprecated in 1.9) 84 | * Use post_migrate instead of post_syncdb in > 1.7 85 | 86 | 1.2.0 87 | ----- 88 | 89 | * We now support Django 1.9 90 | * Following deprecation in django, all get_cache methods have been replaced caches. 91 | 92 | 1.1.0 93 | ----- 94 | 95 | * We now support post-South Django 1.7 native migrations. 96 | 97 | 1.0.0 98 | ----- 99 | 100 | * 1.0 release. API stability promised from now on. 101 | * 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. 102 | 103 | 0.5.1 104 | ----- 105 | 106 | * Add key prefix tests 107 | 108 | 0.5.0 109 | ----- 110 | 111 | * Allow use of cache key prefixes from the CACHES settings if CACHE_MULTISITE_KEY_PREFIX not set 112 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include INSTALL.txt 2 | include README.rst 3 | graft multisite 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/ecometrica/django-multisite.svg?branch=master 2 | :target: https://travis-ci.org/ecometrica/django-multisite?branch=master 3 | .. image:: https://coveralls.io/repos/github/ecometrica/django-multisite/badge.svg?branch=master 4 | :target: https://coveralls.io/github/ecometrica/django-multisite?branch=master 5 | 6 | 7 | README 8 | ====== 9 | 10 | Install with pip:: 11 | 12 | pip install django-multisite 13 | 14 | 15 | Or get the code via git:: 16 | 17 | git clone git://github.com/ecometrica/django-multisite.git django-multisite 18 | 19 | Then run:: 20 | 21 | python setup.py install 22 | 23 | Or add the django-multisite/multisite folder to your PYTHONPATH. 24 | 25 | If you wish to contribute, instead run:: 26 | 27 | python setup.py develop 28 | 29 | 30 | Quickstart 31 | ---------- 32 | 33 | Replace your SITE_ID in settings.py to:: 34 | 35 | from multisite import SiteID 36 | SITE_ID = SiteID(default=1) 37 | 38 | Add these to your INSTALLED_APPS:: 39 | 40 | INSTALLED_APPS = [ 41 | ... 42 | 'django.contrib.sites', 43 | 'multisite', 44 | ... 45 | ] 46 | 47 | Add to your settings.py TEMPLATES loaders in the OPTIONS section:: 48 | 49 | TEMPLATES = [ 50 | ... 51 | { 52 | ... 53 | 'DIRS': {...} 54 | 'OPTIONS': { 55 | 'loaders': ( 56 | 'multisite.template.loaders.filesystem.Loader', 57 | 'django.template.loaders.app_directories.Loader', 58 | ) 59 | } 60 | ... 61 | } 62 | ... 63 | ] 64 | 65 | Edit settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: 66 | 67 | MIDDLEWARE = ( 68 | ... 69 | 'multisite.middleware.DynamicSiteMiddleware', 70 | ... 71 | ) 72 | 73 | Append to settings.py, in order to use a custom cache that can be 74 | safely cleared:: 75 | 76 | # The cache connection to use for django-multisite. 77 | # Default: 'default' 78 | CACHE_MULTISITE_ALIAS = 'multisite' 79 | 80 | # The cache key prefix that django-multisite should use. 81 | # If not set, defaults to the KEY_PREFIX used in the defined 82 | # CACHE_MULTISITE_ALIAS or the default cache (empty string if not set) 83 | CACHE_MULTISITE_KEY_PREFIX = '' 84 | 85 | If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* 86 | ``'multisite'``, add a separate backend to settings.py CACHES:: 87 | 88 | CACHES = { 89 | 'default': { 90 | ... 91 | }, 92 | 'multisite': { 93 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 94 | 'TIMEOUT': 60 * 60 * 24, # 24 hours 95 | ... 96 | }, 97 | } 98 | 99 | 100 | Multisite determines the ALLOWED_HOSTS by checking all Alias domains. You can 101 | also set the MULTISITE_EXTRA_HOSTS to include additional hosts. This can 102 | include wildcards.:: 103 | 104 | MULTISITE_EXTRA_HOSTS = ['example.com'] 105 | # will match the single additional host 106 | 107 | MULTISITE_EXTRA_HOSTS = ['.example.com'] 108 | # will match any host ending '.example.com' 109 | 110 | 111 | Development Environments 112 | ------------------------ 113 | Multisite returns a valid Alias when in "development mode" (defaulting to the 114 | alias associated with the default SiteID. 115 | 116 | Development mode is either: 117 | - Running tests, i.e. manage.py test 118 | - Running locally in settings.DEBUG = True, where the hostname is a top-level name, i.e. localhost 119 | 120 | In order to have multisite use aliases in local environments, add entries to 121 | your local etc/hosts file to match aliases in your applications. E.g. :: 122 | 123 | 127.0.0.1 example.com 124 | 127.0.0.1 examplealias.com 125 | 126 | And access your application at example.com:8000 or examplealias.com:8000 instead of 127 | the usual localhost:8000. 128 | 129 | 130 | Domain fallbacks 131 | ---------------- 132 | 133 | By default, if the domain name is unknown, multisite will respond with 134 | an HTTP 404 Not Found error. To change this behaviour, add to 135 | settings.py:: 136 | 137 | # The view function or class-based view that django-multisite will 138 | # use when it cannot match the hostname with a Site. This can be 139 | # the name of the function or the function itself. 140 | # Default: None 141 | MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView 142 | 143 | # Keyword arguments for the MULTISITE_FALLBACK view. 144 | # Default: {} 145 | MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', 146 | 'permanent': False} 147 | 148 | Templates 149 | --------- 150 | If required, create template subdirectories for domain level templates (in a 151 | location specified in settings.TEMPLATES['DIRS']. 152 | 153 | Multisite's template loader will look for templates in folders with the names of 154 | domains, such as:: 155 | 156 | templates/example.com 157 | 158 | 159 | The template loader will also look for templates in a folder specified by the 160 | optional MULTISITE_DEFAULT_TEMPLATE_DIR setting, e.g.:: 161 | 162 | templates/multisite_templates 163 | 164 | 165 | Cross-domain cookies 166 | -------------------- 167 | 168 | In order to support `cross-domain cookies`_, 169 | for purposes like single-sign-on, 170 | prepend the following to the top of 171 | settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: 172 | 173 | MIDDLEWARE = ( 174 | 'multisite.middleware.CookieDomainMiddleware', 175 | ... 176 | ) 177 | 178 | CookieDomainMiddleware will consult the `Public Suffix List`_ 179 | for effective top-level domains. 180 | It caches this file 181 | in the system's default temporary directory 182 | as ``effective_tld_names.dat``. 183 | To change this in settings.py:: 184 | 185 | MULTISITE_PUBLIC_SUFFIX_LIST_CACHE = '/path/to/multisite_tld.dat' 186 | 187 | By default, 188 | any cookies without a domain set 189 | will be reset to allow \*.domain.tld. 190 | To change this in settings.py:: 191 | 192 | MULTISITE_COOKIE_DOMAIN_DEPTH = 1 # Allow only *.subdomain.domain.tld 193 | 194 | In order to fetch a new version of the list, 195 | run:: 196 | 197 | manage.py update_public_suffix_list 198 | 199 | .. _cross-domain cookies: http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path 200 | .. _Public Suffix List: http://publicsuffix.org/ 201 | 202 | 203 | Tests 204 | ----- 205 | 206 | To run the tests:: 207 | 208 | python setup.py test 209 | 210 | Or:: 211 | 212 | pytest 213 | 214 | Before deploying a change, to verify it has not broken anything by running:: 215 | 216 | tox 217 | 218 | This runs the tests under every supported combination of Django and Python. -------------------------------------------------------------------------------- /multisite/__init__.py: -------------------------------------------------------------------------------- 1 | from .threadlocals import SiteDomain, SiteID 2 | from .__version__ import __version__ 3 | -------------------------------------------------------------------------------- /multisite/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.7.0' 2 | -------------------------------------------------------------------------------- /multisite/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | from django.contrib import admin 5 | from django.contrib.admin.views.main import ChangeList 6 | from django.contrib.sites.models import Site 7 | from django.contrib.sites.admin import SiteAdmin 8 | 9 | from .forms import SiteForm 10 | from .models import Alias 11 | 12 | 13 | class AliasAdmin(admin.ModelAdmin): 14 | """Admin for Alias model.""" 15 | list_display = ('domain', 'site', 'is_canonical', 'redirect_to_canonical') 16 | list_filter = ('is_canonical', 'redirect_to_canonical') 17 | ordering = ('domain',) 18 | raw_id_fields = ('site',) 19 | readonly_fields = ('is_canonical',) 20 | search_fields = ('domain',) 21 | 22 | admin.site.register(Alias, AliasAdmin) 23 | 24 | 25 | class AliasInline(admin.TabularInline): 26 | """Inline for Alias model, showing non-canonical aliases.""" 27 | model = Alias 28 | extra = 1 29 | ordering = ('domain',) 30 | 31 | def get_queryset(self, request): 32 | """Returns only non-canonical aliases.""" 33 | qs = self.model.aliases.get_queryset() 34 | ordering = self.ordering or () 35 | if ordering: 36 | qs = qs.order_by(*ordering) 37 | return qs 38 | 39 | # HACK: Monkeypatch AliasInline into SiteAdmin 40 | SiteAdmin.inlines = type(SiteAdmin.inlines)([AliasInline]) + SiteAdmin.inlines 41 | 42 | # HACK: Monkeypatch Alias validation into SiteForm 43 | SiteAdmin.form = SiteForm 44 | 45 | 46 | class MultisiteChangeList(ChangeList): 47 | """ 48 | A ChangeList like the built-in admin one, but it excludes site filters for 49 | sites you're not associated with, unless you're a super-user. 50 | 51 | At this point, it's probably fragile, given its reliance on Django 52 | internals. 53 | """ 54 | def get_filters(self, request, *args, **kwargs): 55 | """ 56 | This might be considered a fragile function, since it relies on a 57 | fair bit of Django's internals. 58 | """ 59 | get_filters = super(MultisiteChangeList, self).get_filters 60 | filter_specs, has_filter_specs = get_filters(request, *args, **kwargs) 61 | if request.user.is_superuser or not has_filter_specs: 62 | return filter_specs, has_filter_specs 63 | new_filter_specs = [] 64 | profile = request.user.get_profile() 65 | user_sites = frozenset(profile.sites.values_list("pk", "domain")) 66 | for filter_spec in filter_specs: 67 | try: 68 | try: 69 | remote_model = filter_spec.field.remote_field.model 70 | except AttributeError: 71 | remote_model = filter_spec.field.rel.to 72 | except AttributeError: 73 | new_filter_specs.append(filter_spec) 74 | continue 75 | if remote_model is not Site: 76 | new_filter_specs.append(filter_spec) 77 | continue 78 | lookup_choices = frozenset(filter_spec.lookup_choices) & user_sites 79 | if len(lookup_choices) > 1: 80 | # put the choices back into the form they came in 81 | filter_spec.lookup_choices = list(lookup_choices) 82 | filter_spec.lookup_choices.sort() 83 | new_filter_specs.append(filter_spec) 84 | 85 | return new_filter_specs, bool(new_filter_specs) 86 | 87 | 88 | class MultisiteModelAdmin(admin.ModelAdmin): 89 | """ 90 | A very helpful ModelAdmin class for handling multi-site django 91 | applications. 92 | """ 93 | 94 | filter_sites_by_current_object = False 95 | 96 | def get_queryset(self, request): 97 | """ 98 | Filters lists of items to items belonging to sites assigned to the 99 | current member. 100 | 101 | Additionally, for cases where the field containing a reference 102 | to 'site' or 'sites' isn't immediate -- one can supply the 103 | ModelAdmin class with a list of fields to check the site of: 104 | 105 | - multisite_filter_fields 106 | A list of paths to a 'site' or 'sites' field on a related model to 107 | filter the queryset on. 108 | 109 | (As long as you're not a superuser) 110 | """ 111 | qs = super(MultisiteModelAdmin, self).queryset(request) 112 | if request.user.is_superuser: 113 | return qs 114 | 115 | user_sites = request.user.get_profile().sites.all() 116 | if hasattr(qs.model, "site"): 117 | qs = qs.filter(site__in=user_sites) 118 | elif hasattr(qs.model, "sites"): 119 | qs = qs.filter(sites__in=user_sites) 120 | 121 | if hasattr(self, "multisite_filter_fields"): 122 | for field in self.multisite_filter_fields: 123 | qkwargs = { 124 | "{field}__in".format(field=field): user_sites 125 | } 126 | qs = qs.filter(**qkwargs) 127 | 128 | return qs 129 | 130 | def add_view(self, request, form_url='', extra_context=None): 131 | if self.filter_sites_by_current_object: 132 | if hasattr(self.model, "site") or hasattr(self.model, "sites"): 133 | self.object_sites = tuple() 134 | return super(MultisiteModelAdmin, self).add_view(request, form_url, 135 | extra_context) 136 | 137 | def change_view(self, request, object_id, extra_context=None): 138 | if self.filter_sites_by_current_object: 139 | object_instance = self.get_object(request, object_id) 140 | try: 141 | self.object_sites = object_instance.sites.values_list( 142 | "pk", flat=True 143 | ) 144 | except AttributeError: 145 | try: 146 | self.object_sites = (object_instance.site.pk,) 147 | except AttributeError: 148 | pass # assume the object doesn't belong to a site 149 | return super(MultisiteModelAdmin, self).change_view(request, object_id, 150 | extra_context) 151 | 152 | def handle_multisite_foreign_keys(self, db_field, request, **kwargs): 153 | """ 154 | Filters the foreignkey queryset for fields referencing other models 155 | to those models assigned to a site belonging to the current member 156 | (if they aren't a superuser), and (optionally) belonging to the same 157 | site as the current object. 158 | 159 | Also prevents (non-super) users from assigning objects to sites that 160 | they are not members of. 161 | 162 | If the foreign key does not have a site/sites field directly, you can 163 | specify a path to a site/sites field to filter on by setting the key: 164 | 165 | - multisite_foreign_key_site_path 166 | 167 | to a dictionary pointing specific foreign key field instances 168 | from their model to the site field to filter on something 169 | like: 170 | 171 | multisite_indirect_foreign_key_path = { 172 | 'plan_instance': 'plan__site' 173 | } 174 | 175 | for a field named 'plan_instance' referencing a model with a 176 | foreign key named 'plan' having a foreign key to 'site'. 177 | 178 | To filter the FK queryset to the same sites the current object belongs 179 | to, simply set `filter_sites_by_current_object` to `True`. 180 | 181 | Caveats: 182 | 183 | 1) If you're adding an object that belongs to a site (or sites), 184 | and you've set `self.limit_sites_by_current_object = True`, 185 | then the FK fields to objects that also belong to a site won't show 186 | any objects. This is due to filtering on an empty queryset. 187 | """ 188 | 189 | if request.user.is_superuser: 190 | user_sites = Site.objects.all() 191 | else: 192 | user_sites = request.user.get_profile().sites.all() 193 | if self.filter_sites_by_current_object and \ 194 | hasattr(self, "object_sites"): 195 | sites = user_sites.filter(pk__in=self.object_sites) 196 | else: 197 | sites = user_sites 198 | 199 | try: 200 | remote_model = db_field.remote_field.model 201 | except AttributeError: 202 | remote_model = db_field.rel.to 203 | if hasattr(remote_model, "site"): 204 | kwargs["queryset"] = remote_model._default_manager.filter( 205 | site__in=user_sites 206 | ) 207 | if hasattr(remote_model, "sites"): 208 | kwargs["queryset"] = remote_model._default_manager.filter( 209 | sites__in=user_sites 210 | ) 211 | if db_field.name == "site" or db_field.name == "sites": 212 | kwargs["queryset"] = user_sites 213 | if hasattr(self, "multisite_indirect_foreign_key_path") and \ 214 | db_field.name in self.multisite_indirect_foreign_key_path.keys(): 215 | fkey = self.multisite_indirect_foreign_key_path[db_field.name] 216 | kwargs["queryset"] = remote_model._default_manager.filter( 217 | **{fkey: user_sites} 218 | ) 219 | 220 | return kwargs 221 | 222 | def formfield_for_foreignkey(self, db_field, request, **kwargs): 223 | kwargs = self.handle_multisite_foreign_keys(db_field, request, 224 | **kwargs) 225 | return super(MultisiteModelAdmin, self).formfield_for_foreignkey( 226 | db_field, request, **kwargs 227 | ) 228 | 229 | def formfield_for_manytomany(self, db_field, request, **kwargs): 230 | kwargs = self.handle_multisite_foreign_keys(db_field, request, 231 | **kwargs) 232 | return super(MultisiteModelAdmin, self).formfield_for_manytomany( 233 | db_field, request, **kwargs 234 | ) 235 | 236 | def get_changelist(self, request, **kwargs): 237 | """ 238 | Restrict the site filter (if there is one) to sites you are 239 | associated with, or remove it entirely if you're just 240 | associated with one site. Unless you're a super-user, of 241 | course. 242 | """ 243 | return MultisiteChangeList 244 | -------------------------------------------------------------------------------- /multisite/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | 5 | from django.contrib.sites.admin import SiteAdmin 6 | from django.core.exceptions import ValidationError 7 | 8 | from .models import Alias 9 | 10 | 11 | class SiteForm(SiteAdmin.form): 12 | def clean_domain(self): 13 | domain = self.cleaned_data['domain'] 14 | 15 | try: 16 | alias = Alias.objects.get(domain=domain) 17 | except Alias.DoesNotExist: 18 | # New Site that doesn't clobber an Alias 19 | return domain 20 | 21 | if alias.site_id == self.instance.pk and alias.is_canonical: 22 | return domain 23 | 24 | raise ValidationError('Cannot overwrite non-canonical Alias: "%s"' % 25 | alias.domain) 26 | -------------------------------------------------------------------------------- /multisite/hacks.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | import sys 5 | 6 | from django.conf import settings 7 | from django.db.models.signals import post_save, post_delete 8 | 9 | 10 | def use_framework_for_site_cache(): 11 | """Patches sites app to use the caching framework instead of a dict.""" 12 | # This patch has to exist because SITE_CACHE is normally a dict, 13 | # which is local only to the process. When running multiple 14 | # processes, a change to a Site will not be reflected across other 15 | # ones. 16 | from django.contrib.sites import models 17 | 18 | # Patch the SITE_CACHE 19 | site_cache = SiteCache() 20 | models.SITE_CACHE = DictCache(site_cache) 21 | 22 | # Patch the SiteManager class 23 | models.SiteManager.clear_cache = SiteManager_clear_cache 24 | models.SiteManager._get_site_by_id = SiteManager_get_site_by_id 25 | 26 | # Hooks to update SiteCache 27 | post_save.connect(site_cache._site_changed_hook, sender=models.Site) 28 | post_delete.connect(site_cache._site_deleted_hook, sender=models.Site) 29 | 30 | 31 | # Override SiteManager.clear_cache so it doesn't clobber SITE_CACHE 32 | def SiteManager_clear_cache(self): 33 | """Clears the ``Site`` object cache.""" 34 | models = sys.modules.get(self.__class__.__module__) 35 | models.SITE_CACHE.clear() 36 | 37 | 38 | # Override SiteManager._get_site_by_id 39 | def SiteManager_get_site_by_id(self, site_id): 40 | """ 41 | Patch _get_site_by_id to retrieve the site from the cache at the 42 | beginning of the method to avoid a race condition. 43 | """ 44 | models = sys.modules.get(self.__class__.__module__) 45 | site = models.SITE_CACHE.get(site_id) 46 | if site is None: 47 | site = self.get(pk=site_id) 48 | models.SITE_CACHE[site_id] = site 49 | return site 50 | 51 | 52 | class SiteCache(object): 53 | """Wrapper for SITE_CACHE that assigns a key_prefix.""" 54 | 55 | def __init__(self, cache=None): 56 | from django.core.cache import caches 57 | 58 | if cache is None: 59 | cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') 60 | self._key_prefix = getattr( 61 | settings, 62 | 'CACHE_MULTISITE_KEY_PREFIX', 63 | settings.CACHES[cache_alias].get('KEY_PREFIX', '') 64 | ) 65 | cache = caches[cache_alias] 66 | else: 67 | self._key_prefix = getattr( 68 | settings, 'CACHE_MULTISITE_KEY_PREFIX', cache.key_prefix 69 | ) 70 | self._cache = cache 71 | 72 | def _get_cache_key(self, key): 73 | return 'sites.%s.%s' % (self.key_prefix, key) 74 | 75 | def _clean_site(self, site): 76 | # Force site.id to be an int, not a SiteID object. 77 | site.id = int(site.id) 78 | return site 79 | 80 | @property 81 | def key_prefix(self): 82 | return self._key_prefix 83 | 84 | def get(self, key, *args, **kwargs): 85 | return self._cache.get(key=self._get_cache_key(key), *args, **kwargs) 86 | 87 | def set(self, key, value, *args, **kwargs): 88 | self._cache.set(key=self._get_cache_key(key), 89 | value=self._clean_site(value), 90 | *args, **kwargs) 91 | 92 | def delete(self, key, *args, **kwargs): 93 | self._cache.delete(key=self._get_cache_key(key), *args, **kwargs) 94 | 95 | def __contains__(self, key, *args, **kwargs): 96 | return self._cache.__contains__(key=self._get_cache_key(key), 97 | *args, **kwargs) 98 | 99 | def clear(self, *args, **kwargs): 100 | self._cache.clear(*args, **kwargs) 101 | 102 | def _site_changed_hook(self, sender, instance, raw, *args, **kwargs): 103 | if raw: 104 | return 105 | self.set(key=instance.pk, value=instance) 106 | 107 | def _site_deleted_hook(self, sender, instance, *args, **kwargs): 108 | self.delete(key=instance.pk) 109 | 110 | 111 | class DictCache(object): 112 | """Add dictionary protocol to django.core.cache.backends.BaseCache.""" 113 | 114 | def __init__(self, cache): 115 | self._cache = cache 116 | 117 | def __getitem__(self, key): 118 | """x.__getitem__(y) <==> x[y]""" 119 | hash(key) # Raise TypeError if unhashable 120 | result = self._cache.get(key=key) 121 | if result is None: 122 | raise KeyError(key) 123 | return result 124 | 125 | def __setitem__(self, key, value): 126 | """x.__setitem__(i, y) <==> x[i]=y""" 127 | hash(key) # Raise TypeError if unhashable 128 | self._cache.set(key=key, value=value) 129 | 130 | def __delitem__(self, key): 131 | """x.__delitem__(y) <==> del x[y]""" 132 | hash(key) # Raise TypeError if unhashable 133 | self._cache.delete(key=key) 134 | 135 | def __contains__(self, item): 136 | """D.__contains__(k) -> True if D has a key k, else False""" 137 | hash(item) # Raise TypeError if unhashable 138 | return self._cache.__contains__(key=item) 139 | 140 | def clear(self): 141 | """D.clear() -> None. Remove all items from D.""" 142 | self._cache.clear() 143 | 144 | def get(self, key, default=None, version=None): 145 | """D.key(k[, d]) -> k if D has a key k, else d. Defaults to None""" 146 | hash(key) # Raise TypeError if unhashable 147 | return self._cache.get(key=key, default=default, version=version) 148 | -------------------------------------------------------------------------------- /multisite/hosts.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | from django.utils.functional import empty, SimpleLazyObject 5 | 6 | 7 | __ALL__ = ('ALLOWED_HOSTS', 'AllowedHosts') 8 | 9 | _wrapped_default = empty 10 | 11 | 12 | class IterableLazyObject(SimpleLazyObject): 13 | 14 | _wrapped_default = globals()['_wrapped_default'] 15 | 16 | def __iter__(self): 17 | if self._wrapped is self._wrapped_default: 18 | self._setup() 19 | return self._wrapped.__iter__() 20 | 21 | 22 | class AllowedHosts(object): 23 | 24 | alias_model = None 25 | 26 | def __init__(self): 27 | from django.conf import settings 28 | self.extra_hosts = getattr(settings, 'MULTISITE_EXTRA_HOSTS', []) 29 | 30 | if self.alias_model is None: 31 | from .models import Alias 32 | self.alias_model = Alias 33 | 34 | def __iter__(self): 35 | # Yielding extra hosts before actual hosts because there might be 36 | # wild cards in there that would prevent us from doing a database 37 | # query every time. 38 | for host in self.extra_hosts: 39 | yield host 40 | 41 | for host in self.alias_model.objects.values_list('domain'): 42 | yield host[0] 43 | 44 | ALLOWED_HOSTS = IterableLazyObject(lambda: AllowedHosts()) 45 | -------------------------------------------------------------------------------- /multisite/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shestera/django-multisite/97d2529c7591360d1434d6f343ae911692e45bf1/multisite/management/__init__.py -------------------------------------------------------------------------------- /multisite/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /multisite/management/commands/update_public_suffix_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | import logging 6 | import os 7 | import tempfile 8 | 9 | from django.conf import settings 10 | from django.core.management.base import BaseCommand 11 | 12 | import tldextract 13 | 14 | 15 | class Command(BaseCommand): 16 | def handle(self, **options): 17 | self.setup_logging(verbosity=options.get('verbosity', 1)) 18 | 19 | filename = getattr( 20 | settings, 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', 21 | os.path.join(tempfile.gettempdir(), 'multisite_tld.dat') 22 | ) 23 | self.log("Updating {filename}".format(filename=filename)) 24 | 25 | extract = tldextract.TLDExtract(cache_file=filename) 26 | extract.update(fetch_now=True) 27 | self.log("Done.") 28 | 29 | def setup_logging(self, verbosity): 30 | self.verbosity = int(verbosity) 31 | 32 | # Connect to tldextract's logger 33 | self.logger = logging.getLogger('tldextract') 34 | if self.verbosity < 2: 35 | self.logger.setLevel(logging.CRITICAL) 36 | 37 | def log(self, msg): 38 | self.logger.info(msg) 39 | -------------------------------------------------------------------------------- /multisite/managers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | from django.db import models 6 | from django.contrib.sites import managers 7 | from django.db.models.fields import FieldDoesNotExist 8 | from django.db.models.sql import constants 9 | 10 | 11 | class SpanningCurrentSiteManager(managers.CurrentSiteManager): 12 | """As opposed to django.contrib.sites.managers.CurrentSiteManager, this 13 | CurrentSiteManager can span multiple related models by using the django 14 | filtering syntax, namely foo__bar__baz__site. 15 | 16 | For example, let's say you have a model called Layer, which has a field 17 | called family, which points to a model called LayerFamily, which in 18 | turn has a field called site pointing to a django.contrib.sites Site 19 | model. On Layer, add the following manager: 20 | 21 | on_site = SpanningCurrentSiteManager("family__site") 22 | 23 | and it will do the proper thing.""" 24 | 25 | def _validate_field_name(self): 26 | """Given the field identifier, goes down the chain to check that 27 | each specified field 28 | a) exists, 29 | b) is of type ForeignKey or ManyToManyField 30 | 31 | If no field name is specified when instantiating 32 | SpanningCurrentSiteManager, it tries to find either 'site' or 33 | 'sites' as the site link, much like CurrentSiteManager does. 34 | """ 35 | if self._CurrentSiteManager__field_name is None: 36 | # Guess at field name 37 | field_names = self.model._meta.get_all_field_names() 38 | for potential_name in ['site', 'sites']: 39 | if potential_name in field_names: 40 | self._CurrentSiteManager__field_name = potential_name 41 | break 42 | else: 43 | raise ValueError( 44 | "%s couldn't find a field named either 'site' or 'sites' " 45 | "in %s." % 46 | (self.__class__.__name__, self.model._meta.object_name) 47 | ) 48 | 49 | fieldname_chain = self._CurrentSiteManager__field_name.split( 50 | constants.LOOKUP_SEP 51 | ) 52 | model = self.model 53 | 54 | for fieldname in fieldname_chain: 55 | # Throws an exception if anything goes bad 56 | self._validate_single_field_name(model, fieldname) 57 | model = self._get_related_model(model, fieldname) 58 | 59 | # If we get this far without an exception, everything is good 60 | self._CurrentSiteManager__is_validated = True 61 | 62 | def _validate_single_field_name(self, model, field_name): 63 | """Checks if the given fieldname can be used to make a link between a 64 | model and a site with the SpanningCurrentSiteManager class. If 65 | anything is wrong, will raises an appropriate exception, because that 66 | is what CurrentSiteManager expects.""" 67 | try: 68 | field = model._meta.get_field(field_name) 69 | if not isinstance(field, (models.ForeignKey, 70 | models.ManyToManyField)): 71 | raise TypeError( 72 | "Field %r must be a ForeignKey or ManyToManyField." 73 | % field_name 74 | ) 75 | except FieldDoesNotExist: 76 | raise ValueError( 77 | "Couldn't find a field named %r in %s." % 78 | (field_name, model._meta.object_name) 79 | ) 80 | 81 | def _get_related_model(self, model, fieldname): 82 | """Given a model and the name of a ForeignKey or ManyToManyField column 83 | as a string, returns the associated model.""" 84 | try: 85 | return model._meta.get_field(fieldname).remote_field.model 86 | except AttributeError: 87 | return model._meta.get_field(fieldname).rel.to 88 | -------------------------------------------------------------------------------- /multisite/middleware.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | import os 6 | import tempfile 7 | try: 8 | from urlparse import urlsplit, urlunsplit 9 | except ImportError: 10 | from urllib.parse import urlsplit, urlunsplit 11 | 12 | import django 13 | from django.conf import settings 14 | from django.contrib.sites.models import Site, SITE_CACHE 15 | from django.core.exceptions import DisallowedHost 16 | from django.core import mail 17 | 18 | from django.core.cache import caches 19 | 20 | try: 21 | # Django > 1.10 uses MiddlewareMixin 22 | from django.utils.deprecation import MiddlewareMixin 23 | except ImportError: 24 | MiddlewareMixin = object 25 | 26 | from django.core.exceptions import ImproperlyConfigured 27 | 28 | try: 29 | from django.urls import get_callable 30 | except ImportError: 31 | # Django < 1.10 compatibility 32 | from django.core.urlresolvers import get_callable 33 | 34 | from django.db.models.signals import pre_save, post_delete, post_init 35 | from django.http import Http404, HttpResponsePermanentRedirect 36 | 37 | from hashlib import md5 as md5_constructor 38 | 39 | from .models import Alias 40 | 41 | 42 | class DynamicSiteMiddleware(MiddlewareMixin): 43 | def __init__(self, *args, **kwargs): 44 | super(DynamicSiteMiddleware, self).__init__(*args, **kwargs) 45 | if not hasattr(settings.SITE_ID, 'set'): 46 | raise TypeError('Invalid type for settings.SITE_ID: %s' % 47 | type(settings.SITE_ID).__name__) 48 | 49 | self.cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 50 | 'default') 51 | self.key_prefix = getattr( 52 | settings, 53 | 'CACHE_MULTISITE_KEY_PREFIX', 54 | settings.CACHES[self.cache_alias].get('KEY_PREFIX', '') 55 | ) 56 | 57 | self.cache = caches[self.cache_alias] 58 | post_init.connect(self.site_domain_cache_hook, sender=Site, 59 | dispatch_uid='multisite_post_init') 60 | pre_save.connect(self.site_domain_changed_hook, sender=Site) 61 | post_delete.connect(self.site_deleted_hook, sender=Site) 62 | 63 | def get_cache_key(self, netloc): 64 | """Returns a cache key based on ``netloc``.""" 65 | netloc = md5_constructor(netloc.encode('utf-8')) 66 | return 'multisite.alias.%s.%s' % (self.key_prefix, 67 | netloc.hexdigest()) 68 | 69 | def netloc_parse(self, netloc): 70 | """ 71 | Returns ``(host, port)`` for ``netloc`` of the form ``'host:port'``. 72 | 73 | If netloc does not have a port number, ``port`` will be None. 74 | """ 75 | if ':' in netloc: 76 | return netloc.rsplit(':', 1) 77 | else: 78 | return netloc, None 79 | 80 | def get_development_alias(self, netloc): 81 | """ 82 | Returns valid Alias when in development mode. Otherwise, returns None. 83 | 84 | Development mode is either: 85 | - Running tests, i.e. manage.py test 86 | - Running locally in settings.DEBUG = True, where the hostname is 87 | a top-level name, i.e. localhost 88 | """ 89 | # When running tests, django.core.mail.outbox exists and 90 | # netloc == 'testserver' 91 | is_testserver = (hasattr(mail, 'outbox') and 92 | netloc in ('testserver', 'adminsite.com')) 93 | # When using runserver, assume that host will only have one path 94 | # component. This covers 'localhost' and your machine name. 95 | is_local_debug = (settings.DEBUG and len(netloc.split('.')) == 1) 96 | if is_testserver or is_local_debug: 97 | try: 98 | # Prefer the default SITE_ID 99 | site_id = settings.SITE_ID.get_default() 100 | return Alias.canonical.get(site=site_id) 101 | except ValueError: 102 | # Fallback to the first Site object 103 | return Alias.canonical.order_by('site')[0] 104 | 105 | def get_alias(self, netloc): 106 | """ 107 | Returns Alias matching ``netloc``. Otherwise, returns None. 108 | """ 109 | host, port = self.netloc_parse(netloc) 110 | 111 | try: 112 | alias = Alias.objects.resolve(host=host, port=port) 113 | except ValueError: 114 | alias = None 115 | 116 | if alias is None: 117 | # Running under TestCase or runserver? 118 | return self.get_development_alias(netloc) 119 | return alias 120 | 121 | def fallback_view(self, request): 122 | """ 123 | Runs the fallback view function in ``settings.MULTISITE_FALLBACK``. 124 | 125 | If ``MULTISITE_FALLBACK`` is None, raises an Http404 error. 126 | 127 | If ``MULTISITE_FALLBACK`` is callable, will treat that 128 | callable as a view that returns an HttpResponse. 129 | 130 | If ``MULTISITE_FALLBACK`` is a string, will resolve it to a 131 | view that returns an HttpResponse. 132 | 133 | In order to use a generic view that takes additional 134 | parameters, ``settings.MULTISITE_FALLBACK_KWARGS`` may be a 135 | dictionary of additional keyword arguments. 136 | """ 137 | fallback = getattr(settings, 'MULTISITE_FALLBACK', None) 138 | if fallback is None: 139 | raise Http404 140 | if callable(fallback): 141 | view = fallback 142 | else: 143 | try: 144 | view = get_callable(fallback) 145 | if django.VERSION < (1,8): 146 | # older django's get_callable falls through on error, 147 | # returning the input as output 148 | # which notably is definitely not a callable here 149 | if not callable(view): 150 | raise ImportError() 151 | except ImportError: 152 | # newer django forces this to be an error, which is tidier. 153 | # we rewrite the error to be a bit more helpful to our users. 154 | raise ImproperlyConfigured( 155 | 'settings.MULTISITE_FALLBACK is not callable: %s' % 156 | fallback 157 | ) 158 | 159 | kwargs = getattr(settings, 'MULTISITE_FALLBACK_KWARGS', {}) 160 | if hasattr(view, 'as_view'): 161 | # Class-based view 162 | return view.as_view(**kwargs)(request) 163 | # View function 164 | return view(request, **kwargs) 165 | 166 | def redirect_to_canonical(self, request, alias): 167 | if not alias.redirect_to_canonical or alias.is_canonical: 168 | return 169 | url = urlsplit(request.build_absolute_uri(request.get_full_path())) 170 | url = urlunsplit((url.scheme, 171 | alias.site.domain, 172 | url.path, url.query, url.fragment)) 173 | return HttpResponsePermanentRedirect(url) 174 | 175 | def process_request(self, request): 176 | try: 177 | netloc = request.get_host().lower() 178 | except DisallowedHost: 179 | settings.SITE_ID.reset() 180 | return self.fallback_view(request) 181 | 182 | cache_key = self.get_cache_key(netloc) 183 | 184 | # Find the Alias in the cache 185 | alias = self.cache.get(cache_key) 186 | if alias is not None: 187 | self.cache.set(cache_key, alias) 188 | settings.SITE_ID.set(alias.site_id) 189 | return self.redirect_to_canonical(request, alias) 190 | 191 | # Cache missed 192 | alias = self.get_alias(netloc) 193 | 194 | # Fallback using settings.MULTISITE_FALLBACK 195 | if alias is None: 196 | settings.SITE_ID.reset() 197 | return self.fallback_view(request) 198 | 199 | # Found Site 200 | self.cache.set(cache_key, alias) 201 | settings.SITE_ID.set(alias.site_id) 202 | SITE_CACHE[settings.SITE_ID] = alias.site # Pre-populate SITE_CACHE 203 | return self.redirect_to_canonical(request, alias) 204 | 205 | @classmethod 206 | def site_domain_cache_hook(self, sender, instance, *args, **kwargs): 207 | """Caches Site.domain in the object for site_domain_changed_hook.""" 208 | instance._domain_cache = instance.domain 209 | 210 | def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): 211 | """Clears the cache if Site.domain has changed.""" 212 | if raw or instance.pk is None: 213 | return 214 | 215 | original = getattr(instance, '_domain_cache', None) 216 | if original != instance.domain: 217 | self.cache.clear() 218 | 219 | def site_deleted_hook(self, *args, **kwargs): 220 | """Clears the cache if Site was deleted.""" 221 | self.cache.clear() 222 | 223 | 224 | class CookieDomainMiddleware(MiddlewareMixin): 225 | def __init__(self, *args, **kwargs): 226 | super(CookieDomainMiddleware, self).__init__(*args, **kwargs) 227 | self.depth = int(getattr(settings, 'MULTISITE_COOKIE_DOMAIN_DEPTH', 0)) 228 | if self.depth < 0: 229 | raise ValueError( 230 | 'Invalid MULTISITE_COOKIE_DOMAIN_DEPTH: {depth!r}'.format( 231 | depth=self.depth 232 | ) 233 | ) 234 | self.psl_cache = getattr(settings, 235 | 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', 236 | None) 237 | if self.psl_cache is None: 238 | self.psl_cache = os.path.join(tempfile.gettempdir(), 239 | 'multisite_tld.dat') 240 | self._tldextract = None 241 | 242 | def tldextract(self, url): 243 | import tldextract 244 | if self._tldextract is None: 245 | self._tldextract = tldextract.TLDExtract(cache_file=self.psl_cache) 246 | return self._tldextract(url) 247 | 248 | def match_cookies(self, request, response): 249 | return [c for c in response.cookies.values() if not c['domain']] 250 | 251 | def process_response(self, request, response): 252 | matched = self.match_cookies(request=request, response=response) 253 | if not matched: 254 | return response # No cookies to edit 255 | 256 | parsed = self.tldextract(request.get_host()) 257 | if not parsed.suffix: 258 | return response # IP address or local path 259 | if not parsed.domain: 260 | return response # Only TLD 261 | 262 | subdomains = parsed.subdomain.split('.') if parsed.subdomain else [] 263 | if not self.depth: 264 | subdomains = [''] 265 | elif len(subdomains) < self.depth: 266 | return response # Not enough subdomain parts 267 | else: 268 | subdomains = [''] + subdomains[-self.depth:] 269 | 270 | domain = '.'.join(subdomains + [parsed.domain, parsed.suffix]) 271 | 272 | for morsel in matched: 273 | morsel['domain'] = domain 274 | return response 275 | -------------------------------------------------------------------------------- /multisite/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | from django.db import models, migrations 6 | import multisite.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('sites', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Alias', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('domain', models.CharField(help_text='Either "domain" or "domain:port"', unique=True, max_length=100, verbose_name='domain name')), 21 | ('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?')), 22 | ('redirect_to_canonical', models.BooleanField(default=True, help_text='Should this domain name redirect to the one in site?', verbose_name='redirect to canonical?')), 23 | ('site', models.ForeignKey(related_name='aliases', to='sites.Site', on_delete=models.CASCADE)), 24 | ], 25 | options={ 26 | 'verbose_name_plural': 'aliases', 27 | }, 28 | ), 29 | migrations.AlterUniqueTogether( 30 | name='alias', 31 | unique_together=set([('is_canonical', 'site')]), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /multisite/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shestera/django-multisite/97d2529c7591360d1434d6f343ae911692e45bf1/multisite/migrations/__init__.py -------------------------------------------------------------------------------- /multisite/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | import operator 5 | from functools import reduce 6 | 7 | from django.contrib.sites.models import Site 8 | from django.core.exceptions import ValidationError 9 | from django.core.validators import validate_ipv4_address 10 | from django.db import connections, models, router 11 | from django.db.models import Q 12 | from django.db.models.signals import pre_save, post_save 13 | from django.db.models.signals import post_migrate 14 | from django.utils.encoding import python_2_unicode_compatible 15 | from django.utils.translation import ugettext_lazy as _ 16 | 17 | from .hacks import use_framework_for_site_cache 18 | 19 | try: 20 | xrange 21 | except NameError: # python3 22 | xrange = range 23 | 24 | _site_domain = Site._meta.get_field('domain') 25 | 26 | use_framework_for_site_cache() 27 | 28 | 29 | class AliasManager(models.Manager): 30 | """Manager for all Aliases.""" 31 | 32 | def get_queryset(self): 33 | return super(AliasManager, self).get_queryset().select_related('site') 34 | 35 | def resolve(self, host, port=None): 36 | """ 37 | Returns the Alias that best matches ``host`` and ``port``, or None. 38 | 39 | ``host`` is a hostname like ``'example.com'``. 40 | ``port`` is a port number like 8000, or None. 41 | 42 | Attempts to first match by 'host:port' against 43 | Alias.domain. If that fails, it will try to match the bare 44 | 'host' with no port number. 45 | 46 | All comparisons are done case-insensitively. 47 | """ 48 | domains = self._expand_netloc(host=host, port=port) 49 | q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) 50 | aliases = dict((a.domain, a) for a in self.get_queryset().filter(q)) 51 | for domain in domains: 52 | try: 53 | return aliases[domain] 54 | except KeyError: 55 | pass 56 | 57 | @classmethod 58 | def _expand_netloc(cls, host, port=None): 59 | """ 60 | Returns a list of possible domain expansions for ``host`` and ``port``. 61 | 62 | ``host`` is a hostname like ``'example.com'``. 63 | ``port`` is a port number like 8000, or None. 64 | 65 | Expansions are ordered from highest to lowest preference and may 66 | include wildcards. Examples:: 67 | 68 | >>> AliasManager._expand_netloc('www.example.com') 69 | ['www.example.com', '*.example.com', '*.com', '*'] 70 | 71 | >>> AliasManager._expand_netloc('www.example.com', 80) 72 | ['www.example.com:80', 'www.example.com', 73 | '*.example.com:80', '*.example.com', 74 | '*.com:80', '*.com', 75 | '*:80', '*'] 76 | """ 77 | if not host: 78 | raise ValueError(u"Invalid host: %s" % host) 79 | 80 | try: 81 | validate_ipv4_address(host) 82 | bits = [host] 83 | except ValidationError: 84 | # Not an IP address 85 | bits = host.split('.') 86 | 87 | result = [] 88 | for i in xrange(0, (len(bits) + 1)): 89 | if i == 0: 90 | host = '.'.join(bits[i:]) 91 | else: 92 | host = '.'.join(['*'] + bits[i:]) 93 | if port: 94 | result.append("%s:%s" % (host, port)) 95 | result.append(host) 96 | return result 97 | 98 | 99 | class CanonicalAliasManager(models.Manager): 100 | """Manager for Alias objects where is_canonical is True.""" 101 | 102 | def get_queryset(self): 103 | qset = super(CanonicalAliasManager, self).get_queryset() 104 | return qset.filter(is_canonical=True) 105 | 106 | def sync_many(self, *args, **kwargs): 107 | """ 108 | Synchronize canonical Alias objects based on Site.domain. 109 | 110 | You can pass Q-objects or filter arguments to update a subset of 111 | Alias objects:: 112 | 113 | Alias.canonical.sync_many(site__domain='example.com') 114 | """ 115 | aliases = self.get_queryset().filter(*args, **kwargs) 116 | for alias in aliases.select_related('site'): 117 | domain = alias.site.domain 118 | if domain and alias.domain != domain: 119 | alias.domain = domain 120 | alias.save() 121 | 122 | def sync_missing(self): 123 | """Create missing canonical Alias objects based on Site.domain.""" 124 | aliases = self.get_queryset() 125 | try: 126 | sites = self.model._meta.get_field('site').remote_field.model 127 | except AttributeError: 128 | sites = self.model._meta.get_field('site').rel.to 129 | for site in sites.objects.exclude(aliases__in=aliases): 130 | Alias.sync(site=site) 131 | 132 | def sync_all(self): 133 | """Create or sync canonical Alias objects from all Site objects.""" 134 | self.sync_many() 135 | self.sync_missing() 136 | 137 | 138 | class NotCanonicalAliasManager(models.Manager): 139 | """Manager for Aliases where is_canonical is None.""" 140 | 141 | def get_queryset(self): 142 | qset = super(NotCanonicalAliasManager, self).get_queryset() 143 | return qset.filter(is_canonical__isnull=True) 144 | 145 | 146 | def validate_true_or_none(value): 147 | """Raises ValidationError if value is not True or None.""" 148 | if value not in (True, None): 149 | raise ValidationError(u'%r must be True or None' % value) 150 | 151 | 152 | @python_2_unicode_compatible 153 | class Alias(models.Model): 154 | """ 155 | Model for domain-name aliases for Site objects. 156 | 157 | Domain names must be unique in the format of: 'hostname[:port].' 158 | Each Site object that has a domain must have an ``is_canonical`` 159 | Alias. 160 | """ 161 | 162 | domain = type(_site_domain)( 163 | _('domain name'), 164 | max_length=_site_domain.max_length, 165 | unique=True, 166 | help_text=_('Either "domain" or "domain:port"'), 167 | ) 168 | site = models.ForeignKey( 169 | Site, related_name='aliases', on_delete=models.CASCADE 170 | ) 171 | is_canonical = models.NullBooleanField( 172 | _('is canonical?'), 173 | default=None, editable=False, 174 | validators=[validate_true_or_none], 175 | help_text=_('Does this domain name match the one in site?'), 176 | ) 177 | redirect_to_canonical = models.BooleanField( 178 | _('redirect to canonical?'), 179 | default=True, 180 | help_text=_('Should this domain name redirect to the one in site?'), 181 | ) 182 | 183 | objects = AliasManager() 184 | canonical = CanonicalAliasManager() 185 | aliases = NotCanonicalAliasManager() 186 | 187 | class Meta: 188 | unique_together = [('is_canonical', 'site')] 189 | verbose_name_plural = _('aliases') 190 | 191 | def __str__(self): 192 | return "%s -> %s" % (self.domain, self.site.domain) 193 | 194 | def __repr__(self): 195 | return '' % str(self) 196 | 197 | def save_base(self, *args, **kwargs): 198 | self.full_clean() 199 | # For canonical Alias, domains must match Site domains. 200 | # This needs to be validated here so that it is executed *after* the 201 | # Site pre-save signal updates the domain (an AliasInline modelform 202 | # on SiteAdmin will be saved (and it's clean methods run before 203 | # the Site is saved) 204 | if self.is_canonical and self.domain != self.site.domain: 205 | raise ValidationError( 206 | {'domain': ['Does not match %r' % self.site]} 207 | ) 208 | super(Alias, self).save_base(*args, **kwargs) 209 | 210 | def validate_unique(self, exclude=None): 211 | errors = {} 212 | try: 213 | super(Alias, self).validate_unique(exclude=exclude) 214 | except ValidationError as e: 215 | errors = e.update_error_dict(errors) 216 | 217 | if exclude is not None and 'domain' not in exclude: 218 | # Ensure domain is unique, insensitive to case 219 | field_name = 'domain' 220 | field_error = self.unique_error_message(self.__class__, 221 | (field_name,)) 222 | if field_name not in errors or \ 223 | str(field_error) not in [str(err) for err in errors[field_name]]: 224 | qset = self.__class__.objects.filter( 225 | **{field_name + '__iexact': getattr(self, field_name)} 226 | ) 227 | if self.pk is not None: 228 | qset = qset.exclude(pk=self.pk) 229 | if qset.exists(): 230 | errors.setdefault(field_name, []).append(field_error) 231 | 232 | if errors: 233 | raise ValidationError(errors) 234 | 235 | @classmethod 236 | def _sync_blank_domain(cls, site): 237 | """Delete associated Alias object for ``site``, if domain is blank.""" 238 | 239 | if site.domain: 240 | raise ValueError('%r has a domain' % site) 241 | 242 | # Remove canonical Alias, if no non-canonical aliases exist. 243 | try: 244 | alias = cls.objects.get(site=site) 245 | except cls.DoesNotExist: 246 | # Nothing to delete 247 | pass 248 | else: 249 | if not alias.is_canonical: 250 | raise cls.MultipleObjectsReturned( 251 | 'Other %s still exist for %r' % 252 | (cls._meta.verbose_name_plural.capitalize(), site) 253 | ) 254 | alias.delete() 255 | 256 | @classmethod 257 | def sync(cls, site, force_insert=False): 258 | """ 259 | Create or synchronize Alias object from ``site``. 260 | 261 | If `force_insert`, forces creation of Alias object. 262 | """ 263 | domain = site.domain 264 | if not domain: 265 | cls._sync_blank_domain(site) 266 | return 267 | 268 | if force_insert: 269 | alias = cls.objects.create(site=site, is_canonical=True, 270 | domain=domain) 271 | 272 | else: 273 | alias, created = cls.objects.get_or_create( 274 | site=site, is_canonical=True, 275 | defaults={'domain': domain} 276 | ) 277 | if not created and alias.domain != domain: 278 | alias.site = site 279 | alias.domain = domain 280 | alias.save() 281 | 282 | return alias 283 | 284 | @classmethod 285 | def site_domain_changed_hook(cls, sender, instance, raw, *args, **kwargs): 286 | """Updates canonical Alias object if Site.domain has changed.""" 287 | if raw or instance.pk is None: 288 | return 289 | 290 | try: 291 | original = sender.objects.get(pk=instance.pk) 292 | except sender.DoesNotExist: 293 | return 294 | 295 | # Update Alias.domain to match site 296 | if original.domain != instance.domain: 297 | cls.sync(site=instance) 298 | 299 | @classmethod 300 | def site_created_hook(cls, sender, instance, raw, created, 301 | *args, **kwargs): 302 | """Creates canonical Alias object for a new Site.""" 303 | if raw or not created: 304 | return 305 | 306 | # When running create_default_site() because of post_syncdb, 307 | # don't try to sync before the db_table has been created. 308 | using = router.db_for_write(cls) 309 | tables = connections[using].introspection.table_names() 310 | if cls._meta.db_table not in tables: 311 | return 312 | 313 | # Update Alias.domain to match site 314 | cls.sync(site=instance) 315 | 316 | @classmethod 317 | def db_table_created_hook(cls, *args, **kwargs): 318 | """Syncs canonical Alias objects for all existing Site objects.""" 319 | Alias.canonical.sync_all() 320 | 321 | 322 | # Hooks to handle Site objects being created or changed 323 | pre_save.connect(Alias.site_domain_changed_hook, sender=Site) 324 | post_save.connect(Alias.site_created_hook, sender=Site) 325 | 326 | # Hook to handle syncdb creating the Alias table 327 | post_migrate.connect(Alias.db_table_created_hook) 328 | -------------------------------------------------------------------------------- /multisite/template/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shestera/django-multisite/97d2529c7591360d1434d6f343ae911692e45bf1/multisite/template/__init__.py -------------------------------------------------------------------------------- /multisite/template/loaders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shestera/django-multisite/97d2529c7591360d1434d6f343ae911692e45bf1/multisite/template/loaders/__init__.py -------------------------------------------------------------------------------- /multisite/template/loaders/filesystem.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | import os 6 | from django.conf import settings 7 | from django.contrib.sites.models import Site 8 | from django.template.loaders.filesystem import Loader as FilesystemLoader 9 | from django import VERSION as django_version 10 | 11 | 12 | class Loader(FilesystemLoader): 13 | def get_template_sources(self, *args, **kwargs): 14 | template_name = args[0] 15 | domain = Site.objects.get_current().domain 16 | default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', 17 | 'default') 18 | for tname in (os.path.join(domain, template_name), 19 | os.path.join(default_dir, template_name)): 20 | if django_version < (2, 0, 0): 21 | args = [tname, None] 22 | else: 23 | args = [tname] 24 | for item in super(Loader, self).get_template_sources(*args, **kwargs): 25 | yield item 26 | -------------------------------------------------------------------------------- /multisite/template_loader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | from .template.loaders.filesystem import Loader 6 | 7 | # The template.loaders.filesystem.Loader class used to live here. Now that 8 | # we have more than one Loader class in the project, they are defined in the 9 | # same fashion as Django's. 10 | # For backward-compatibility reasons, Loader in this file points to what 11 | # used to be defined here. 12 | __all__ = ['Loader'] 13 | -------------------------------------------------------------------------------- /multisite/test_settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | from multisite import SiteID 3 | 4 | SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': 'test', 10 | } 11 | } 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.sites', 15 | 'multisite', 16 | ] 17 | 18 | 19 | SITE_ID = SiteID(default=1) 20 | 21 | MIDDLEWARE = [ 22 | 'multisite.middleware.DynamicSiteMiddleware', 23 | ] 24 | if django.VERSION < (1,10,0): 25 | # we are backwards compatible, but the settings file format has changed post-1.10: 26 | # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware 27 | MIDDLEWARE_CLASSES = list(MIDDLEWARE) 28 | del MIDDLEWARE 29 | 30 | 31 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 32 | -------------------------------------------------------------------------------- /multisite/test_templates/example.com/example.html: -------------------------------------------------------------------------------- 1 | Test example.com template -------------------------------------------------------------------------------- /multisite/test_templates/multisite_templates/test.html: -------------------------------------------------------------------------------- 1 | Test! -------------------------------------------------------------------------------- /multisite/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for django-multisite. 3 | 4 | To run this, use: 5 | $ python -m multisite.tests 6 | or 7 | $ python setup.py test 8 | from the parent directory. 9 | 10 | This file uses relative imports and so cannot be run standalone. 11 | """ 12 | 13 | from __future__ import unicode_literals 14 | from __future__ import absolute_import 15 | 16 | import django 17 | import logging 18 | import os 19 | import pytest 20 | import sys 21 | import tempfile 22 | import warnings 23 | from unittest import skipUnless 24 | 25 | try: 26 | from unittest import mock 27 | except ImportError: 28 | import mock 29 | 30 | from django.conf import settings 31 | from django.conf.urls import url 32 | from django.contrib.sites.models import Site 33 | from django.core.exceptions import ImproperlyConfigured, ValidationError 34 | from django.core.management import call_command 35 | from django.http import Http404, HttpResponse 36 | from django.template.loader import get_template 37 | from django.test import TestCase, override_settings 38 | from django.test.client import RequestFactory as DjangoRequestFactory 39 | from django.utils.six import StringIO 40 | 41 | from multisite import SiteDomain, SiteID, threadlocals 42 | 43 | from .hacks import use_framework_for_site_cache 44 | from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject 45 | from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware 46 | from .models import Alias 47 | 48 | 49 | class RequestFactory(DjangoRequestFactory): 50 | def __init__(self, host): 51 | super(RequestFactory, self).__init__() 52 | self.host = host 53 | 54 | def get(self, path, data={}, host=None, **extra): 55 | if host is None: 56 | host = self.host 57 | return super(RequestFactory, self).get(path=path, data=data, 58 | HTTP_HOST=host, **extra) 59 | 60 | @pytest.mark.django_db 61 | @skipUnless(Site._meta.installed, 62 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 63 | @override_settings( 64 | SITE_ID=SiteID(), 65 | CACHE_SITES_KEY_PREFIX='__test__', 66 | ) 67 | class TestContribSite(TestCase): 68 | def setUp(self): 69 | Site.objects.all().delete() 70 | self.site = Site.objects.create(domain='example.com') 71 | settings.SITE_ID.set(self.site.id) 72 | 73 | def test_get_current_site(self): 74 | current_site = Site.objects.get_current() 75 | self.assertEqual(current_site, self.site) 76 | self.assertEqual(current_site.id, settings.SITE_ID) 77 | 78 | # Because we are a middleware package, we have no views available to test with easily 79 | # So create one: 80 | # (This is only used by test_integration) 81 | urlpatterns = [ 82 | url(r'^domain/$', lambda request, *args, **kwargs: HttpResponse(str(Site.objects.get_current()))) 83 | ] 84 | 85 | @pytest.mark.django_db 86 | @skipUnless(Site._meta.installed, 87 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 88 | @override_settings( 89 | ALLOWED_SITES=['*'], 90 | ROOT_URLCONF=__name__, #this means that urlpatterns above is used when .get() is called below. 91 | SITE_ID=SiteID(default=0), 92 | CACHE_MULTISITE_ALIAS='multisite', 93 | CACHES={ 94 | 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, 95 | 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} 96 | }, 97 | MULTISITE_FALLBACK=None, 98 | ALLOWED_HOSTS=ALLOWED_HOSTS 99 | ) 100 | class DynamicSiteMiddlewareTest(TestCase): 101 | def setUp(self): 102 | self.host = 'example.com' 103 | self.factory = RequestFactory(host=self.host) 104 | 105 | Site.objects.all().delete() 106 | self.site = Site.objects.create(domain=self.host) 107 | self.site2 = Site.objects.create(domain='anothersite.example') 108 | 109 | def test_valid_domain(self): 110 | # Make the request 111 | request = self.factory.get('/') 112 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 113 | self.assertEqual(settings.SITE_ID, self.site.pk) 114 | # Request again 115 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 116 | self.assertEqual(settings.SITE_ID, self.site.pk) 117 | 118 | def test_valid_domain_port(self): 119 | # Make the request with a specific port 120 | request = self.factory.get('/', host=self.host + ':8000') 121 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 122 | self.assertEqual(settings.SITE_ID, self.site.pk) 123 | # Request again 124 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 125 | self.assertEqual(settings.SITE_ID, self.site.pk) 126 | 127 | def test_case_sensitivity(self): 128 | # Make the request in all uppercase 129 | request = self.factory.get('/', host=self.host.upper()) 130 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 131 | self.assertEqual(settings.SITE_ID, self.site.pk) 132 | 133 | def test_change_domain(self): 134 | # Make the request 135 | request = self.factory.get('/') 136 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 137 | self.assertEqual(settings.SITE_ID, self.site.pk) 138 | # Another request with a different site 139 | request = self.factory.get('/', host=self.site2.domain) 140 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 141 | self.assertEqual(settings.SITE_ID, self.site2.pk) 142 | 143 | def test_unknown_host(self): 144 | # Unknown host 145 | request = self.factory.get('/', host='unknown') 146 | with self.assertRaises(Http404): 147 | DynamicSiteMiddleware().process_request(request) 148 | # The middleware resets SiteID to its default value, as given above, on error. 149 | self.assertEqual(settings.SITE_ID, 0) 150 | 151 | def test_unknown_hostport(self): 152 | # Unknown host:port 153 | request = self.factory.get('/', host='unknown:8000') 154 | with self.assertRaises(Http404): 155 | DynamicSiteMiddleware().process_request(request) 156 | # The middleware resets SiteID to its default value, as given above, on error. 157 | self.assertEqual(settings.SITE_ID, 0) 158 | 159 | def test_invalid_host(self): 160 | # Invalid host 161 | request = self.factory.get('/', host='') 162 | with self.assertRaises(Http404): 163 | DynamicSiteMiddleware().process_request(request) 164 | 165 | def test_invalid_hostport(self): 166 | # Invalid host:port 167 | request = self.factory.get('/', host=':8000') 168 | with self.assertRaises(Http404): 169 | DynamicSiteMiddleware().process_request(request) 170 | 171 | def test_no_sites(self): 172 | # FIXME: this needs to go into its own TestCase since it requires modifying the fixture to work properly 173 | # Remove all Sites 174 | Site.objects.all().delete() 175 | # Make the request 176 | request = self.factory.get('/') 177 | self.assertRaises(Http404, 178 | DynamicSiteMiddleware().process_request, request) 179 | # The middleware resets SiteID to its default value, as given above, on error. 180 | self.assertEqual(settings.SITE_ID, 0) 181 | 182 | def test_redirect(self): 183 | host = 'example.org' 184 | alias = Alias.objects.create(site=self.site, domain=host) 185 | self.assertTrue(alias.redirect_to_canonical) 186 | # Make the request 187 | request = self.factory.get('/path', host=host) 188 | response = DynamicSiteMiddleware().process_request(request) 189 | self.assertEqual(response.status_code, 301) 190 | self.assertEqual(response['Location'], 191 | "http://%s/path" % self.host) 192 | 193 | def test_no_redirect(self): 194 | host = 'example.org' 195 | Alias.objects.create(site=self.site, domain=host, 196 | redirect_to_canonical=False) 197 | # Make the request 198 | request = self.factory.get('/path', host=host) 199 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 200 | self.assertEqual(settings.SITE_ID, self.site.pk) 201 | 202 | def test_integration(self): 203 | """ 204 | Test that the middleware loads and runs properly under settings.MIDDLEWARE. 205 | """ 206 | resp = self.client.get('/domain/', HTTP_HOST=self.host) 207 | self.assertEqual(resp.status_code, 200) 208 | self.assertContains(resp, self.site.domain) 209 | self.assertEqual(settings.SITE_ID, self.site.pk) 210 | 211 | resp = self.client.get('/domain/', HTTP_HOST=self.site2.domain) 212 | self.assertEqual(resp.status_code, 200) 213 | self.assertContains(resp, self.site2.domain) 214 | self.assertEqual(settings.SITE_ID, self.site2.pk) 215 | 216 | 217 | @pytest.mark.django_db 218 | @skipUnless(Site._meta.installed, 219 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 220 | @override_settings( 221 | SITE_ID=SiteID(default=0), 222 | CACHE_MULTISITE_ALIAS='multisite', 223 | CACHES={ 224 | 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} 225 | }, MULTISITE_FALLBACK=None, 226 | MULTISITE_FALLBACK_KWARGS={}, 227 | ) 228 | class DynamicSiteMiddlewareFallbackTest(TestCase): 229 | def setUp(self): 230 | self.factory = RequestFactory(host='unknown') 231 | 232 | Site.objects.all().delete() 233 | 234 | def test_404(self): 235 | request = self.factory.get('/') 236 | self.assertRaises(Http404, 237 | DynamicSiteMiddleware().process_request, request) 238 | self.assertEqual(settings.SITE_ID, 0) 239 | 240 | def test_testserver(self): 241 | host = 'testserver' 242 | site = Site.objects.create(domain=host) 243 | request = self.factory.get('/', host=host) 244 | self.assertEqual(DynamicSiteMiddleware().process_request(request), None) 245 | self.assertEqual(settings.SITE_ID, site.pk) 246 | 247 | def test_string_class(self): 248 | # Class based 249 | settings.MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView' 250 | settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', 251 | 'permanent': False} 252 | request = self.factory.get('/') 253 | response = DynamicSiteMiddleware().process_request(request) 254 | self.assertEqual(response.status_code, 302) 255 | self.assertEqual(response['Location'], 256 | settings.MULTISITE_FALLBACK_KWARGS['url']) 257 | 258 | def test_class_view(self): 259 | from django.views.generic.base import RedirectView 260 | settings.MULTISITE_FALLBACK = RedirectView.as_view( 261 | url='http://example.com/', permanent=False 262 | ) 263 | request = self.factory.get('/') 264 | response = DynamicSiteMiddleware().process_request(request) 265 | self.assertEqual(response.status_code, 302) 266 | self.assertEqual(response['Location'], 'http://example.com/') 267 | 268 | def test_invalid(self): 269 | settings.MULTISITE_FALLBACK = '' 270 | request = self.factory.get('/') 271 | self.assertRaises(ImproperlyConfigured, 272 | DynamicSiteMiddleware().process_request, request) 273 | 274 | 275 | @pytest.mark.django_db 276 | @skipUnless(Site._meta.installed, 277 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 278 | @override_settings(SITE_ID=0,) 279 | class DynamicSiteMiddlewareSettingsTest(TestCase): 280 | def test_invalid_settings(self): 281 | self.assertRaises(TypeError, DynamicSiteMiddleware) 282 | 283 | 284 | @pytest.mark.django_db 285 | @override_settings( 286 | SITE_ID=SiteID(default=0), 287 | CACHE_MULTISITE_ALIAS='multisite', 288 | CACHES={ 289 | 'multisite': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} 290 | }, 291 | MULTISITE_FALLBACK=None, 292 | ALLOWED_HOSTS=ALLOWED_HOSTS 293 | ) 294 | class CacheTest(TestCase): 295 | def setUp(self): 296 | self.host = 'example.com' 297 | self.factory = RequestFactory(host=self.host) 298 | 299 | Site.objects.all().delete() 300 | self.site = Site.objects.create(domain=self.host) 301 | 302 | def test_site_domain_changed(self): 303 | # Test to ensure that the cache is cleared properly 304 | middleware = DynamicSiteMiddleware() 305 | cache_key = middleware.get_cache_key(self.host) 306 | self.assertEqual(middleware.cache.get(cache_key), None) 307 | # Make the request 308 | request = self.factory.get('/') 309 | self.assertEqual(middleware.process_request(request), None) 310 | self.assertEqual(middleware.cache.get(cache_key).site_id, 311 | self.site.pk) 312 | # Change the domain name 313 | self.site.domain = 'example.org' 314 | self.site.save() 315 | self.assertEqual(middleware.cache.get(cache_key), None) 316 | # Make the request again, which will now be invalid 317 | request = self.factory.get('/') 318 | self.assertRaises(Http404, 319 | middleware.process_request, request) 320 | self.assertEqual(settings.SITE_ID, 0) 321 | 322 | 323 | @pytest.mark.django_db 324 | @skipUnless(Site._meta.installed, 325 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 326 | @override_settings(SITE_ID=SiteID(),) 327 | class SiteCacheTest(TestCase): 328 | 329 | def _initialize_cache(self): 330 | # initialize cache again so override key prefix settings are used 331 | from django.contrib.sites import models 332 | use_framework_for_site_cache() 333 | self.cache = models.SITE_CACHE 334 | 335 | def setUp(self): 336 | from django.contrib.sites import models 337 | 338 | if hasattr(models, 'clear_site_cache'): 339 | # Before Django 1.6, the Site cache is cleared after the Site 340 | # object has been created. This replicates that behaviour. 341 | def save(self, *args, **kwargs): 342 | super(models.Site, self).save(*args, **kwargs) 343 | models.SITE_CACHE.clear() 344 | models.Site.save = save 345 | 346 | self._initialize_cache() 347 | Site.objects.all().delete() 348 | self.host = 'example.com' 349 | self.site = Site.objects.create(domain=self.host) 350 | settings.SITE_ID.set(self.site.id) 351 | 352 | def test_get_current(self): 353 | self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) 354 | # Populate cache 355 | self.assertEqual(Site.objects.get_current(), self.site) 356 | self.assertEqual(self.cache[self.site.id], self.site) 357 | self.assertEqual(self.cache.get(key=self.site.id), self.site) 358 | self.assertEqual(self.cache.get(key=-1), 359 | None) # Site doesn't exist 360 | self.assertEqual(self.cache.get(-1, 'Default'), 361 | 'Default') # Site doesn't exist 362 | self.assertEqual(self.cache.get(key=-1, default='Non-existant'), 363 | 'Non-existant') # Site doesn't exist 364 | self.assertEqual('Non-existant', 365 | self.cache.get(self.site.id, default='Non-existant', 366 | version=100)) # Wrong key version 3 367 | # Clear cache 368 | self.cache.clear() 369 | self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) 370 | self.assertEqual(self.cache.get(key=self.site.id, default='Cleared'), 371 | 'Cleared') 372 | 373 | def test_create_site(self): 374 | self.assertEqual(Site.objects.get_current(), self.site) 375 | self.assertEqual(Site.objects.get_current().domain, self.site.domain) 376 | # Create new site 377 | site = Site.objects.create(domain='example.org') 378 | settings.SITE_ID.set(site.id) 379 | self.assertEqual(Site.objects.get_current(), site) 380 | self.assertEqual(Site.objects.get_current().domain, site.domain) 381 | 382 | def test_change_site(self): 383 | self.assertEqual(Site.objects.get_current(), self.site) 384 | self.assertEqual(Site.objects.get_current().domain, self.site.domain) 385 | # Change site domain 386 | self.site.domain = 'example.org' 387 | self.site.save() 388 | self.assertEqual(Site.objects.get_current(), self.site) 389 | self.assertEqual(Site.objects.get_current().domain, self.site.domain) 390 | 391 | def test_delete_site(self): 392 | self.assertEqual(Site.objects.get_current(), self.site) 393 | self.assertEqual(Site.objects.get_current().domain, self.site.domain) 394 | # Delete site 395 | self.site.delete() 396 | self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) 397 | 398 | @override_settings(CACHE_MULTISITE_KEY_PREFIX="__test__") 399 | def test_multisite_key_prefix(self): 400 | self._initialize_cache() 401 | # Populate cache 402 | self.assertEqual(Site.objects.get_current(), self.site) 403 | self.assertEqual(self.cache[self.site.id], self.site) 404 | self.assertEqual( 405 | self.cache._cache._get_cache_key(self.site.id), 406 | 'sites.{}.{}'.format( 407 | settings.CACHE_MULTISITE_KEY_PREFIX, self.site.id 408 | ), 409 | self.cache._cache._get_cache_key(self.site.id) 410 | ) 411 | 412 | @override_settings( 413 | CACHE_MULTISITE_ALIAS='multisite', 414 | CACHES={ 415 | 'multisite': { 416 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 417 | 'KEY_PREFIX': 'looselycoupled' 418 | } 419 | }, 420 | ) 421 | def test_default_key_prefix(self): 422 | """ 423 | If CACHE_MULTISITE_KEY_PREFIX is undefined, 424 | the caching system should use CACHES[current]['KEY_PREFIX']. 425 | """ 426 | self._initialize_cache() 427 | # Populate cache 428 | self.assertEqual(Site.objects.get_current(), self.site) 429 | self.assertEqual(self.cache[self.site.id], self.site) 430 | self.assertEqual( 431 | self.cache._cache._get_cache_key(self.site.id), 432 | "sites.looselycoupled.{}".format(self.site.id) 433 | ) 434 | 435 | @override_settings( 436 | CACHE_MULTISITE_KEY_PREFIX="virtuouslyvirtual", 437 | ) 438 | def test_multisite_key_prefix_takes_priority_over_default(self): 439 | self._initialize_cache() 440 | # Populate cache 441 | self.assertEqual(Site.objects.get_current(), self.site) 442 | self.assertEqual(self.cache[self.site.id], self.site) 443 | self.assertEqual( 444 | self.cache._cache._get_cache_key(self.site.id), 445 | "sites.virtuouslyvirtual.{}".format(self.site.id) 446 | ) 447 | 448 | 449 | @pytest.mark.django_db 450 | class TestSiteID(TestCase): 451 | def setUp(self): 452 | Site.objects.all().delete() 453 | self.site = Site.objects.create(domain='example.com') 454 | self.site_id = SiteID() 455 | 456 | def test_invalid_default(self): 457 | self.assertRaises(ValueError, SiteID, default='a') 458 | self.assertRaises(ValueError, SiteID, default=self.site_id) 459 | 460 | def test_compare_default_site_id(self): 461 | self.site_id = SiteID(default=self.site.id) 462 | self.assertEqual(self.site_id, self.site.id) 463 | self.assertFalse(self.site_id != self.site.id) 464 | self.assertFalse(self.site_id < self.site.id) 465 | self.assertTrue(self.site_id <= self.site.id) 466 | self.assertFalse(self.site_id > self.site.id) 467 | self.assertTrue(self.site_id >= self.site.id) 468 | 469 | def test_compare_site_ids(self): 470 | self.site_id.set(1) 471 | self.assertEqual(self.site_id, self.site_id) 472 | self.assertFalse(self.site_id != self.site_id) 473 | self.assertFalse(self.site_id < self.site_id) 474 | self.assertTrue(self.site_id <= self.site_id) 475 | self.assertFalse(self.site_id > self.site_id) 476 | self.assertTrue(self.site_id >= self.site_id) 477 | 478 | def test_compare_differing_types(self): 479 | self.site_id.set(1) 480 | self.assertNotEqual(self.site_id, '1') 481 | self.assertFalse(self.site_id == '1') 482 | self.assertTrue(self.site_id < '1') 483 | self.assertTrue(self.site_id <= '1') 484 | self.assertFalse(self.site_id > '1') 485 | self.assertFalse(self.site_id >= '1') 486 | self.assertNotEqual('1', self.site_id) 487 | self.assertFalse('1' == self.site_id) 488 | self.assertFalse('1' < self.site_id) 489 | self.assertFalse('1' <= self.site_id) 490 | self.assertTrue('1' > self.site_id) 491 | self.assertTrue('1' >= self.site_id) 492 | 493 | def test_set(self): 494 | self.site_id.set(10) 495 | self.assertEqual(int(self.site_id), 10) 496 | self.site_id.set(20) 497 | self.assertEqual(int(self.site_id), 20) 498 | self.site_id.set(self.site) 499 | self.assertEqual(int(self.site_id), self.site.id) 500 | 501 | def test_hash(self): 502 | self.site_id.set(10) 503 | self.assertEqual(hash(self.site_id), 10) 504 | self.site_id.set(20) 505 | self.assertEqual(hash(self.site_id), 20) 506 | 507 | def test_str_repr(self): 508 | self.site_id.set(10) 509 | self.assertEqual(str(self.site_id), '10') 510 | self.assertEqual(repr(self.site_id), '10') 511 | 512 | def test_context_manager(self): 513 | self.assertEqual(self.site_id.site_id, None) 514 | with self.site_id.override(1): 515 | self.assertEqual(self.site_id.site_id, 1) 516 | with self.site_id.override(2): 517 | self.assertEqual(self.site_id.site_id, 2) 518 | self.assertEqual(self.site_id.site_id, 1) 519 | self.assertEqual(self.site_id.site_id, None) 520 | 521 | 522 | @pytest.mark.django_db 523 | @skipUnless(Site._meta.installed, 524 | 'django.contrib.sites is not in settings.INSTALLED_APPS') 525 | class TestSiteDomain(TestCase): 526 | def setUp(self): 527 | Site.objects.all().delete() 528 | self.domain = 'example.com' 529 | self.site = Site.objects.create(domain=self.domain) 530 | 531 | def test_init(self): 532 | self.assertEqual(int(SiteDomain(default=self.domain)), self.site.id) 533 | self.assertRaises(Site.DoesNotExist, 534 | int, SiteDomain(default='invalid')) 535 | self.assertRaises(TypeError, SiteDomain, default=None) 536 | self.assertRaises(TypeError, SiteDomain, default=1) 537 | 538 | def test_deferred_site(self): 539 | domain = 'example.org' 540 | self.assertRaises(Site.DoesNotExist, 541 | int, SiteDomain(default=domain)) 542 | site = Site.objects.create(domain=domain) 543 | self.assertEqual(int(SiteDomain(default=domain)), 544 | site.id) 545 | 546 | 547 | @pytest.mark.django_db 548 | class AliasTest(TestCase): 549 | def setUp(self): 550 | Alias.objects.all().delete() 551 | Site.objects.all().delete() 552 | 553 | def test_create(self): 554 | site0 = Site.objects.create() 555 | site1 = Site.objects.create(domain='1.example') 556 | site2 = Site.objects.create(domain='2.example') 557 | # Missing site 558 | self.assertRaises(ValidationError, Alias.objects.create) 559 | self.assertRaises(ValidationError, 560 | Alias.objects.create, domain='0.example') 561 | # Valid 562 | self.assertTrue(Alias.objects.create(domain='1a.example', site=site1)) 563 | # Duplicate domain 564 | self.assertRaises( 565 | ValidationError, 566 | Alias.objects.create, domain=site1.domain, site=site1 567 | ) 568 | self.assertRaises( 569 | ValidationError, 570 | Alias.objects.create, domain=site2.domain, site=site1 571 | ) 572 | self.assertRaises( 573 | ValidationError, 574 | Alias.objects.create, domain='1a.example', site=site1 575 | ) 576 | # Duplicate domains, case-sensitivity 577 | self.assertRaises( 578 | ValidationError, 579 | Alias.objects.create, domain='1A.EXAMPLE', site=site2 580 | ) 581 | self.assertRaises( 582 | ValidationError, 583 | Alias.objects.create, domain='2.EXAMPLE', site=site2 584 | ) 585 | # Duplicate is_canonical 586 | site1.domain = '1b.example' 587 | self.assertRaises( 588 | ValidationError, 589 | Alias.objects.create, 590 | domain=site1.domain, site=site1, is_canonical=True 591 | ) 592 | # Invalid is_canonical 593 | self.assertRaises( 594 | ValidationError, 595 | Alias.objects.create, 596 | domain=site1.domain, site=site1, is_canonical=False 597 | ) 598 | 599 | def test_repr(self): 600 | site = Site.objects.create(domain='example.com') 601 | self.assertEqual(repr(Alias.objects.get(site=site)), 602 | u' %(domain)s>' % site.__dict__) 603 | 604 | def test_managers(self): 605 | site = Site.objects.create(domain='example.com') 606 | Alias.objects.create(site=site, domain='example.org') 607 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 608 | set(['example.com', 'example.org'])) 609 | self.assertEqual(set(Alias.canonical.values_list('domain', flat=True)), 610 | set(['example.com'])) 611 | self.assertEqual(set(Alias.aliases.values_list('domain', flat=True)), 612 | set(['example.org'])) 613 | 614 | def test_sync_many(self): 615 | # Create Sites with Aliases 616 | Site.objects.create() 617 | site1 = Site.objects.create(domain='1.example.com') 618 | site2 = Site.objects.create(domain='2.example.com') 619 | # Create Site without triggering signals 620 | site3 = Site(domain='3.example.com') 621 | site3.save_base(raw=True) 622 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 623 | set([site1.domain, site2.domain])) 624 | # Sync existing 625 | site1.domain = '1.example.org' 626 | site1.save_base(raw=True) 627 | site2.domain = '2.example.org' 628 | site2.save_base(raw=True) 629 | Alias.canonical.sync_many() 630 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 631 | set([site1.domain, site2.domain])) 632 | # Sync with filter 633 | site1.domain = '1.example.net' 634 | site1.save_base(raw=True) 635 | site2.domain = '2.example.net' 636 | site2.save_base(raw=True) 637 | Alias.canonical.sync_many(site__domain=site1.domain) 638 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 639 | set([site1.domain, '2.example.org'])) 640 | 641 | def test_sync_missing(self): 642 | Site.objects.create() 643 | site1 = Site.objects.create(domain='1.example.com') 644 | # Update site1 without triggering signals 645 | site1.domain = '1.example.org' 646 | site1.save_base(raw=True) 647 | # Create site2 without triggering signals 648 | site2 = Site(domain='2.example.org') 649 | site2.save_base(raw=True) 650 | # Only site2 should be updated 651 | Alias.canonical.sync_missing() 652 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 653 | set(['1.example.com', site2.domain])) 654 | 655 | def test_sync_all(self): 656 | Site.objects.create() 657 | site1 = Site.objects.create(domain='1.example.com') 658 | # Update site1 without triggering signals 659 | site1.domain = '1.example.org' 660 | site1.save_base(raw=True) 661 | # Create site2 without triggering signals 662 | site2 = Site(domain='2.example.org') 663 | site2.save_base(raw=True) 664 | # Sync all 665 | Alias.canonical.sync_all() 666 | self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), 667 | set([site1.domain, site2.domain])) 668 | 669 | def test_sync(self): 670 | # Create Site without triggering signals 671 | site = Site(domain='example.com') 672 | site.save_base(raw=True) 673 | # Insert Alias 674 | self.assertFalse(Alias.objects.filter(site=site).exists()) 675 | Alias.sync(site=site) 676 | self.assertEqual(Alias.objects.get(site=site).domain, site.domain) 677 | # Idempotent sync_alias 678 | Alias.sync(site=site) 679 | self.assertEqual(Alias.objects.get(site=site).domain, site.domain) 680 | # Duplicate force_insert 681 | self.assertRaises(ValidationError, 682 | Alias.sync, site=site, force_insert=True) 683 | # Update Alias 684 | site.domain = 'example.org' 685 | Alias.sync(site=site) 686 | self.assertEqual(Alias.objects.get(site=site).domain, site.domain) 687 | # Clear domain 688 | site.domain = '' 689 | Alias.sync(site=site) 690 | self.assertFalse(Alias.objects.filter(site=site).exists()) 691 | 692 | def test_sync_blank_domain(self): 693 | # Create Site 694 | site = Site.objects.create(domain='example.com') 695 | # Without clearing domain 696 | self.assertRaises(ValueError, Alias._sync_blank_domain, site) 697 | # With an extra Alias 698 | site.domain = '' 699 | alias = Alias.objects.create(site=site, domain='example.org') 700 | self.assertRaises(Alias.MultipleObjectsReturned, 701 | Alias._sync_blank_domain, site) 702 | # With a blank site 703 | alias.delete() 704 | Alias._sync_blank_domain(site) 705 | self.assertFalse(Alias.objects.filter(site=site).exists()) 706 | 707 | def test_hooks(self): 708 | # Create empty Site 709 | Site.objects.create() 710 | self.assertFalse(Alias.objects.filter(domain='').exists()) 711 | # Create Site 712 | site = Site.objects.create(domain='example.com') 713 | alias = Alias.objects.get(site=site) 714 | self.assertEqual(alias.domain, site.domain) 715 | self.assertTrue(alias.is_canonical) 716 | # Create a non-canonical alias 717 | Alias.objects.create(site=site, domain='example.info') 718 | # Change Site to another domain name 719 | site.domain = 'example.org' 720 | site.save() 721 | self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) 722 | self.assertEqual(Alias.aliases.get(site=site).domain, 'example.info') 723 | # Change Site to an empty domain name 724 | site.domain = '' 725 | self.assertRaises(Alias.MultipleObjectsReturned, site.save) 726 | Alias.aliases.all().delete() 727 | Site.objects.get(domain='').delete() # domain is unique in Django1.9 728 | site.save() 729 | self.assertFalse(Alias.objects.filter(site=site).exists()) 730 | # Change Site from an empty domain name 731 | site.domain = 'example.net' 732 | site.save() 733 | self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) 734 | # Delete Site 735 | site.delete() 736 | self.assertFalse(Alias.objects.filter(site=site).exists()) 737 | 738 | def test_expand_netloc(self): 739 | _expand_netloc = Alias.objects._expand_netloc 740 | self.assertRaises(ValueError, _expand_netloc, '') 741 | self.assertRaises(ValueError, _expand_netloc, '', 8000) 742 | self.assertEqual(_expand_netloc('testserver', 8000), 743 | ['testserver:8000', 'testserver', 744 | '*:8000', '*']) 745 | self.assertEqual(_expand_netloc('testserver'), 746 | ['testserver', '*']) 747 | self.assertEqual(_expand_netloc('example.com', 8000), 748 | ['example.com:8000', 'example.com', 749 | '*.com:8000', '*.com', 750 | '*:8000', '*']) 751 | self.assertEqual(_expand_netloc('example.com'), 752 | ['example.com', '*.com', '*']) 753 | self.assertEqual(_expand_netloc('www.example.com', 8000), 754 | ['www.example.com:8000', 'www.example.com', 755 | '*.example.com:8000', '*.example.com', 756 | '*.com:8000', '*.com', 757 | '*:8000', '*']) 758 | self.assertEqual(_expand_netloc('www.example.com'), 759 | ['www.example.com', '*.example.com', '*.com', '*']) 760 | 761 | def test_resolve(self): 762 | site = Site.objects.create(domain='example.com') 763 | # *.example.com 764 | self.assertEqual(Alias.objects.resolve('www.example.com'), 765 | None) 766 | self.assertEqual(Alias.objects.resolve('www.dev.example.com'), 767 | None) 768 | alias = Alias.objects.create(site=site, domain='*.example.com') 769 | self.assertEqual(Alias.objects.resolve('www.example.com'), 770 | alias) 771 | self.assertEqual(Alias.objects.resolve('www.dev.example.com'), 772 | alias) 773 | # * 774 | self.assertEqual(Alias.objects.resolve('example.net'), 775 | None) 776 | alias = Alias.objects.create(site=site, domain='*') 777 | self.assertEqual(Alias.objects.resolve('example.net'), 778 | alias) 779 | 780 | 781 | 782 | @pytest.mark.django_db 783 | @override_settings( 784 | MULTISITE_COOKIE_DOMAIN_DEPTH=0, 785 | MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, 786 | ALLOWED_HOSTS=ALLOWED_HOSTS, 787 | MULTISITE_EXTRA_HOSTS=['.extrahost.com'] 788 | ) 789 | class TestCookieDomainMiddleware(TestCase): 790 | 791 | def setUp(self): 792 | self.factory = RequestFactory(host='example.com') 793 | Site.objects.all().delete() 794 | # create sites so we populate ALLOWED_HOSTS 795 | Site.objects.create(domain='example.com') 796 | Site.objects.create(domain='test.example.com') 797 | Site.objects.create(domain='app.test1.example.com') 798 | Site.objects.create(domain='app.test2.example.com') 799 | Site.objects.create(domain='new.app.test3.example.com') 800 | 801 | def test_init(self): 802 | self.assertEqual(CookieDomainMiddleware().depth, 0) 803 | self.assertEqual(CookieDomainMiddleware().psl_cache, 804 | os.path.join(tempfile.gettempdir(), 805 | 'multisite_tld.dat')) 806 | 807 | with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=1, 808 | MULTISITE_PUBLIC_SUFFIX_LIST_CACHE='/var/psl'): 809 | middleware = CookieDomainMiddleware() 810 | self.assertEqual(middleware.depth, 1) 811 | self.assertEqual(middleware.psl_cache, '/var/psl') 812 | 813 | with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=-1): 814 | self.assertRaises(ValueError, CookieDomainMiddleware) 815 | 816 | with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH='invalid'): 817 | self.assertRaises(ValueError, CookieDomainMiddleware) 818 | 819 | def test_no_matched_cookies(self): 820 | # No cookies 821 | request = self.factory.get('/') 822 | response = HttpResponse() 823 | self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), 824 | []) 825 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 826 | self.assertEqual(list(cookies.values()), []) 827 | 828 | # Add some cookies with their domains already set 829 | response.set_cookie(key='a', value='a', domain='.example.org') 830 | response.set_cookie(key='b', value='b', domain='.example.co.uk') 831 | self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), 832 | []) 833 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 834 | 835 | if sys.version_info.major < 3: # for testing under Python 2.X 836 | self.assertItemsEqual( 837 | list(cookies.values()), [cookies['a'], cookies['b']] 838 | ) 839 | else: 840 | self.assertCountEqual( 841 | list(cookies.values()), [cookies['a'], cookies['b']] 842 | ) 843 | self.assertEqual(cookies['a']['domain'], '.example.org') 844 | self.assertEqual(cookies['b']['domain'], '.example.co.uk') 845 | 846 | def test_matched_cookies(self): 847 | request = self.factory.get('/') 848 | response = HttpResponse() 849 | response.set_cookie(key='a', value='a', domain=None) 850 | self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), 851 | [response.cookies['a']]) 852 | # No new cookies should be introduced 853 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 854 | self.assertEqual(list(cookies.values()), [cookies['a']]) 855 | 856 | def test_ip_address(self): 857 | response = HttpResponse() 858 | response.set_cookie(key='a', value='a', domain=None) 859 | allowed = [host for host in ALLOWED_HOSTS] + ['192.0.43.10'] 860 | # IP addresses should not be mutated 861 | with override_settings(ALLOWED_HOSTS=allowed): 862 | request = self.factory.get('/', host='192.0.43.10') 863 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 864 | self.assertEqual(cookies['a']['domain'], '') 865 | 866 | def test_localpath(self): 867 | response = HttpResponse() 868 | response.set_cookie(key='a', value='a', domain=None) 869 | 870 | allowed = [host for host in ALLOWED_HOSTS] + \ 871 | ['localhost', 'localhost.localdomain'] 872 | with override_settings(ALLOWED_HOSTS=allowed): 873 | # Local domains should not be mutated 874 | request = self.factory.get('/', host='localhost') 875 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 876 | self.assertEqual(cookies['a']['domain'], '') 877 | # Even local subdomains 878 | request = self.factory.get('/', host='localhost.localdomain') 879 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 880 | self.assertEqual(cookies['a']['domain'], '') 881 | 882 | def test_simple_tld(self): 883 | response = HttpResponse() 884 | response.set_cookie(key='a', value='a', domain=None) 885 | 886 | allowed = [host for host in ALLOWED_HOSTS] + \ 887 | ['ai', 'www.ai'] 888 | with override_settings(ALLOWED_HOSTS=allowed): 889 | # Top-level domains shouldn't get mutated 890 | request = self.factory.get('/', host='ai') 891 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 892 | self.assertEqual(cookies['a']['domain'], '') 893 | # Domains inside a TLD are OK 894 | request = self.factory.get('/', host='www.ai') 895 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 896 | self.assertEqual(cookies['a']['domain'], '.www.ai') 897 | 898 | def test_effective_tld(self): 899 | response = HttpResponse() 900 | response.set_cookie(key='a', value='a', domain=None) 901 | 902 | allowed = [host for host in ALLOWED_HOSTS] + \ 903 | ['com.ai', 'nic.com.ai'] 904 | with override_settings(ALLOWED_HOSTS=allowed): 905 | # Effective top-level domains with a webserver shouldn't get mutated 906 | request = self.factory.get('/', host='com.ai') 907 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 908 | self.assertEqual(cookies['a']['domain'], '') 909 | # Domains within an effective TLD are OK 910 | request = self.factory.get('/', host='nic.com.ai') 911 | cookies = CookieDomainMiddleware().process_response(request, response).cookies 912 | self.assertEqual(cookies['a']['domain'], '.nic.com.ai') 913 | 914 | def test_subdomain_depth(self): 915 | response = HttpResponse() 916 | response.set_cookie(key='a', value='a', domain=None) 917 | 918 | allowed = [host for host in ALLOWED_HOSTS] + ['com'] 919 | with override_settings( 920 | MULTISITE_COOKIE_DOMAIN_DEPTH=1, ALLOWED_HOSTS=allowed 921 | ): 922 | # At depth 1: 923 | middleware = CookieDomainMiddleware() 924 | # Top-level domains are ignored 925 | request = self.factory.get('/', host='com') 926 | cookies = middleware.process_response(request, response).cookies 927 | self.assertEqual(cookies['a']['domain'], '') 928 | # As are domains within a TLD 929 | request = self.factory.get('/', host='example.com') 930 | cookies = middleware.process_response(request, response).cookies 931 | self.assertEqual(cookies['a']['domain'], '') 932 | # But subdomains will get matched 933 | request = self.factory.get('/', host='test.example.com') 934 | cookies = middleware.process_response(request, response).cookies 935 | self.assertEqual(cookies['a']['domain'], '.test.example.com') 936 | # And sub-subdomains will get matched to 1 level deep 937 | cookies['a']['domain'] = '' 938 | request = self.factory.get('/', host='app.test1.example.com') 939 | cookies = middleware.process_response(request, response).cookies 940 | self.assertEqual(cookies['a']['domain'], '.test1.example.com') 941 | 942 | def test_subdomain_depth_2(self): 943 | response = HttpResponse() 944 | response.set_cookie(key='a', value='a', domain=None) 945 | with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=2): 946 | # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to 947 | # 2 levels deep 948 | middleware = CookieDomainMiddleware() 949 | request = self.factory.get('/', host='app.test2.example.com') 950 | cookies = middleware.process_response(request, response).cookies 951 | self.assertEqual(cookies['a']['domain'], '.app.test2.example.com') 952 | cookies['a']['domain'] = '' 953 | request = self.factory.get('/', host='new.app.test3.example.com') 954 | cookies = middleware.process_response(request, response).cookies 955 | self.assertEqual(cookies['a']['domain'], '.app.test3.example.com') 956 | 957 | def test_wildcard_subdomains(self): 958 | response = HttpResponse() 959 | response.set_cookie(key='a', value='a', domain=None) 960 | 961 | allowed = [host for host in ALLOWED_HOSTS] + ['.test.example.com'] 962 | with override_settings( 963 | MULTISITE_COOKIE_DOMAIN_DEPTH=2, ALLOWED_HOSTS=allowed 964 | ): 965 | # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to 966 | # 2 levels deep against the wildcard 967 | middleware = CookieDomainMiddleware() 968 | request = self.factory.get('/', host='foo.test.example.com') 969 | cookies = middleware.process_response(request, response).cookies 970 | self.assertEqual(cookies['a']['domain'], '.foo.test.example.com') 971 | cookies['a']['domain'] = '' 972 | request = self.factory.get('/', host='foo.bar.test.example.com') 973 | cookies = middleware.process_response(request, response).cookies 974 | self.assertEqual(cookies['a']['domain'], '.bar.test.example.com') 975 | 976 | def test_multisite_extra_hosts(self): 977 | # MULTISITE_EXTRA_HOSTS is set to ['.extrahost.com'] but 978 | # ALLOWED_HOSTS seems to be genereated in override_settings before 979 | # the extra hosts is added, so we need to recalculate it here. 980 | allowed = IterableLazyObject(lambda: AllowedHosts()) 981 | with override_settings(ALLOWED_HOSTS=allowed): 982 | response = HttpResponse() 983 | response.set_cookie(key='a', value='a', domain=None) 984 | middleware = CookieDomainMiddleware() 985 | request = self.factory.get('/', host='test.extrahost.com') 986 | cookies = middleware.process_response(request, response).cookies 987 | self.assertEqual(cookies['a']['domain'], '.extrahost.com') 988 | cookies['a']['domain'] = '' 989 | request = self.factory.get('/', host='foo.extrahost.com') 990 | cookies = middleware.process_response(request, response).cookies 991 | self.assertEqual(cookies['a']['domain'], '.extrahost.com') 992 | cookies['a']['domain'] = '' 993 | request = self.factory.get('/', host='foo.bar.extrahost.com') 994 | cookies = middleware.process_response(request, response).cookies 995 | self.assertEqual(cookies['a']['domain'], '.extrahost.com') 996 | 997 | 998 | if django.VERSION < (1, 8): 999 | TEMPLATE_SETTINGS = { 1000 | 'TEMPLATE_LOADERS': ['multisite.template.loaders.filesystem.Loader'], 1001 | 'TEMPLATE_DIRS': [os.path.join(os.path.abspath(os.path.dirname(__file__)), 1002 | 'test_templates')] 1003 | } 1004 | else: 1005 | TEMPLATE_SETTINGS = {'TEMPLATES':[ 1006 | { 1007 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 1008 | 'DIRS': [ 1009 | os.path.join(os.path.abspath(os.path.dirname(__file__)), 1010 | 'test_templates') 1011 | ], 1012 | 'OPTIONS': { 1013 | 'loaders': [ 1014 | 'multisite.template.loaders.filesystem.Loader', 1015 | ] 1016 | }, 1017 | } 1018 | ] 1019 | } 1020 | 1021 | 1022 | @override_settings( 1023 | MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates', 1024 | **TEMPLATE_SETTINGS 1025 | ) 1026 | class TemplateLoaderTests(TestCase): 1027 | 1028 | def test_get_template_multisite_default_dir(self): 1029 | template = get_template("test.html") 1030 | self.assertEqual(template.render(), "Test!") 1031 | 1032 | def test_domain_template(self): 1033 | template = get_template("example.html") 1034 | self.assertEqual(template.render(), "Test example.com template") 1035 | 1036 | def test_get_template_old_settings(self): 1037 | # tests that we can still get to the template filesystem loader with 1038 | # the old setting configuration 1039 | with override_settings( 1040 | TEMPLATES=[ 1041 | { 1042 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 1043 | 'DIRS': [ 1044 | os.path.join( 1045 | os.path.abspath(os.path.dirname(__file__)), 1046 | 'test_templates') 1047 | ], 1048 | 'OPTIONS': { 1049 | 'loaders': [ 1050 | 'multisite.template_loader.Loader', 1051 | ] 1052 | }, 1053 | } 1054 | ] 1055 | ): 1056 | template = get_template("test.html") 1057 | self.assertEqual(template.render(), "Test!") 1058 | 1059 | 1060 | class UpdatePublicSuffixListCommandTestCase(TestCase): 1061 | 1062 | def setUp(self): 1063 | self.cache_file = os.path.join(tempfile.gettempdir(), "multisite_tld.dat") 1064 | # save the tldextract logger output to a buffer to test output 1065 | self.out = StringIO() 1066 | self.logger = logging.getLogger('tldextract') 1067 | self.logger.setLevel(logging.DEBUG) 1068 | stdout_handler = logging.StreamHandler(self.out) 1069 | stdout_handler.setLevel(logging.DEBUG) 1070 | self.logger.addHandler(stdout_handler) 1071 | 1072 | # patch tldextract to avoid actual requests 1073 | self.patcher = mock.patch('tldextract.TLDExtract') 1074 | self.tldextract = self.patcher.start() 1075 | 1076 | def tearDown(self): 1077 | self.patcher.stop() 1078 | 1079 | def tldextract_update_side_effect(self, *args, **kwargs): 1080 | self.logger.debug('TLDExtract.update called') 1081 | 1082 | def test_command(self): 1083 | call_command('update_public_suffix_list') 1084 | expected_calls = [ 1085 | mock.call(cache_file=self.cache_file), 1086 | mock.call().update(fetch_now=True) 1087 | ] 1088 | self.assertEqual(self.tldextract.mock_calls, expected_calls) 1089 | 1090 | def test_command_output(self): 1091 | # make sure that the logger receives output from the method 1092 | self.tldextract().update.side_effect = self.tldextract_update_side_effect 1093 | 1094 | call_command('update_public_suffix_list', verbosity=3) 1095 | update_message = 'Updating {}'.format(self.cache_file) 1096 | self.assertIn(update_message, self.out.getvalue()) 1097 | self.assertIn('TLDExtract.update called', self.out.getvalue()) 1098 | -------------------------------------------------------------------------------- /multisite/threadlocals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | from __future__ import unicode_literals 3 | from __future__ import absolute_import 4 | 5 | import sys 6 | 7 | from django.utils import six 8 | from contextlib import contextmanager 9 | from warnings import warn 10 | 11 | try: 12 | from threading import local 13 | except ImportError: 14 | from django.utils._threading_local import local 15 | 16 | from django.core.exceptions import ImproperlyConfigured 17 | 18 | 19 | _thread_locals = local() 20 | 21 | 22 | def get_request(): 23 | return getattr(_thread_locals, 'request', None) 24 | 25 | 26 | class ThreadLocalsMiddleware(object): 27 | """Middleware that saves request in thread local starage""" 28 | def process_request(self, request): 29 | _thread_locals.request = request 30 | 31 | 32 | class SiteID(local): 33 | """ 34 | Dynamic settings.SITE_ID replacement, which acts like an integer. 35 | 36 | django.contrib.sites can allow multiple Django sites to share the 37 | same database. However, they cannot share the same code by 38 | default. 39 | 40 | SiteID can be used to replace the static settings.SITE_ID integer 41 | when combined with the appropriate middleware. 42 | """ 43 | 44 | def __init__(self, default=None, *args, **kwargs): 45 | """ 46 | ``default``, if specified, determines the default SITE_ID, 47 | if that is unset. 48 | """ 49 | if default is not None and not isinstance(default, six.integer_types): 50 | raise ValueError("%r is not a valid default." % default) 51 | self.default = default 52 | self.reset() 53 | 54 | def __repr__(self): 55 | return repr(self.__int__()) 56 | 57 | def __str__(self): 58 | return str(self.__int__()) 59 | 60 | def __int__(self): 61 | if self.site_id is None: 62 | return self.get_default() 63 | return self.site_id 64 | 65 | def __lt__(self, other): 66 | if isinstance(other, six.integer_types): 67 | return self.__int__() < other 68 | elif isinstance(other, SiteID): 69 | return self.__int__() < other.__int__() 70 | return True 71 | 72 | def __le__(self, other): 73 | if isinstance(other, six.integer_types): 74 | return self.__int__() <= other 75 | elif isinstance(other, SiteID): 76 | return self.__int__() <= other.__int__() 77 | return True 78 | 79 | def __eq__(self, other): 80 | if isinstance(other, six.integer_types): 81 | return self.__int__() == other 82 | elif isinstance(other, SiteID): 83 | return self.__int__() == other.__int__() 84 | return False 85 | 86 | def __ne__(self, other): 87 | return not self.__eq__(other) 88 | 89 | def __gt__(self, other): 90 | return not self.__le__(other) 91 | 92 | def __ge__(self, other): 93 | return not self.__lt__(other) 94 | 95 | def __hash__(self): 96 | return self.__int__() 97 | 98 | @contextmanager 99 | def override(self, value): 100 | """ 101 | Overrides SITE_ID temporarily:: 102 | 103 | >>> with settings.SITE_ID.override(2): 104 | ... print settings.SITE_ID 105 | 2 106 | """ 107 | site_id_original = self.site_id 108 | self.set(value) 109 | try: 110 | yield self 111 | finally: 112 | self.site_id = site_id_original 113 | 114 | def set(self, value): 115 | from django.db.models import Model 116 | if isinstance(value, Model): 117 | value = value.pk 118 | self.site_id = value 119 | 120 | def reset(self): 121 | self.site_id = None 122 | 123 | def get_default(self): 124 | """Returns the default SITE_ID.""" 125 | if self.default is None: 126 | raise ValueError('SITE_ID has not been set.') 127 | return self.default 128 | 129 | 130 | class SiteDomain(SiteID): 131 | def __init__(self, default, *args, **kwargs): 132 | """ 133 | ``default`` is the default domain name, resolved to SITE_ID, if 134 | that is unset. 135 | """ 136 | # make sure they passed us a string; doing this is the single hardest py2/py3 compat headache. 137 | # http://python-future.org/compatible_idioms.html#basestring and 138 | # https://github.com/PythonCharmers/python-future/blob/master/src/past/types/basestring.py 139 | # are not super informative, so just fall back on a literal version check: 140 | if not isinstance(default, basestring if sys.version_info.major == 2 else str): 141 | raise TypeError("%r is not a valid default domain." % default) 142 | self.default_domain = default 143 | self.default = None 144 | self.reset() 145 | 146 | def get_default(self): 147 | """Returns the default SITE_ID that matches the default domain name.""" 148 | from django.contrib.sites.models import Site 149 | if not Site._meta.installed: 150 | raise ImproperlyConfigured('django.contrib.sites is not in ' 151 | 'settings.INSTALLED_APPS') 152 | 153 | if self.default is None: 154 | qset = Site.objects.only('id') 155 | self.default = qset.get(domain=self.default_domain).id 156 | return self.default 157 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | django_find_project = false 3 | DJANGO_SETTINGS_MODULE = multisite.test_settings 4 | python_files = tests.py test_*.py *_tests.pyc 5 | addopts = --reuse-db 6 | python_paths = multisite 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import find_packages, setup 5 | 6 | _dir_ = os.path.dirname(__file__) 7 | 8 | 9 | if sys.version_info < (3, 4): 10 | install_requires = ['Django>=1.8,<2.0', 'tldextract>=1.2'] 11 | else: 12 | install_requires = ['Django>=1.8,<2.3', 'tldextract>=1.2'] 13 | 14 | 15 | def long_description(): 16 | """Returns the value of README.rst""" 17 | with open(os.path.join(_dir_, 'README.rst')) as f: 18 | return f.read() 19 | 20 | here = os.path.abspath(_dir_) 21 | version = {} 22 | with open(os.path.join(here, 'multisite', '__version__.py')) as f: 23 | exec(f.read(), version) 24 | 25 | 26 | files = ["multisite/test_templates/*"] 27 | 28 | setup(name='django-multisite', 29 | version=version['__version__'], 30 | description='Serve multiple sites from a single Django application', 31 | long_description=long_description(), 32 | author='Leonid S Shestera', 33 | author_email='leonid@shestera.ru', 34 | maintainer='Ecometrica', 35 | maintainer_email='dev@ecometrica.com', 36 | url='http://github.com/ecometrica/django-multisite', 37 | packages=find_packages(), 38 | include_package_data=True, 39 | package_data={'multisite': files}, 40 | install_requires=install_requires, 41 | setup_requires=['pytest-runner'], 42 | tests_require=['coverage', 'mock', 'pytest', 'pytest-cov', 43 | 'pytest-django', 'pytest-pythonpath', 'tox'], 44 | test_suite="multisite.tests", 45 | classifiers=['Development Status :: 4 - Beta', 46 | 'Environment :: Web Environment', 47 | 'Framework :: Django', 48 | 'Intended Audience :: Developers', 49 | 'License :: OSI Approved :: BSD License', 50 | 'Operating System :: OS Independent', 51 | 'Programming Language :: Python', 52 | 'Programming Language :: Python :: 2.7', 53 | 'Programming Language :: Python :: 3.4', 54 | 'Programming Language :: Python :: 3.5', 55 | 'Programming Language :: Python :: 3.6', 56 | 'Topic :: Internet', 57 | 'Topic :: Internet :: WWW/HTTP', 58 | 'Topic :: Software Development :: Libraries', 59 | 'Topic :: Utilities'], 60 | ) 61 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | setenv= 8 | PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} 9 | usedevelop = True 10 | envlist = 11 | py36-django{2.2,2.1,2.0,1.11} 12 | py35-django{2.1,2.0,1.11,1.10,1.9,1.8} 13 | py27-django{1.11,1.10,1.9,1.8} 14 | 15 | [testenv] 16 | commands = pytest --cov --cov-config .coveragerc --pyargs multisite 17 | deps = 18 | coverage 19 | pytest 20 | pytest-cov 21 | pytest-pythonpath 22 | pytest-django 23 | 24 | py27: mock 25 | django2.2: Django>=2.2,<2.3 26 | django2.1: Django>=2.1,<2.2 27 | django2.0: Django>=2.0,<2.1 28 | django1.11: Django>=1.11,<2.0 29 | django1.10: Django>=1.10,<1.11 30 | django1.9: Django>=1.9,<1.10 31 | django1.8: Django>=1.8,<1.9 32 | --------------------------------------------------------------------------------