diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0af74aa --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source=multisite +omit=multisite/tests.py,multisite/migrations/*,multisite/south_migrations/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 48905ee..70df889 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,16 @@ *.pyc *.pyo +.cache/ +.eggs/ .installed.cfg bin develop-eggs +django_multisite.egg-info/ dist downloads eggs parts +MANIFEST multisite/*.egg-info +.tox/ +.pytest_cache/ diff --git a/.hgtags b/.hgtags new file mode 100644 index 0000000..8fe82a1 --- /dev/null +++ b/.hgtags @@ -0,0 +1,14 @@ +eddc73ee54538a88c0a65496f9f70d0f8ff7ad54 version-0.2 +444842039a404fe6ffb5124865df2e5ab26e69a3 version-0.2.1 +0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 +0de1201845d838d910bb2076c849575690e3bed7 version-0.2.2 +c723f9796de60e2651b2d9f3b3688bd65c83df62 version-0.2.2 +be904a3e798ce001dc4b5feb0b82602368827906 version-0.2.3 +b1cedca9137cb7e4bf49e61205c216d7a0dd610c version-0.2.4 +ca16e31171a00aa53a54f2c93d1c31ecd8947e2b version-0.3.0 +3fa7a1923f4fad345e32d1616a8f38c31505eb8c version-0.3.1 +2da6336d70b099d1b817f72ba7144f15e2b21346 version-0.4.0 +1f497c216209683af3bc20bb15bb29ef305f7ca8 version-1.1.0 +16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 +16618d8dfaa888a2ad5a094d7dcbec6d68152e4e version-1.3.0 +efe5daef3c883dc309e64e6fa1ab88bb69098ab5 version-1.3.0 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dd43318 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +sudo: false +language: python +python: + - "2.7" + - "3.4" + - "3.5" + - "3.6" + +install: + - pip install tox-travis + - pip install coverage + - pip install python-coveralls + +script: + - tox + +after_success: + - coveralls \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..08e4a6a --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,105 @@ +============= +Release Notes +============= + +1.6.0 +----- +* Fix KeyError from _get_site_by_id +* Drop support for Django 1.7 +* Remove unnecessary cache type warnings +* Remove deprecated SiteIDHook + +1.5.0 +----- +* Support Django 2.0 (PR #47 and #60) +* Remove code for Django < 1.7 +* Remove obsolete PathAssistedCurrentSiteManager +* Remove obsolete template.loaders.cached +* Update README to better describe local development setup + +1.4.1 +----- +* Specify Django <2.0 in setup.py +* Drop support for python 3.3 + +1.4.0 +----- +* Support Django 1.10 (PR #38) and 1.11 +* Support Python 3 +* Remove support for Django <1.7 +* Use setuptools over distutils, and integrate the tests with them +* Use pytest and tox for testing +* Set up CI with travis +* Set up coverage and coveralls.io +* Document MULTISITE_EXTRA_HOSTS in README + +1.3.1 +----- + +* Add default for SiteID in the README (PR #31) +* Respect the CACHE_MULTISITE_ALIAS in SiteCache (PR #34) +* Replace deprecated ExtractResult().tld with .suffic (PR #32) + +1.3.0 +----- + +* Fix tempfile issue with update_public_suffix_list command +* Support for tldextract version >= 2.0 + +1.2.6 +---- + +* Pin the tldextract dependency to version < 2.0, which breaks API. + +1.2.5 +---- + +* Make template loading more resilient to changes in django (thanks to jbazik for the contribution) + +1.2.4 +----- + +* Fix domain validation so it's called after the pre_save signal + +1.2.3 +----- + +* Fix a broken test, due to a django uniqueness constraint in 1.9 + +1.2.2 +----- + +* Fix for 1.9: change the return type of filesystem template loader's get_template_sources() + +1.2.1 +----- + +* Remove django.utils.unittest (deprecated in 1.9) +* Use post_migrate instead of post_syncdb in > 1.7 + +1.2.0 +----- + +* We now support Django 1.9 +* Following deprecation in django, all get_cache methods have been replaced caches. + +1.1.0 +----- + +* We now support post-South Django 1.7 native migrations. + +1.0.0 +----- + +* 1.0 release. API stability promised from now on. +* Following the deprecation in Django itself, all get_query_set methods have been renamed to get_queryset. This means Django 1.6 is now the minimum required version. + +0.5.1 +----- + +* Add key prefix tests + +0.5.0 +----- + +* Allow use of cache key prefixes from the CACHES settings if CACHE_MULTISITE_KEY_PREFIX not set diff --git a/INSTALL.txt b/INSTALL.txt deleted file mode 100644 index c63dc3f..0000000 --- a/INSTALL.txt +++ /dev/null @@ -1,10 +0,0 @@ -Thanks for downloading django-multisite. - -To install it, run the following command inside this directory: - - python setup.py install - -Or if you'd prefer you can simply place the included ``django-multisite`` -directory somewhere on your Python path, or symlink to it from -somewhere on your Python path; this is useful if you're working from a -Subversion checkout. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7b05902 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include INSTALL.txt +include README.rst +graft multisite diff --git a/README.markdown b/README.markdown deleted file mode 100644 index 0d41b24..0000000 --- a/README.markdown +++ /dev/null @@ -1,34 +0,0 @@ -README -====== - -Get the code via svn: - - git clone git://github.com/shestera/django-multisite.git django-multisite - -Add the django-multisite/multisite folder to your PYTHONPATH. - -Replace your SITE_ID in settings.py to: - - from multisite.threadlocals import SiteIDHook - SITE_ID = SiteIDHook() - -Add to settings.py TEMPLATE_LOADERS: - - TEMPLATE_LOADERS = ( - 'multisite.template_loader.load_template_source', - 'django.template.loaders.app_directories.load_template_source', - ) - -Edit to settings.py MIDDLEWARE_CLASSES: - - MIDDLEWARE_CLASSES = ( - ... - 'multisite.middleware.DynamicSiteMiddleware', - ... - ) - -Create a directory settings.TEMPLATE_DIRS directory with the names of domains, such as: - - mkdir templates/example.com - - diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..67f39b3 --- /dev/null +++ b/README.rst @@ -0,0 +1,218 @@ +.. image:: https://travis-ci.org/ecometrica/django-multisite.svg?branch=master + :target: https://travis-ci.org/ecometrica/django-multisite?branch=master +.. image:: https://coveralls.io/repos/github/ecometrica/django-multisite/badge.svg?branch=master + :target: https://coveralls.io/github/ecometrica/django-multisite?branch=master + + +README +====== + +Install with pip:: + + pip install django-multisite + + +Or get the code via git:: + + git clone git://github.com/ecometrica/django-multisite.git django-multisite + +Then run:: + + python setup.py install + +Or add the django-multisite/multisite folder to your PYTHONPATH. + +If you wish to contribute, instead run:: + + python setup.py develop + + +Quickstart +---------- + +Replace your SITE_ID in settings.py to:: + + from multisite import SiteID + SITE_ID = SiteID(default=1) + +Add these to your INSTALLED_APPS:: + + INSTALLED_APPS = [ + ... + 'django.contrib.sites', + 'multisite', + ... + ] + +Add to your settings.py TEMPLATES loaders in the OPTIONS section:: + + TEMPLATES = [ + ... + { + ... + 'DIRS': {...} + 'OPTIONS': { + 'loaders': ( + 'multisite.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ) + } + ... + } + ... + ] + +Edit settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: + + MIDDLEWARE = ( + ... + 'multisite.middleware.DynamicSiteMiddleware', + ... + ) + +Append to settings.py, in order to use a custom cache that can be +safely cleared:: + + # The cache connection to use for django-multisite. + # Default: 'default' + CACHE_MULTISITE_ALIAS = 'multisite' + + # The cache key prefix that django-multisite should use. + # If not set, defaults to the KEY_PREFIX used in the defined + # CACHE_MULTISITE_ALIAS or the default cache (empty string if not set) + CACHE_MULTISITE_KEY_PREFIX = '' + +If you have set CACHE\_MULTISITE\_ALIAS to a custom value, *e.g.* +``'multisite'``, add a separate backend to settings.py CACHES:: + + CACHES = { + 'default': { + ... + }, + 'multisite': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 60 * 60 * 24, # 24 hours + ... + }, + } + + +Multisite determines the ALLOWED_HOSTS by checking all Alias domains. You can +also set the MULTISITE_EXTRA_HOSTS to include additional hosts. This can +include wildcards.:: + + MULTISITE_EXTRA_HOSTS = ['example.com'] + # will match the single additional host + + MULTISITE_EXTRA_HOSTS = ['.example.com'] + # will match any host ending '.example.com' + + +Development Environments +------------------------ +Multisite returns a valid Alias when in "development mode" (defaulting to the +alias associated with the default SiteID. + +Development mode is either: + - Running tests, i.e. manage.py test + - Running locally in settings.DEBUG = True, where the hostname is a top-level name, i.e. localhost + +In order to have multisite use aliases in local environments, add entries to +your local etc/hosts file to match aliases in your applications. E.g. :: + + 127.0.0.1 example.com + 127.0.0.1 examplealias.com + +And access your application at example.com:8000 or examplealias.com:8000 instead of +the usual localhost:8000. + + +Domain fallbacks +---------------- + +By default, if the domain name is unknown, multisite will respond with +an HTTP 404 Not Found error. To change this behaviour, add to +settings.py:: + + # The view function or class-based view that django-multisite will + # use when it cannot match the hostname with a Site. This can be + # the name of the function or the function itself. + # Default: None + MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView + + # Keyword arguments for the MULTISITE_FALLBACK view. + # Default: {} + MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + +Templates +--------- +If required, create template subdirectories for domain level templates (in a +location specified in settings.TEMPLATES['DIRS']. + +Multisite's template loader will look for templates in folders with the names of +domains, such as:: + + templates/example.com + + +The template loader will also look for templates in a folder specified by the +optional MULTISITE_DEFAULT_TEMPLATE_DIR setting, e.g.:: + + templates/multisite_templates + + +Cross-domain cookies +-------------------- + +In order to support `cross-domain cookies`_, +for purposes like single-sign-on, +prepend the following to the top of +settings.py MIDDLEWARE (MIDDLEWARE_CLASSES for Django < 1.10):: + + MIDDLEWARE = ( + 'multisite.middleware.CookieDomainMiddleware', + ... + ) + +CookieDomainMiddleware will consult the `Public Suffix List`_ +for effective top-level domains. +It caches this file +in the system's default temporary directory +as ``effective_tld_names.dat``. +To change this in settings.py:: + + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE = '/path/to/multisite_tld.dat' + +By default, +any cookies without a domain set +will be reset to allow \*.domain.tld. +To change this in settings.py:: + + MULTISITE_COOKIE_DOMAIN_DEPTH = 1 # Allow only *.subdomain.domain.tld + +In order to fetch a new version of the list, +run:: + + manage.py update_public_suffix_list + +.. _cross-domain cookies: http://en.wikipedia.org/wiki/HTTP_cookie#Domain_and_Path +.. _Public Suffix List: http://publicsuffix.org/ + + +Tests +----- + +To run the tests:: + + python setup.py test + +Or:: + + pytest + +Before deploying a change, to verify it has not broken anything by running:: + + tox + +This runs the tests under every supported combination of Django and Python. \ No newline at end of file diff --git a/multisite/__init__.py b/multisite/__init__.py index e69de29..56c7d5e 100644 --- a/multisite/__init__.py +++ b/multisite/__init__.py @@ -0,0 +1,2 @@ +from .threadlocals import SiteDomain, SiteID +from .__version__ import __version__ diff --git a/multisite/__version__.py b/multisite/__version__.py new file mode 100644 index 0000000..bcd8d54 --- /dev/null +++ b/multisite/__version__.py @@ -0,0 +1 @@ +__version__ = '1.6.0' diff --git a/multisite/admin.py b/multisite/admin.py new file mode 100644 index 0000000..820ff2a --- /dev/null +++ b/multisite/admin.py @@ -0,0 +1,243 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from django.contrib import admin +from django.contrib.admin.views.main import ChangeList +from django.contrib.sites.models import Site +from django.contrib.sites.admin import SiteAdmin + +from .forms import SiteForm +from .models import Alias + + +class AliasAdmin(admin.ModelAdmin): + """Admin for Alias model.""" + list_display = ('domain', 'site', 'is_canonical', 'redirect_to_canonical') + list_filter = ('is_canonical', 'redirect_to_canonical') + ordering = ('domain',) + raw_id_fields = ('site',) + readonly_fields = ('is_canonical',) + search_fields = ('domain',) + +admin.site.register(Alias, AliasAdmin) + + +class AliasInline(admin.TabularInline): + """Inline for Alias model, showing non-canonical aliases.""" + model = Alias + extra = 1 + ordering = ('domain',) + + def queryset(self, request): + """Returns only non-canonical aliases.""" + qs = self.model.aliases.get_queryset() + ordering = self.ordering or () + if ordering: + qs = qs.order_by(*ordering) + return qs + +# HACK: Monkeypatch AliasInline into SiteAdmin +SiteAdmin.inlines = type(SiteAdmin.inlines)([AliasInline]) + SiteAdmin.inlines + +# HACK: Monkeypatch Alias validation into SiteForm +SiteAdmin.form = SiteForm + + +class MultisiteChangeList(ChangeList): + """ + A ChangeList like the built-in admin one, but it excludes site filters for + sites you're not associated with, unless you're a super-user. + + At this point, it's probably fragile, given its reliance on Django + internals. + """ + def get_filters(self, request, *args, **kwargs): + """ + This might be considered a fragile function, since it relies on a + fair bit of Django's internals. + """ + get_filters = super(MultisiteChangeList, self).get_filters + filter_specs, has_filter_specs = get_filters(request, *args, **kwargs) + if request.user.is_superuser or not has_filter_specs: + return filter_specs, has_filter_specs + new_filter_specs = [] + profile = request.user.get_profile() + user_sites = frozenset(profile.sites.values_list("pk", "domain")) + for filter_spec in filter_specs: + try: + try: + remote_model = filter_spec.field.remote_field.model + except AttributeError: + remote_model = filter_spec.field.rel.to + except AttributeError: + new_filter_specs.append(filter_spec) + continue + if remote_model is not Site: + new_filter_specs.append(filter_spec) + continue + lookup_choices = frozenset(filter_spec.lookup_choices) & user_sites + if len(lookup_choices) > 1: + # put the choices back into the form they came in + filter_spec.lookup_choices = list(lookup_choices) + filter_spec.lookup_choices.sort() + new_filter_specs.append(filter_spec) + + return new_filter_specs, bool(new_filter_specs) + + +class MultisiteModelAdmin(admin.ModelAdmin): + """ + A very helpful ModelAdmin class for handling multi-site django + applications. + """ + + filter_sites_by_current_object = False + + def queryset(self, request): + """ + Filters lists of items to items belonging to sites assigned to the + current member. + + Additionally, for cases where the field containing a reference + to 'site' or 'sites' isn't immediate -- one can supply the + ModelAdmin class with a list of fields to check the site of: + + - multisite_filter_fields + A list of paths to a 'site' or 'sites' field on a related model to + filter the queryset on. + + (As long as you're not a superuser) + """ + qs = super(MultisiteModelAdmin, self).queryset(request) + if request.user.is_superuser: + return qs + + user_sites = request.user.get_profile().sites.all() + if hasattr(qs.model, "site"): + qs = qs.filter(site__in=user_sites) + elif hasattr(qs.model, "sites"): + qs = qs.filter(sites__in=user_sites) + + if hasattr(self, "multisite_filter_fields"): + for field in self.multisite_filter_fields: + qkwargs = { + "{field}__in".format(field=field): user_sites + } + qs = qs.filter(**qkwargs) + + return qs + + def add_view(self, request, form_url='', extra_context=None): + if self.filter_sites_by_current_object: + if hasattr(self.model, "site") or hasattr(self.model, "sites"): + self.object_sites = tuple() + return super(MultisiteModelAdmin, self).add_view(request, form_url, + extra_context) + + def change_view(self, request, object_id, extra_context=None): + if self.filter_sites_by_current_object: + object_instance = self.get_object(request, object_id) + try: + self.object_sites = object_instance.sites.values_list( + "pk", flat=True + ) + except AttributeError: + try: + self.object_sites = (object_instance.site.pk,) + except AttributeError: + pass # assume the object doesn't belong to a site + return super(MultisiteModelAdmin, self).change_view(request, object_id, + extra_context) + + def handle_multisite_foreign_keys(self, db_field, request, **kwargs): + """ + Filters the foreignkey queryset for fields referencing other models + to those models assigned to a site belonging to the current member + (if they aren't a superuser), and (optionally) belonging to the same + site as the current object. + + Also prevents (non-super) users from assigning objects to sites that + they are not members of. + + If the foreign key does not have a site/sites field directly, you can + specify a path to a site/sites field to filter on by setting the key: + + - multisite_foreign_key_site_path + + to a dictionary pointing specific foreign key field instances + from their model to the site field to filter on something + like: + + multisite_indirect_foreign_key_path = { + 'plan_instance': 'plan__site' + } + + for a field named 'plan_instance' referencing a model with a + foreign key named 'plan' having a foreign key to 'site'. + + To filter the FK queryset to the same sites the current object belongs + to, simply set `filter_sites_by_current_object` to `True`. + + Caveats: + + 1) If you're adding an object that belongs to a site (or sites), + and you've set `self.limit_sites_by_current_object = True`, + then the FK fields to objects that also belong to a site won't show + any objects. This is due to filtering on an empty queryset. + """ + + if request.user.is_superuser: + user_sites = Site.objects.all() + else: + user_sites = request.user.get_profile().sites.all() + if self.filter_sites_by_current_object and \ + hasattr(self, "object_sites"): + sites = user_sites.filter(pk__in=self.object_sites) + else: + sites = user_sites + + try: + remote_model = db_field.remote_field.model + except AttributeError: + remote_model = db_field.rel.to + if hasattr(remote_model, "site"): + kwargs["queryset"] = remote_model._default_manager.filter( + site__in=user_sites + ) + if hasattr(remote_model, "sites"): + kwargs["queryset"] = remote_model._default_manager.filter( + sites__in=user_sites + ) + if db_field.name == "site" or db_field.name == "sites": + kwargs["queryset"] = user_sites + if hasattr(self, "multisite_indirect_foreign_key_path") and \ + db_field.name in self.multisite_indirect_foreign_key_path.keys(): + fkey = self.multisite_indirect_foreign_key_path[db_field.name] + kwargs["queryset"] = remote_model._default_manager.filter( + **{fkey: user_sites} + ) + + return kwargs + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + kwargs = self.handle_multisite_foreign_keys(db_field, request, + **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_foreignkey( + db_field, request, **kwargs + ) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + kwargs = self.handle_multisite_foreign_keys(db_field, request, + **kwargs) + return super(MultisiteModelAdmin, self).formfield_for_manytomany( + db_field, request, **kwargs + ) + + def get_changelist(self, request, **kwargs): + """ + Restrict the site filter (if there is one) to sites you are + associated with, or remove it entirely if you're just + associated with one site. Unless you're a super-user, of + course. + """ + return MultisiteChangeList diff --git a/multisite/forms.py b/multisite/forms.py new file mode 100644 index 0000000..a100888 --- /dev/null +++ b/multisite/forms.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + + +from django.contrib.sites.admin import SiteAdmin +from django.core.exceptions import ValidationError + +from .models import Alias + + +class SiteForm(SiteAdmin.form): + def clean_domain(self): + domain = self.cleaned_data['domain'] + + try: + alias = Alias.objects.get(domain=domain) + except Alias.DoesNotExist: + # New Site that doesn't clobber an Alias + return domain + + if alias.site_id == self.instance.pk and alias.is_canonical: + return domain + + raise ValidationError('Cannot overwrite non-canonical Alias: "%s"' % + alias.domain) diff --git a/multisite/hacks.py b/multisite/hacks.py new file mode 100644 index 0000000..d0e2002 --- /dev/null +++ b/multisite/hacks.py @@ -0,0 +1,147 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import sys + +from django.conf import settings +from django.db.models.signals import post_save, post_delete + + +def use_framework_for_site_cache(): + """Patches sites app to use the caching framework instead of a dict.""" + # This patch has to exist because SITE_CACHE is normally a dict, + # which is local only to the process. When running multiple + # processes, a change to a Site will not be reflected across other + # ones. + from django.contrib.sites import models + + # Patch the SITE_CACHE + site_cache = SiteCache() + models.SITE_CACHE = DictCache(site_cache) + + # Patch the SiteManager class + models.SiteManager.clear_cache = SiteManager_clear_cache + models.SiteManager._get_site_by_id = SiteManager_get_site_by_id + + # Hooks to update SiteCache + post_save.connect(site_cache._site_changed_hook, sender=models.Site) + post_delete.connect(site_cache._site_deleted_hook, sender=models.Site) + + +# Override SiteManager.clear_cache so it doesn't clobber SITE_CACHE +def SiteManager_clear_cache(self): + """Clears the ``Site`` object cache.""" + models = sys.modules.get(self.__class__.__module__) + models.SITE_CACHE.clear() + + +# Override SiteManager._get_site_by_id +def SiteManager_get_site_by_id(self, site_id): + """ + Patch _get_site_by_id to retrieve the site from the cache at the + beginning of the method to avoid a race condition. + """ + models = sys.modules.get(self.__class__.__module__) + site = models.SITE_CACHE.get(site_id) + if site is None: + site = self.get(pk=site_id) + models.SITE_CACHE[site_id] = site + return site + + +class SiteCache(object): + """Wrapper for SITE_CACHE that assigns a key_prefix.""" + + def __init__(self, cache=None): + from django.core.cache import caches + + if cache is None: + cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', 'default') + self._key_prefix = getattr( + settings, + 'CACHE_MULTISITE_KEY_PREFIX', + settings.CACHES[cache_alias].get('KEY_PREFIX', '') + ) + cache = caches[cache_alias] + else: + self._key_prefix = getattr( + settings, 'CACHE_MULTISITE_KEY_PREFIX', cache.key_prefix + ) + self._cache = cache + + def _get_cache_key(self, key): + return 'sites.%s.%s' % (self.key_prefix, key) + + def _clean_site(self, site): + # Force site.id to be an int, not a SiteID object. + site.id = int(site.id) + return site + + @property + def key_prefix(self): + return self._key_prefix + + def get(self, key, *args, **kwargs): + return self._cache.get(key=self._get_cache_key(key), *args, **kwargs) + + def set(self, key, value, *args, **kwargs): + self._cache.set(key=self._get_cache_key(key), + value=self._clean_site(value), + *args, **kwargs) + + def delete(self, key, *args, **kwargs): + self._cache.delete(key=self._get_cache_key(key), *args, **kwargs) + + def __contains__(self, key, *args, **kwargs): + return self._cache.__contains__(key=self._get_cache_key(key), + *args, **kwargs) + + def clear(self, *args, **kwargs): + self._cache.clear(*args, **kwargs) + + def _site_changed_hook(self, sender, instance, raw, *args, **kwargs): + if raw: + return + self.set(key=instance.pk, value=instance) + + def _site_deleted_hook(self, sender, instance, *args, **kwargs): + self.delete(key=instance.pk) + + +class DictCache(object): + """Add dictionary protocol to django.core.cache.backends.BaseCache.""" + + def __init__(self, cache): + self._cache = cache + + def __getitem__(self, key): + """x.__getitem__(y) <==> x[y]""" + hash(key) # Raise TypeError if unhashable + result = self._cache.get(key=key) + if result is None: + raise KeyError(key) + return result + + def __setitem__(self, key, value): + """x.__setitem__(i, y) <==> x[i]=y""" + hash(key) # Raise TypeError if unhashable + self._cache.set(key=key, value=value) + + def __delitem__(self, key): + """x.__delitem__(y) <==> del x[y]""" + hash(key) # Raise TypeError if unhashable + self._cache.delete(key=key) + + def __contains__(self, item): + """D.__contains__(k) -> True if D has a key k, else False""" + hash(item) # Raise TypeError if unhashable + return self._cache.__contains__(key=item) + + def clear(self): + """D.clear() -> None. Remove all items from D.""" + self._cache.clear() + + def get(self, key, default=None, version=None): + """D.key(k[, d]) -> k if D has a key k, else d. Defaults to None""" + hash(key) # Raise TypeError if unhashable + return self._cache.get(key=key, default=default, version=version) diff --git a/multisite/hosts.py b/multisite/hosts.py new file mode 100644 index 0000000..90525a9 --- /dev/null +++ b/multisite/hosts.py @@ -0,0 +1,44 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from django.utils.functional import empty, SimpleLazyObject + + +__ALL__ = ('ALLOWED_HOSTS', 'AllowedHosts') + +_wrapped_default = empty + + +class IterableLazyObject(SimpleLazyObject): + + _wrapped_default = globals()['_wrapped_default'] + + def __iter__(self): + if self._wrapped is self._wrapped_default: + self._setup() + return self._wrapped.__iter__() + + +class AllowedHosts(object): + + alias_model = None + + def __init__(self): + from django.conf import settings + self.extra_hosts = getattr(settings, 'MULTISITE_EXTRA_HOSTS', []) + + if self.alias_model is None: + from .models import Alias + self.alias_model = Alias + + def __iter__(self): + # Yielding extra hosts before actual hosts because there might be + # wild cards in there that would prevent us from doing a database + # query every time. + for host in self.extra_hosts: + yield host + + for host in self.alias_model.objects.values_list('domain'): + yield host[0] + +ALLOWED_HOSTS = IterableLazyObject(lambda: AllowedHosts()) diff --git a/multisite/management/__init__.py b/multisite/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/management/commands/__init__.py b/multisite/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/multisite/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/multisite/management/commands/update_public_suffix_list.py b/multisite/management/commands/update_public_suffix_list.py new file mode 100644 index 0000000..88e6fdb --- /dev/null +++ b/multisite/management/commands/update_public_suffix_list.py @@ -0,0 +1,38 @@ +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import absolute_import + +import logging +import os +import tempfile + +from django.conf import settings +from django.core.management.base import BaseCommand + +import tldextract + + +class Command(BaseCommand): + def handle(self, **options): + self.setup_logging(verbosity=options.get('verbosity', 1)) + + filename = getattr( + settings, 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', + os.path.join(tempfile.gettempdir(), 'multisite_tld.dat') + ) + self.log("Updating {filename}".format(filename=filename)) + + extract = tldextract.TLDExtract(cache_file=filename) + extract.update(fetch_now=True) + self.log("Done.") + + def setup_logging(self, verbosity): + self.verbosity = int(verbosity) + + # Connect to tldextract's logger + self.logger = logging.getLogger('tldextract') + if self.verbosity < 2: + self.logger.setLevel(logging.CRITICAL) + + def log(self, msg): + self.logger.info(msg) diff --git a/multisite/managers.py b/multisite/managers.py new file mode 100644 index 0000000..c4fee11 --- /dev/null +++ b/multisite/managers.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -* +from __future__ import unicode_literals +from __future__ import absolute_import + +from django.db import models +from django.contrib.sites import managers +from django.db.models.fields import FieldDoesNotExist +from django.db.models.sql import constants + + +class SpanningCurrentSiteManager(managers.CurrentSiteManager): + """As opposed to django.contrib.sites.managers.CurrentSiteManager, this + CurrentSiteManager can span multiple related models by using the django + filtering syntax, namely foo__bar__baz__site. + + For example, let's say you have a model called Layer, which has a field + called family, which points to a model called LayerFamily, which in + turn has a field called site pointing to a django.contrib.sites Site + model. On Layer, add the following manager: + + on_site = SpanningCurrentSiteManager("family__site") + + and it will do the proper thing.""" + + def _validate_field_name(self): + """Given the field identifier, goes down the chain to check that + each specified field + a) exists, + b) is of type ForeignKey or ManyToManyField + + If no field name is specified when instantiating + SpanningCurrentSiteManager, it tries to find either 'site' or + 'sites' as the site link, much like CurrentSiteManager does. + """ + if self._CurrentSiteManager__field_name is None: + # Guess at field name + field_names = self.model._meta.get_all_field_names() + for potential_name in ['site', 'sites']: + if potential_name in field_names: + self._CurrentSiteManager__field_name = potential_name + break + else: + raise ValueError( + "%s couldn't find a field named either 'site' or 'sites' " + "in %s." % + (self.__class__.__name__, self.model._meta.object_name) + ) + + fieldname_chain = self._CurrentSiteManager__field_name.split( + constants.LOOKUP_SEP + ) + model = self.model + + for fieldname in fieldname_chain: + # Throws an exception if anything goes bad + self._validate_single_field_name(model, fieldname) + model = self._get_related_model(model, fieldname) + + # If we get this far without an exception, everything is good + self._CurrentSiteManager__is_validated = True + + def _validate_single_field_name(self, model, field_name): + """Checks if the given fieldname can be used to make a link between a + model and a site with the SpanningCurrentSiteManager class. If + anything is wrong, will raises an appropriate exception, because that + is what CurrentSiteManager expects.""" + try: + field = model._meta.get_field(field_name) + if not isinstance(field, (models.ForeignKey, + models.ManyToManyField)): + raise TypeError( + "Field %r must be a ForeignKey or ManyToManyField." + % field_name + ) + except FieldDoesNotExist: + raise ValueError( + "Couldn't find a field named %r in %s." % + (field_name, model._meta.object_name) + ) + + def _get_related_model(self, model, fieldname): + """Given a model and the name of a ForeignKey or ManyToManyField column + as a string, returns the associated model.""" + try: + return model._meta.get_field(fieldname).remote_field.model + except AttributeError: + return model._meta.get_field(fieldname).rel.to diff --git a/multisite/middleware.py b/multisite/middleware.py index ec34832..5f37828 100644 --- a/multisite/middleware.py +++ b/multisite/middleware.py @@ -1,48 +1,274 @@ # -*- coding: utf-8 -*- +from __future__ import unicode_literals +from __future__ import absolute_import + +import os +import tempfile +try: + from urlparse import urlsplit, urlunsplit +except ImportError: + from urllib.parse import urlsplit, urlunsplit + +import django from django.conf import settings -from django.contrib.sites.models import Site +from django.contrib.sites.models import Site, SITE_CACHE +from django.core.exceptions import DisallowedHost +from django.core import mail -HOST_CACHE = {} +from django.core.cache import caches -class DynamicSiteMiddleware(object): - def process_request(self, request): - host = request.get_host() - shost = host.rsplit(':', 1)[0] # only host, without port +try: + # Django > 1.10 uses MiddlewareMixin + from django.utils.deprecation import MiddlewareMixin +except ImportError: + MiddlewareMixin = object + +from django.core.exceptions import ImproperlyConfigured + +try: + from django.urls import get_callable +except ImportError: + # Django < 1.10 compatibility + from django.core.urlresolvers import get_callable + +from django.db.models.signals import pre_save, post_delete, post_init +from django.http import Http404, HttpResponsePermanentRedirect + +from hashlib import md5 as md5_constructor + +from .models import Alias + + +class DynamicSiteMiddleware(MiddlewareMixin): + def __init__(self, *args, **kwargs): + super(DynamicSiteMiddleware, self).__init__(*args, **kwargs) + if not hasattr(settings.SITE_ID, 'set'): + raise TypeError('Invalid type for settings.SITE_ID: %s' % + type(settings.SITE_ID).__name__) + + self.cache_alias = getattr(settings, 'CACHE_MULTISITE_ALIAS', + 'default') + self.key_prefix = getattr( + settings, + 'CACHE_MULTISITE_KEY_PREFIX', + settings.CACHES[self.cache_alias].get('KEY_PREFIX', '') + ) + + self.cache = caches[self.cache_alias] + post_init.connect(self.site_domain_cache_hook, sender=Site, + dispatch_uid='multisite_post_init') + pre_save.connect(self.site_domain_changed_hook, sender=Site) + post_delete.connect(self.site_deleted_hook, sender=Site) + + def get_cache_key(self, netloc): + """Returns a cache key based on ``netloc``.""" + netloc = md5_constructor(netloc.encode('utf-8')) + return 'multisite.alias.%s.%s' % (self.key_prefix, + netloc.hexdigest()) + + def netloc_parse(self, netloc): + """ + Returns ``(host, port)`` for ``netloc`` of the form ``'host:port'``. + + If netloc does not have a port number, ``port`` will be None. + """ + if ':' in netloc: + return netloc.rsplit(':', 1) + else: + return netloc, None + + def get_development_alias(self, netloc): + """ + Returns valid Alias when in development mode. Otherwise, returns None. + + Development mode is either: + - Running tests, i.e. manage.py test + - Running locally in settings.DEBUG = True, where the hostname is + a top-level name, i.e. localhost + """ + # When running tests, django.core.mail.outbox exists and + # netloc == 'testserver' + is_testserver = (hasattr(mail, 'outbox') and + netloc in ('testserver', 'adminsite.com')) + # When using runserver, assume that host will only have one path + # component. This covers 'localhost' and your machine name. + is_local_debug = (settings.DEBUG and len(netloc.split('.')) == 1) + if is_testserver or is_local_debug: + try: + # Prefer the default SITE_ID + site_id = settings.SITE_ID.get_default() + return Alias.canonical.get(site=site_id) + except ValueError: + # Fallback to the first Site object + return Alias.canonical.order_by('site')[0] + + def get_alias(self, netloc): + """ + Returns Alias matching ``netloc``. Otherwise, returns None. + """ + host, port = self.netloc_parse(netloc) try: - settings.SITE_ID.set(HOST_CACHE[host]) - return - except KeyError: - pass + alias = Alias.objects.resolve(host=host, port=port) + except ValueError: + alias = None - try: # get by whole hostname - site = Site.objects.get(domain=host) - HOST_CACHE[host] = site.pk - settings.SITE_ID.set(site.pk) - return - except Site.DoesNotExist: - pass + if alias is None: + # Running under TestCase or runserver? + return self.get_development_alias(netloc) + return alias + + def fallback_view(self, request): + """ + Runs the fallback view function in ``settings.MULTISITE_FALLBACK``. + + If ``MULTISITE_FALLBACK`` is None, raises an Http404 error. + + If ``MULTISITE_FALLBACK`` is callable, will treat that + callable as a view that returns an HttpResponse. - if shost != host: # get by hostname without port + If ``MULTISITE_FALLBACK`` is a string, will resolve it to a + view that returns an HttpResponse. + + In order to use a generic view that takes additional + parameters, ``settings.MULTISITE_FALLBACK_KWARGS`` may be a + dictionary of additional keyword arguments. + """ + fallback = getattr(settings, 'MULTISITE_FALLBACK', None) + if fallback is None: + raise Http404 + if callable(fallback): + view = fallback + else: try: - site = Site.objects.get(domain=shost) - HOST_CACHE[host] = site.pk - settings.SITE_ID.set(site.pk) - return - except Site.DoesNotExist: - pass - - try: # get by settings.SITE_ID - site = Site.objects.get(pk=settings.SITE_ID) - HOST_CACHE[host] = site.pk + view = get_callable(fallback) + if django.VERSION < (1,8): + # older django's get_callable falls through on error, + # returning the input as output + # which notably is definitely not a callable here + if not callable(view): + raise ImportError() + except ImportError: + # newer django forces this to be an error, which is tidier. + # we rewrite the error to be a bit more helpful to our users. + raise ImproperlyConfigured( + 'settings.MULTISITE_FALLBACK is not callable: %s' % + fallback + ) + + kwargs = getattr(settings, 'MULTISITE_FALLBACK_KWARGS', {}) + if hasattr(view, 'as_view'): + # Class-based view + return view.as_view(**kwargs)(request) + # View function + return view(request, **kwargs) + + def redirect_to_canonical(self, request, alias): + if not alias.redirect_to_canonical or alias.is_canonical: return - except Site.DoesNotExist: - pass + url = urlsplit(request.build_absolute_uri(request.get_full_path())) + url = urlunsplit((url.scheme, + alias.site.domain, + url.path, url.query, url.fragment)) + return HttpResponsePermanentRedirect(url) + + def process_request(self, request): + try: + netloc = request.get_host().lower() + except DisallowedHost: + settings.SITE_ID.reset() + return self.fallback_view(request) + + cache_key = self.get_cache_key(netloc) + + # Find the Alias in the cache + alias = self.cache.get(cache_key) + if alias is not None: + self.cache.set(cache_key, alias) + settings.SITE_ID.set(alias.site_id) + return self.redirect_to_canonical(request, alias) + + # Cache missed + alias = self.get_alias(netloc) + + # Fallback using settings.MULTISITE_FALLBACK + if alias is None: + settings.SITE_ID.reset() + return self.fallback_view(request) + + # Found Site + self.cache.set(cache_key, alias) + settings.SITE_ID.set(alias.site_id) + SITE_CACHE[settings.SITE_ID] = alias.site # Pre-populate SITE_CACHE + return self.redirect_to_canonical(request, alias) + + @classmethod + def site_domain_cache_hook(self, sender, instance, *args, **kwargs): + """Caches Site.domain in the object for site_domain_changed_hook.""" + instance._domain_cache = instance.domain - try: # misconfigured settings? - site = Site.objects.all()[0] - HOST_CACHE[host] = site.pk - settings.SITE_ID.set(site.pk) + def site_domain_changed_hook(self, sender, instance, raw, *args, **kwargs): + """Clears the cache if Site.domain has changed.""" + if raw or instance.pk is None: return - except IndexError: # no sites in db - pass + + original = getattr(instance, '_domain_cache', None) + if original != instance.domain: + self.cache.clear() + + def site_deleted_hook(self, *args, **kwargs): + """Clears the cache if Site was deleted.""" + self.cache.clear() + + +class CookieDomainMiddleware(MiddlewareMixin): + def __init__(self, *args, **kwargs): + super(CookieDomainMiddleware, self).__init__(*args, **kwargs) + self.depth = int(getattr(settings, 'MULTISITE_COOKIE_DOMAIN_DEPTH', 0)) + if self.depth < 0: + raise ValueError( + 'Invalid MULTISITE_COOKIE_DOMAIN_DEPTH: {depth!r}'.format( + depth=self.depth + ) + ) + self.psl_cache = getattr(settings, + 'MULTISITE_PUBLIC_SUFFIX_LIST_CACHE', + None) + if self.psl_cache is None: + self.psl_cache = os.path.join(tempfile.gettempdir(), + 'multisite_tld.dat') + self._tldextract = None + + def tldextract(self, url): + import tldextract + if self._tldextract is None: + self._tldextract = tldextract.TLDExtract(cache_file=self.psl_cache) + return self._tldextract(url) + + def match_cookies(self, request, response): + return [c for c in response.cookies.values() if not c['domain']] + + def process_response(self, request, response): + matched = self.match_cookies(request=request, response=response) + if not matched: + return response # No cookies to edit + + parsed = self.tldextract(request.get_host()) + if not parsed.suffix: + return response # IP address or local path + if not parsed.domain: + return response # Only TLD + + subdomains = parsed.subdomain.split('.') if parsed.subdomain else [] + if not self.depth: + subdomains = [''] + elif len(subdomains) < self.depth: + return response # Not enough subdomain parts + else: + subdomains = [''] + subdomains[-self.depth:] + + domain = '.'.join(subdomains + [parsed.domain, parsed.suffix]) + + for morsel in matched: + morsel['domain'] = domain + return response diff --git a/multisite/migrations/0001_initial.py b/multisite/migrations/0001_initial.py new file mode 100644 index 0000000..bc2d16e --- /dev/null +++ b/multisite/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from __future__ import absolute_import + +from django.db import models, migrations +import multisite.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Alias', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('domain', models.CharField(help_text='Either "domain" or "domain:port"', unique=True, max_length=100, verbose_name='domain name')), + ('is_canonical', models.NullBooleanField(default=None, validators=[multisite.models.validate_true_or_none], editable=False, help_text='Does this domain name match the one in site?', verbose_name='is canonical?')), + ('redirect_to_canonical', models.BooleanField(default=True, help_text='Should this domain name redirect to the one in site?', verbose_name='redirect to canonical?')), + ('site', models.ForeignKey(related_name='aliases', to='sites.Site', on_delete=models.CASCADE)), + ], + options={ + 'verbose_name_plural': 'aliases', + }, + ), + migrations.AlterUniqueTogether( + name='alias', + unique_together=set([('is_canonical', 'site')]), + ), + ] diff --git a/multisite/migrations/__init__.py b/multisite/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/models.py b/multisite/models.py new file mode 100644 index 0000000..48b493d --- /dev/null +++ b/multisite/models.py @@ -0,0 +1,327 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import operator +from functools import reduce + +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError +from django.core.validators import validate_ipv4_address +from django.db import connections, models, router +from django.db.models import Q +from django.db.models.signals import pre_save, post_save +from django.db.models.signals import post_migrate +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from .hacks import use_framework_for_site_cache + +try: + xrange +except NameError: # python3 + xrange = range + +_site_domain = Site._meta.get_field('domain') + +use_framework_for_site_cache() + + +class AliasManager(models.Manager): + """Manager for all Aliases.""" + + def get_queryset(self): + return super(AliasManager, self).get_queryset().select_related('site') + + def resolve(self, host, port=None): + """ + Returns the Alias that best matches ``host`` and ``port``, or None. + + ``host`` is a hostname like ``'example.com'``. + ``port`` is a port number like 8000, or None. + + Attempts to first match by 'host:port' against + Alias.domain. If that fails, it will try to match the bare + 'host' with no port number. + + All comparisons are done case-insensitively. + """ + domains = self._expand_netloc(host=host, port=port) + q = reduce(operator.or_, (Q(domain__iexact=d) for d in domains)) + aliases = dict((a.domain, a) for a in self.get_queryset().filter(q)) + for domain in domains: + try: + return aliases[domain] + except KeyError: + pass + + @classmethod + def _expand_netloc(cls, host, port=None): + """ + Returns a list of possible domain expansions for ``host`` and ``port``. + + ``host`` is a hostname like ``'example.com'``. + ``port`` is a port number like 8000, or None. + + Expansions are ordered from highest to lowest preference and may + include wildcards. Examples:: + + >>> AliasManager._expand_netloc('www.example.com') + ['www.example.com', '*.example.com', '*.com', '*'] + + >>> AliasManager._expand_netloc('www.example.com', 80) + ['www.example.com:80', 'www.example.com', + '*.example.com:80', '*.example.com', + '*.com:80', '*.com', + '*:80', '*'] + """ + if not host: + raise ValueError(u"Invalid host: %s" % host) + + try: + validate_ipv4_address(host) + bits = [host] + except ValidationError: + # Not an IP address + bits = host.split('.') + + result = [] + for i in xrange(0, (len(bits) + 1)): + if i == 0: + host = '.'.join(bits[i:]) + else: + host = '.'.join(['*'] + bits[i:]) + if port: + result.append("%s:%s" % (host, port)) + result.append(host) + return result + + +class CanonicalAliasManager(models.Manager): + """Manager for Alias objects where is_canonical is True.""" + + def get_queryset(self): + qset = super(CanonicalAliasManager, self).get_queryset() + return qset.filter(is_canonical=True) + + def sync_many(self, *args, **kwargs): + """ + Synchronize canonical Alias objects based on Site.domain. + + You can pass Q-objects or filter arguments to update a subset of + Alias objects:: + + Alias.canonical.sync_many(site__domain='example.com') + """ + aliases = self.get_queryset().filter(*args, **kwargs) + for alias in aliases.select_related('site'): + domain = alias.site.domain + if domain and alias.domain != domain: + alias.domain = domain + alias.save() + + def sync_missing(self): + """Create missing canonical Alias objects based on Site.domain.""" + aliases = self.get_queryset() + try: + sites = self.model._meta.get_field('site').remote_field.model + except AttributeError: + sites = self.model._meta.get_field('site').rel.to + for site in sites.objects.exclude(aliases__in=aliases): + Alias.sync(site=site) + + def sync_all(self): + """Create or sync canonical Alias objects from all Site objects.""" + self.sync_many() + self.sync_missing() + + +class NotCanonicalAliasManager(models.Manager): + """Manager for Aliases where is_canonical is None.""" + + def get_queryset(self): + qset = super(NotCanonicalAliasManager, self).get_queryset() + return qset.filter(is_canonical__isnull=True) + + +def validate_true_or_none(value): + """Raises ValidationError if value is not True or None.""" + if value not in (True, None): + raise ValidationError(u'%r must be True or None' % value) + + +@python_2_unicode_compatible +class Alias(models.Model): + """ + Model for domain-name aliases for Site objects. + + Domain names must be unique in the format of: 'hostname[:port].' + Each Site object that has a domain must have an ``is_canonical`` + Alias. + """ + + domain = type(_site_domain)( + _('domain name'), + max_length=_site_domain.max_length, + unique=True, + help_text=_('Either "domain" or "domain:port"'), + ) + site = models.ForeignKey( + Site, related_name='aliases', on_delete=models.CASCADE + ) + is_canonical = models.NullBooleanField( + _('is canonical?'), + default=None, editable=False, + validators=[validate_true_or_none], + help_text=_('Does this domain name match the one in site?'), + ) + redirect_to_canonical = models.BooleanField( + _('redirect to canonical?'), + default=True, + help_text=_('Should this domain name redirect to the one in site?'), + ) + + objects = AliasManager() + canonical = CanonicalAliasManager() + aliases = NotCanonicalAliasManager() + + class Meta: + unique_together = [('is_canonical', 'site')] + verbose_name_plural = _('aliases') + + def __str__(self): + return "%s -> %s" % (self.domain, self.site.domain) + + def __repr__(self): + return '' % str(self) + + def save_base(self, *args, **kwargs): + self.full_clean() + # For canonical Alias, domains must match Site domains. + # This needs to be validated here so that it is executed *after* the + # Site pre-save signal updates the domain (an AliasInline modelform + # on SiteAdmin will be saved (and it's clean methods run before + # the Site is saved) + if self.is_canonical and self.domain != self.site.domain: + raise ValidationError( + {'domain': ['Does not match %r' % self.site]} + ) + super(Alias, self).save_base(*args, **kwargs) + + def validate_unique(self, exclude=None): + errors = {} + try: + super(Alias, self).validate_unique(exclude=exclude) + except ValidationError as e: + errors = e.update_error_dict(errors) + + if exclude is not None and 'domain' not in exclude: + # Ensure domain is unique, insensitive to case + field_name = 'domain' + field_error = self.unique_error_message(self.__class__, + (field_name,)) + if field_name not in errors or \ + str(field_error) not in [str(err) for err in errors[field_name]]: + qset = self.__class__.objects.filter( + **{field_name + '__iexact': getattr(self, field_name)} + ) + if self.pk is not None: + qset = qset.exclude(pk=self.pk) + if qset.exists(): + errors.setdefault(field_name, []).append(field_error) + + if errors: + raise ValidationError(errors) + + @classmethod + def _sync_blank_domain(cls, site): + """Delete associated Alias object for ``site``, if domain is blank.""" + + if site.domain: + raise ValueError('%r has a domain' % site) + + # Remove canonical Alias, if no non-canonical aliases exist. + try: + alias = cls.objects.get(site=site) + except cls.DoesNotExist: + # Nothing to delete + pass + else: + if not alias.is_canonical: + raise cls.MultipleObjectsReturned( + 'Other %s still exist for %r' % + (cls._meta.verbose_name_plural.capitalize(), site) + ) + alias.delete() + + @classmethod + def sync(cls, site, force_insert=False): + """ + Create or synchronize Alias object from ``site``. + + If `force_insert`, forces creation of Alias object. + """ + domain = site.domain + if not domain: + cls._sync_blank_domain(site) + return + + if force_insert: + alias = cls.objects.create(site=site, is_canonical=True, + domain=domain) + + else: + alias, created = cls.objects.get_or_create( + site=site, is_canonical=True, + defaults={'domain': domain} + ) + if not created and alias.domain != domain: + alias.site = site + alias.domain = domain + alias.save() + + return alias + + @classmethod + def site_domain_changed_hook(cls, sender, instance, raw, *args, **kwargs): + """Updates canonical Alias object if Site.domain has changed.""" + if raw or instance.pk is None: + return + + try: + original = sender.objects.get(pk=instance.pk) + except sender.DoesNotExist: + return + + # Update Alias.domain to match site + if original.domain != instance.domain: + cls.sync(site=instance) + + @classmethod + def site_created_hook(cls, sender, instance, raw, created, + *args, **kwargs): + """Creates canonical Alias object for a new Site.""" + if raw or not created: + return + + # When running create_default_site() because of post_syncdb, + # don't try to sync before the db_table has been created. + using = router.db_for_write(cls) + tables = connections[using].introspection.table_names() + if cls._meta.db_table not in tables: + return + + # Update Alias.domain to match site + cls.sync(site=instance) + + @classmethod + def db_table_created_hook(cls, *args, **kwargs): + """Syncs canonical Alias objects for all existing Site objects.""" + Alias.canonical.sync_all() + + +# Hooks to handle Site objects being created or changed +pre_save.connect(Alias.site_domain_changed_hook, sender=Site) +post_save.connect(Alias.site_created_hook, sender=Site) + +# Hook to handle syncdb creating the Alias table +post_migrate.connect(Alias.db_table_created_hook) diff --git a/multisite/template/__init__.py b/multisite/template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/template/loaders/__init__.py b/multisite/template/loaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/multisite/template/loaders/filesystem.py b/multisite/template/loaders/filesystem.py new file mode 100644 index 0000000..b061754 --- /dev/null +++ b/multisite/template/loaders/filesystem.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from __future__ import absolute_import + +import os +from django.conf import settings +from django.contrib.sites.models import Site +from django.template.loaders.filesystem import Loader as FilesystemLoader +from django import VERSION as django_version + + +class Loader(FilesystemLoader): + def get_template_sources(self, *args, **kwargs): + template_name = args[0] + domain = Site.objects.get_current().domain + default_dir = getattr(settings, 'MULTISITE_DEFAULT_TEMPLATE_DIR', + 'default') + for tname in (os.path.join(domain, template_name), + os.path.join(default_dir, template_name)): + if django_version < (2, 0, 0): + args = [tname, None] + else: + args = [tname] + for item in super(Loader, self).get_template_sources(*args, **kwargs): + yield item diff --git a/multisite/template_loader.py b/multisite/template_loader.py index 7b36582..5461d86 100644 --- a/multisite/template_loader.py +++ b/multisite/template_loader.py @@ -1,28 +1,12 @@ -from django.template import TemplateDoesNotExist -from django.utils._os import safe_join -import os.path -from django.contrib.sites.models import Site -from django.conf import settings +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from __future__ import absolute_import -def get_template_sources(template_name, template_dirs=None): - template_dir = os.path.join(settings.TEMPLATE_DIRS[0], Site.objects.get_current().domain) - try: - yield safe_join(template_dir, template_name) - except UnicodeDecodeError: - raise - except ValueError: - pass +from .template.loaders.filesystem import Loader -def load_template_source(template_name, template_dirs=None): - tried = [] - for filepath in get_template_sources(template_name, template_dirs): - try: - return (open(filepath).read().decode(settings.FILE_CHARSET), filepath) - except IOError: - tried.append(filepath) - if tried: - error_msg = "Tried %s" % tried - else: - error_msg = "Your TEMPLATE_DIRS setting is empty. Change it to point to at least one template directory." - raise TemplateDoesNotExist, error_msg -load_template_source.is_usable = True +# The template.loaders.filesystem.Loader class used to live here. Now that +# we have more than one Loader class in the project, they are defined in the +# same fashion as Django's. +# For backward-compatibility reasons, Loader in this file points to what +# used to be defined here. +__all__ = ['Loader'] diff --git a/multisite/test_settings.py b/multisite/test_settings.py new file mode 100644 index 0000000..0f437b7 --- /dev/null +++ b/multisite/test_settings.py @@ -0,0 +1,31 @@ +import django +from multisite import SiteID + +SECRET_KEY = "iufoj=mibkpdz*%bob952x(%49rqgv8gg45k36kjcg76&-y5=!" + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test', + } +} + +INSTALLED_APPS = [ + 'django.contrib.sites', + 'multisite', +] + + +SITE_ID = SiteID(default=1) + +MIDDLEWARE = [ + 'multisite.middleware.DynamicSiteMiddleware', +] +if django.VERSION < (1,10,0): + # we are backwards compatible, but the settings file format has changed post-1.10: + # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware + MIDDLEWARE_CLASSES = list(MIDDLEWARE) + del MIDDLEWARE + + +TEST_RUNNER = 'django.test.runner.DiscoverRunner' diff --git a/multisite/test_templates/example.com/example.html b/multisite/test_templates/example.com/example.html new file mode 100644 index 0000000..0566fcb --- /dev/null +++ b/multisite/test_templates/example.com/example.html @@ -0,0 +1 @@ +Test example.com template \ No newline at end of file diff --git a/multisite/test_templates/multisite_templates/test.html b/multisite/test_templates/multisite_templates/test.html new file mode 100644 index 0000000..4d92dbe --- /dev/null +++ b/multisite/test_templates/multisite_templates/test.html @@ -0,0 +1 @@ +Test! \ No newline at end of file diff --git a/multisite/tests.py b/multisite/tests.py new file mode 100644 index 0000000..6eaa67b --- /dev/null +++ b/multisite/tests.py @@ -0,0 +1,1098 @@ +""" +Tests for django-multisite. + +To run this, use: +$ python -m multisite.tests +or +$ python setup.py test +from the parent directory. + +This file uses relative imports and so cannot be run standalone. +""" + +from __future__ import unicode_literals +from __future__ import absolute_import + +import django +import logging +import os +import pytest +import sys +import tempfile +import warnings +from unittest import skipUnless + +try: + from unittest import mock +except ImportError: + import mock + +from django.conf import settings +from django.conf.urls import url +from django.contrib.sites.models import Site +from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.management import call_command +from django.http import Http404, HttpResponse +from django.template.loader import get_template +from django.test import TestCase, override_settings +from django.test.client import RequestFactory as DjangoRequestFactory +from django.utils.six import StringIO + +from multisite import SiteDomain, SiteID, threadlocals + +from .hacks import use_framework_for_site_cache +from .hosts import ALLOWED_HOSTS, AllowedHosts, IterableLazyObject +from .middleware import CookieDomainMiddleware, DynamicSiteMiddleware +from .models import Alias + + +class RequestFactory(DjangoRequestFactory): + def __init__(self, host): + super(RequestFactory, self).__init__() + self.host = host + + def get(self, path, data={}, host=None, **extra): + if host is None: + host = self.host + return super(RequestFactory, self).get(path=path, data=data, + HTTP_HOST=host, **extra) + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings( + SITE_ID=SiteID(), + CACHE_SITES_KEY_PREFIX='__test__', +) +class TestContribSite(TestCase): + def setUp(self): + Site.objects.all().delete() + self.site = Site.objects.create(domain='example.com') + settings.SITE_ID.set(self.site.id) + + def test_get_current_site(self): + current_site = Site.objects.get_current() + self.assertEqual(current_site, self.site) + self.assertEqual(current_site.id, settings.SITE_ID) + +# Because we are a middleware package, we have no views available to test with easily +# So create one: +# (This is only used by test_integration) +urlpatterns = [ + url(r'^domain/$', lambda request, *args, **kwargs: HttpResponse(str(Site.objects.get_current()))) +] + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings( + ALLOWED_SITES=['*'], + ROOT_URLCONF=__name__, #this means that urlpatterns above is used when .get() is called below. + SITE_ID=SiteID(default=0), + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, + 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} + }, + MULTISITE_FALLBACK=None, + ALLOWED_HOSTS=ALLOWED_HOSTS +) +class DynamicSiteMiddlewareTest(TestCase): + def setUp(self): + self.host = 'example.com' + self.factory = RequestFactory(host=self.host) + + Site.objects.all().delete() + self.site = Site.objects.create(domain=self.host) + self.site2 = Site.objects.create(domain='anothersite.example') + + def test_valid_domain(self): + # Make the request + request = self.factory.get('/') + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Request again + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_valid_domain_port(self): + # Make the request with a specific port + request = self.factory.get('/', host=self.host + ':8000') + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Request again + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_case_sensitivity(self): + # Make the request in all uppercase + request = self.factory.get('/', host=self.host.upper()) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_change_domain(self): + # Make the request + request = self.factory.get('/') + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + # Another request with a different site + request = self.factory.get('/', host=self.site2.domain) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site2.pk) + + def test_unknown_host(self): + # Unknown host + request = self.factory.get('/', host='unknown') + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) + # The middleware resets SiteID to its default value, as given above, on error. + self.assertEqual(settings.SITE_ID, 0) + + def test_unknown_hostport(self): + # Unknown host:port + request = self.factory.get('/', host='unknown:8000') + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) + # The middleware resets SiteID to its default value, as given above, on error. + self.assertEqual(settings.SITE_ID, 0) + + def test_invalid_host(self): + # Invalid host + request = self.factory.get('/', host='') + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) + + def test_invalid_hostport(self): + # Invalid host:port + request = self.factory.get('/', host=':8000') + with self.assertRaises(Http404): + DynamicSiteMiddleware().process_request(request) + + def test_no_sites(self): + # FIXME: this needs to go into its own TestCase since it requires modifying the fixture to work properly + # Remove all Sites + Site.objects.all().delete() + # Make the request + request = self.factory.get('/') + self.assertRaises(Http404, + DynamicSiteMiddleware().process_request, request) + # The middleware resets SiteID to its default value, as given above, on error. + self.assertEqual(settings.SITE_ID, 0) + + def test_redirect(self): + host = 'example.org' + alias = Alias.objects.create(site=self.site, domain=host) + self.assertTrue(alias.redirect_to_canonical) + # Make the request + request = self.factory.get('/path', host=host) + response = DynamicSiteMiddleware().process_request(request) + self.assertEqual(response.status_code, 301) + self.assertEqual(response['Location'], + "http://%s/path" % self.host) + + def test_no_redirect(self): + host = 'example.org' + Alias.objects.create(site=self.site, domain=host, + redirect_to_canonical=False) + # Make the request + request = self.factory.get('/path', host=host) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, self.site.pk) + + def test_integration(self): + """ + Test that the middleware loads and runs properly under settings.MIDDLEWARE. + """ + resp = self.client.get('/domain/', HTTP_HOST=self.host) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, self.site.domain) + self.assertEqual(settings.SITE_ID, self.site.pk) + + resp = self.client.get('/domain/', HTTP_HOST=self.site2.domain) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, self.site2.domain) + self.assertEqual(settings.SITE_ID, self.site2.pk) + + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings( + SITE_ID=SiteID(default=0), + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'} + }, MULTISITE_FALLBACK=None, + MULTISITE_FALLBACK_KWARGS={}, +) +class DynamicSiteMiddlewareFallbackTest(TestCase): + def setUp(self): + self.factory = RequestFactory(host='unknown') + + Site.objects.all().delete() + + def test_404(self): + request = self.factory.get('/') + self.assertRaises(Http404, + DynamicSiteMiddleware().process_request, request) + self.assertEqual(settings.SITE_ID, 0) + + def test_testserver(self): + host = 'testserver' + site = Site.objects.create(domain=host) + request = self.factory.get('/', host=host) + self.assertEqual(DynamicSiteMiddleware().process_request(request), None) + self.assertEqual(settings.SITE_ID, site.pk) + + def test_string_class(self): + # Class based + settings.MULTISITE_FALLBACK = 'django.views.generic.base.RedirectView' + settings.MULTISITE_FALLBACK_KWARGS = {'url': 'http://example.com/', + 'permanent': False} + request = self.factory.get('/') + response = DynamicSiteMiddleware().process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], + settings.MULTISITE_FALLBACK_KWARGS['url']) + + def test_class_view(self): + from django.views.generic.base import RedirectView + settings.MULTISITE_FALLBACK = RedirectView.as_view( + url='http://example.com/', permanent=False + ) + request = self.factory.get('/') + response = DynamicSiteMiddleware().process_request(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], 'http://example.com/') + + def test_invalid(self): + settings.MULTISITE_FALLBACK = '' + request = self.factory.get('/') + self.assertRaises(ImproperlyConfigured, + DynamicSiteMiddleware().process_request, request) + + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings(SITE_ID=0,) +class DynamicSiteMiddlewareSettingsTest(TestCase): + def test_invalid_settings(self): + self.assertRaises(TypeError, DynamicSiteMiddleware) + + +@pytest.mark.django_db +@override_settings( + SITE_ID=SiteID(default=0), + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'} + }, + MULTISITE_FALLBACK=None, + ALLOWED_HOSTS=ALLOWED_HOSTS +) +class CacheTest(TestCase): + def setUp(self): + self.host = 'example.com' + self.factory = RequestFactory(host=self.host) + + Site.objects.all().delete() + self.site = Site.objects.create(domain=self.host) + + def test_site_domain_changed(self): + # Test to ensure that the cache is cleared properly + middleware = DynamicSiteMiddleware() + cache_key = middleware.get_cache_key(self.host) + self.assertEqual(middleware.cache.get(cache_key), None) + # Make the request + request = self.factory.get('/') + self.assertEqual(middleware.process_request(request), None) + self.assertEqual(middleware.cache.get(cache_key).site_id, + self.site.pk) + # Change the domain name + self.site.domain = 'example.org' + self.site.save() + self.assertEqual(middleware.cache.get(cache_key), None) + # Make the request again, which will now be invalid + request = self.factory.get('/') + self.assertRaises(Http404, + middleware.process_request, request) + self.assertEqual(settings.SITE_ID, 0) + + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +@override_settings(SITE_ID=SiteID(),) +class SiteCacheTest(TestCase): + + def _initialize_cache(self): + # initialize cache again so override key prefix settings are used + from django.contrib.sites import models + use_framework_for_site_cache() + self.cache = models.SITE_CACHE + + def setUp(self): + from django.contrib.sites import models + + if hasattr(models, 'clear_site_cache'): + # Before Django 1.6, the Site cache is cleared after the Site + # object has been created. This replicates that behaviour. + def save(self, *args, **kwargs): + super(models.Site, self).save(*args, **kwargs) + models.SITE_CACHE.clear() + models.Site.save = save + + self._initialize_cache() + Site.objects.all().delete() + self.host = 'example.com' + self.site = Site.objects.create(domain=self.host) + settings.SITE_ID.set(self.site.id) + + def test_get_current(self): + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual(self.cache.get(key=self.site.id), self.site) + self.assertEqual(self.cache.get(key=-1), + None) # Site doesn't exist + self.assertEqual(self.cache.get(-1, 'Default'), + 'Default') # Site doesn't exist + self.assertEqual(self.cache.get(key=-1, default='Non-existant'), + 'Non-existant') # Site doesn't exist + self.assertEqual('Non-existant', + self.cache.get(self.site.id, default='Non-existant', + version=100)) # Wrong key version 3 + # Clear cache + self.cache.clear() + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + self.assertEqual(self.cache.get(key=self.site.id, default='Cleared'), + 'Cleared') + + def test_create_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Create new site + site = Site.objects.create(domain='example.org') + settings.SITE_ID.set(site.id) + self.assertEqual(Site.objects.get_current(), site) + self.assertEqual(Site.objects.get_current().domain, site.domain) + + def test_change_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Change site domain + self.site.domain = 'example.org' + self.site.save() + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + + def test_delete_site(self): + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(Site.objects.get_current().domain, self.site.domain) + # Delete site + self.site.delete() + self.assertRaises(KeyError, self.cache.__getitem__, self.site.id) + + @override_settings(CACHE_MULTISITE_KEY_PREFIX="__test__") + def test_multisite_key_prefix(self): + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + 'sites.{}.{}'.format( + settings.CACHE_MULTISITE_KEY_PREFIX, self.site.id + ), + self.cache._cache._get_cache_key(self.site.id) + ) + + @override_settings( + CACHE_MULTISITE_ALIAS='multisite', + CACHES={ + 'multisite': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'KEY_PREFIX': 'looselycoupled' + } + }, + ) + def test_default_key_prefix(self): + """ + If CACHE_MULTISITE_KEY_PREFIX is undefined, + the caching system should use CACHES[current]['KEY_PREFIX']. + """ + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + "sites.looselycoupled.{}".format(self.site.id) + ) + + @override_settings( + CACHE_MULTISITE_KEY_PREFIX="virtuouslyvirtual", + ) + def test_multisite_key_prefix_takes_priority_over_default(self): + self._initialize_cache() + # Populate cache + self.assertEqual(Site.objects.get_current(), self.site) + self.assertEqual(self.cache[self.site.id], self.site) + self.assertEqual( + self.cache._cache._get_cache_key(self.site.id), + "sites.virtuouslyvirtual.{}".format(self.site.id) + ) + + +@pytest.mark.django_db +class TestSiteID(TestCase): + def setUp(self): + Site.objects.all().delete() + self.site = Site.objects.create(domain='example.com') + self.site_id = SiteID() + + def test_invalid_default(self): + self.assertRaises(ValueError, SiteID, default='a') + self.assertRaises(ValueError, SiteID, default=self.site_id) + + def test_compare_default_site_id(self): + self.site_id = SiteID(default=self.site.id) + self.assertEqual(self.site_id, self.site.id) + self.assertFalse(self.site_id != self.site.id) + self.assertFalse(self.site_id < self.site.id) + self.assertTrue(self.site_id <= self.site.id) + self.assertFalse(self.site_id > self.site.id) + self.assertTrue(self.site_id >= self.site.id) + + def test_compare_site_ids(self): + self.site_id.set(1) + self.assertEqual(self.site_id, self.site_id) + self.assertFalse(self.site_id != self.site_id) + self.assertFalse(self.site_id < self.site_id) + self.assertTrue(self.site_id <= self.site_id) + self.assertFalse(self.site_id > self.site_id) + self.assertTrue(self.site_id >= self.site_id) + + def test_compare_differing_types(self): + self.site_id.set(1) + self.assertNotEqual(self.site_id, '1') + self.assertFalse(self.site_id == '1') + self.assertTrue(self.site_id < '1') + self.assertTrue(self.site_id <= '1') + self.assertFalse(self.site_id > '1') + self.assertFalse(self.site_id >= '1') + self.assertNotEqual('1', self.site_id) + self.assertFalse('1' == self.site_id) + self.assertFalse('1' < self.site_id) + self.assertFalse('1' <= self.site_id) + self.assertTrue('1' > self.site_id) + self.assertTrue('1' >= self.site_id) + + def test_set(self): + self.site_id.set(10) + self.assertEqual(int(self.site_id), 10) + self.site_id.set(20) + self.assertEqual(int(self.site_id), 20) + self.site_id.set(self.site) + self.assertEqual(int(self.site_id), self.site.id) + + def test_hash(self): + self.site_id.set(10) + self.assertEqual(hash(self.site_id), 10) + self.site_id.set(20) + self.assertEqual(hash(self.site_id), 20) + + def test_str_repr(self): + self.site_id.set(10) + self.assertEqual(str(self.site_id), '10') + self.assertEqual(repr(self.site_id), '10') + + def test_context_manager(self): + self.assertEqual(self.site_id.site_id, None) + with self.site_id.override(1): + self.assertEqual(self.site_id.site_id, 1) + with self.site_id.override(2): + self.assertEqual(self.site_id.site_id, 2) + self.assertEqual(self.site_id.site_id, 1) + self.assertEqual(self.site_id.site_id, None) + + +@pytest.mark.django_db +@skipUnless(Site._meta.installed, + 'django.contrib.sites is not in settings.INSTALLED_APPS') +class TestSiteDomain(TestCase): + def setUp(self): + Site.objects.all().delete() + self.domain = 'example.com' + self.site = Site.objects.create(domain=self.domain) + + def test_init(self): + self.assertEqual(int(SiteDomain(default=self.domain)), self.site.id) + self.assertRaises(Site.DoesNotExist, + int, SiteDomain(default='invalid')) + self.assertRaises(TypeError, SiteDomain, default=None) + self.assertRaises(TypeError, SiteDomain, default=1) + + def test_deferred_site(self): + domain = 'example.org' + self.assertRaises(Site.DoesNotExist, + int, SiteDomain(default=domain)) + site = Site.objects.create(domain=domain) + self.assertEqual(int(SiteDomain(default=domain)), + site.id) + + +@pytest.mark.django_db +class AliasTest(TestCase): + def setUp(self): + Alias.objects.all().delete() + Site.objects.all().delete() + + def test_create(self): + site0 = Site.objects.create() + site1 = Site.objects.create(domain='1.example') + site2 = Site.objects.create(domain='2.example') + # Missing site + self.assertRaises(ValidationError, Alias.objects.create) + self.assertRaises(ValidationError, + Alias.objects.create, domain='0.example') + # Valid + self.assertTrue(Alias.objects.create(domain='1a.example', site=site1)) + # Duplicate domain + self.assertRaises( + ValidationError, + Alias.objects.create, domain=site1.domain, site=site1 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain=site2.domain, site=site1 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain='1a.example', site=site1 + ) + # Duplicate domains, case-sensitivity + self.assertRaises( + ValidationError, + Alias.objects.create, domain='1A.EXAMPLE', site=site2 + ) + self.assertRaises( + ValidationError, + Alias.objects.create, domain='2.EXAMPLE', site=site2 + ) + # Duplicate is_canonical + site1.domain = '1b.example' + self.assertRaises( + ValidationError, + Alias.objects.create, + domain=site1.domain, site=site1, is_canonical=True + ) + # Invalid is_canonical + self.assertRaises( + ValidationError, + Alias.objects.create, + domain=site1.domain, site=site1, is_canonical=False + ) + + def test_repr(self): + site = Site.objects.create(domain='example.com') + self.assertEqual(repr(Alias.objects.get(site=site)), + u' %(domain)s>' % site.__dict__) + + def test_managers(self): + site = Site.objects.create(domain='example.com') + Alias.objects.create(site=site, domain='example.org') + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set(['example.com', 'example.org'])) + self.assertEqual(set(Alias.canonical.values_list('domain', flat=True)), + set(['example.com'])) + self.assertEqual(set(Alias.aliases.values_list('domain', flat=True)), + set(['example.org'])) + + def test_sync_many(self): + # Create Sites with Aliases + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + site2 = Site.objects.create(domain='2.example.com') + # Create Site without triggering signals + site3 = Site(domain='3.example.com') + site3.save_base(raw=True) + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + # Sync existing + site1.domain = '1.example.org' + site1.save_base(raw=True) + site2.domain = '2.example.org' + site2.save_base(raw=True) + Alias.canonical.sync_many() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + # Sync with filter + site1.domain = '1.example.net' + site1.save_base(raw=True) + site2.domain = '2.example.net' + site2.save_base(raw=True) + Alias.canonical.sync_many(site__domain=site1.domain) + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, '2.example.org'])) + + def test_sync_missing(self): + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + # Update site1 without triggering signals + site1.domain = '1.example.org' + site1.save_base(raw=True) + # Create site2 without triggering signals + site2 = Site(domain='2.example.org') + site2.save_base(raw=True) + # Only site2 should be updated + Alias.canonical.sync_missing() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set(['1.example.com', site2.domain])) + + def test_sync_all(self): + Site.objects.create() + site1 = Site.objects.create(domain='1.example.com') + # Update site1 without triggering signals + site1.domain = '1.example.org' + site1.save_base(raw=True) + # Create site2 without triggering signals + site2 = Site(domain='2.example.org') + site2.save_base(raw=True) + # Sync all + Alias.canonical.sync_all() + self.assertEqual(set(Alias.objects.values_list('domain', flat=True)), + set([site1.domain, site2.domain])) + + def test_sync(self): + # Create Site without triggering signals + site = Site(domain='example.com') + site.save_base(raw=True) + # Insert Alias + self.assertFalse(Alias.objects.filter(site=site).exists()) + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Idempotent sync_alias + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Duplicate force_insert + self.assertRaises(ValidationError, + Alias.sync, site=site, force_insert=True) + # Update Alias + site.domain = 'example.org' + Alias.sync(site=site) + self.assertEqual(Alias.objects.get(site=site).domain, site.domain) + # Clear domain + site.domain = '' + Alias.sync(site=site) + self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_sync_blank_domain(self): + # Create Site + site = Site.objects.create(domain='example.com') + # Without clearing domain + self.assertRaises(ValueError, Alias._sync_blank_domain, site) + # With an extra Alias + site.domain = '' + alias = Alias.objects.create(site=site, domain='example.org') + self.assertRaises(Alias.MultipleObjectsReturned, + Alias._sync_blank_domain, site) + # With a blank site + alias.delete() + Alias._sync_blank_domain(site) + self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_hooks(self): + # Create empty Site + Site.objects.create() + self.assertFalse(Alias.objects.filter(domain='').exists()) + # Create Site + site = Site.objects.create(domain='example.com') + alias = Alias.objects.get(site=site) + self.assertEqual(alias.domain, site.domain) + self.assertTrue(alias.is_canonical) + # Create a non-canonical alias + Alias.objects.create(site=site, domain='example.info') + # Change Site to another domain name + site.domain = 'example.org' + site.save() + self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) + self.assertEqual(Alias.aliases.get(site=site).domain, 'example.info') + # Change Site to an empty domain name + site.domain = '' + self.assertRaises(Alias.MultipleObjectsReturned, site.save) + Alias.aliases.all().delete() + Site.objects.get(domain='').delete() # domain is unique in Django1.9 + site.save() + self.assertFalse(Alias.objects.filter(site=site).exists()) + # Change Site from an empty domain name + site.domain = 'example.net' + site.save() + self.assertEqual(Alias.canonical.get(site=site).domain, site.domain) + # Delete Site + site.delete() + self.assertFalse(Alias.objects.filter(site=site).exists()) + + def test_expand_netloc(self): + _expand_netloc = Alias.objects._expand_netloc + self.assertRaises(ValueError, _expand_netloc, '') + self.assertRaises(ValueError, _expand_netloc, '', 8000) + self.assertEqual(_expand_netloc('testserver', 8000), + ['testserver:8000', 'testserver', + '*:8000', '*']) + self.assertEqual(_expand_netloc('testserver'), + ['testserver', '*']) + self.assertEqual(_expand_netloc('example.com', 8000), + ['example.com:8000', 'example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('example.com'), + ['example.com', '*.com', '*']) + self.assertEqual(_expand_netloc('www.example.com', 8000), + ['www.example.com:8000', 'www.example.com', + '*.example.com:8000', '*.example.com', + '*.com:8000', '*.com', + '*:8000', '*']) + self.assertEqual(_expand_netloc('www.example.com'), + ['www.example.com', '*.example.com', '*.com', '*']) + + def test_resolve(self): + site = Site.objects.create(domain='example.com') + # *.example.com + self.assertEqual(Alias.objects.resolve('www.example.com'), + None) + self.assertEqual(Alias.objects.resolve('www.dev.example.com'), + None) + alias = Alias.objects.create(site=site, domain='*.example.com') + self.assertEqual(Alias.objects.resolve('www.example.com'), + alias) + self.assertEqual(Alias.objects.resolve('www.dev.example.com'), + alias) + # * + self.assertEqual(Alias.objects.resolve('example.net'), + None) + alias = Alias.objects.create(site=site, domain='*') + self.assertEqual(Alias.objects.resolve('example.net'), + alias) + + + +@pytest.mark.django_db +@override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=0, + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE=None, + ALLOWED_HOSTS=ALLOWED_HOSTS, + MULTISITE_EXTRA_HOSTS=['.extrahost.com'] +) +class TestCookieDomainMiddleware(TestCase): + + def setUp(self): + self.factory = RequestFactory(host='example.com') + Site.objects.all().delete() + # create sites so we populate ALLOWED_HOSTS + Site.objects.create(domain='example.com') + Site.objects.create(domain='test.example.com') + Site.objects.create(domain='app.test1.example.com') + Site.objects.create(domain='app.test2.example.com') + Site.objects.create(domain='new.app.test3.example.com') + + def test_init(self): + self.assertEqual(CookieDomainMiddleware().depth, 0) + self.assertEqual(CookieDomainMiddleware().psl_cache, + os.path.join(tempfile.gettempdir(), + 'multisite_tld.dat')) + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=1, + MULTISITE_PUBLIC_SUFFIX_LIST_CACHE='/var/psl'): + middleware = CookieDomainMiddleware() + self.assertEqual(middleware.depth, 1) + self.assertEqual(middleware.psl_cache, '/var/psl') + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=-1): + self.assertRaises(ValueError, CookieDomainMiddleware) + + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH='invalid'): + self.assertRaises(ValueError, CookieDomainMiddleware) + + def test_no_matched_cookies(self): + # No cookies + request = self.factory.get('/') + response = HttpResponse() + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), + []) + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(list(cookies.values()), []) + + # Add some cookies with their domains already set + response.set_cookie(key='a', value='a', domain='.example.org') + response.set_cookie(key='b', value='b', domain='.example.co.uk') + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), + []) + cookies = CookieDomainMiddleware().process_response(request, response).cookies + + if sys.version_info.major < 3: # for testing under Python 2.X + self.assertItemsEqual( + list(cookies.values()), [cookies['a'], cookies['b']] + ) + else: + self.assertCountEqual( + list(cookies.values()), [cookies['a'], cookies['b']] + ) + self.assertEqual(cookies['a']['domain'], '.example.org') + self.assertEqual(cookies['b']['domain'], '.example.co.uk') + + def test_matched_cookies(self): + request = self.factory.get('/') + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + self.assertEqual(CookieDomainMiddleware().match_cookies(request, response), + [response.cookies['a']]) + # No new cookies should be introduced + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(list(cookies.values()), [cookies['a']]) + + def test_ip_address(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + allowed = [host for host in ALLOWED_HOSTS] + ['192.0.43.10'] + # IP addresses should not be mutated + with override_settings(ALLOWED_HOSTS=allowed): + request = self.factory.get('/', host='192.0.43.10') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + + def test_localpath(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['localhost', 'localhost.localdomain'] + with override_settings(ALLOWED_HOSTS=allowed): + # Local domains should not be mutated + request = self.factory.get('/', host='localhost') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Even local subdomains + request = self.factory.get('/', host='localhost.localdomain') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + + def test_simple_tld(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['ai', 'www.ai'] + with override_settings(ALLOWED_HOSTS=allowed): + # Top-level domains shouldn't get mutated + request = self.factory.get('/', host='ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains inside a TLD are OK + request = self.factory.get('/', host='www.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.www.ai') + + def test_effective_tld(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + \ + ['com.ai', 'nic.com.ai'] + with override_settings(ALLOWED_HOSTS=allowed): + # Effective top-level domains with a webserver shouldn't get mutated + request = self.factory.get('/', host='com.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # Domains within an effective TLD are OK + request = self.factory.get('/', host='nic.com.ai') + cookies = CookieDomainMiddleware().process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.nic.com.ai') + + def test_subdomain_depth(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + ['com'] + with override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=1, ALLOWED_HOSTS=allowed + ): + # At depth 1: + middleware = CookieDomainMiddleware() + # Top-level domains are ignored + request = self.factory.get('/', host='com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # As are domains within a TLD + request = self.factory.get('/', host='example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '') + # But subdomains will get matched + request = self.factory.get('/', host='test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.test.example.com') + # And sub-subdomains will get matched to 1 level deep + cookies['a']['domain'] = '' + request = self.factory.get('/', host='app.test1.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.test1.example.com') + + def test_subdomain_depth_2(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + with override_settings(MULTISITE_COOKIE_DOMAIN_DEPTH=2): + # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to + # 2 levels deep + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='app.test2.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.app.test2.example.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='new.app.test3.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.app.test3.example.com') + + def test_wildcard_subdomains(self): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + + allowed = [host for host in ALLOWED_HOSTS] + ['.test.example.com'] + with override_settings( + MULTISITE_COOKIE_DOMAIN_DEPTH=2, ALLOWED_HOSTS=allowed + ): + # At MULTISITE_COOKIE_DOMAIN_DEPTH 2, subdomains are matched to + # 2 levels deep against the wildcard + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='foo.test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.foo.test.example.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.bar.test.example.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.bar.test.example.com') + + def test_multisite_extra_hosts(self): + # MULTISITE_EXTRA_HOSTS is set to ['.extrahost.com'] but + # ALLOWED_HOSTS seems to be genereated in override_settings before + # the extra hosts is added, so we need to recalculate it here. + allowed = IterableLazyObject(lambda: AllowedHosts()) + with override_settings(ALLOWED_HOSTS=allowed): + response = HttpResponse() + response.set_cookie(key='a', value='a', domain=None) + middleware = CookieDomainMiddleware() + request = self.factory.get('/', host='test.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + cookies['a']['domain'] = '' + request = self.factory.get('/', host='foo.bar.extrahost.com') + cookies = middleware.process_response(request, response).cookies + self.assertEqual(cookies['a']['domain'], '.extrahost.com') + + +if django.VERSION < (1, 8): + TEMPLATE_SETTINGS = { + 'TEMPLATE_LOADERS': ['multisite.template.loaders.filesystem.Loader'], + 'TEMPLATE_DIRS': [os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates')] + } +else: + TEMPLATE_SETTINGS = {'TEMPLATES':[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'test_templates') + ], + 'OPTIONS': { + 'loaders': [ + 'multisite.template.loaders.filesystem.Loader', + ] + }, + } + ] + } + + +@override_settings( + MULTISITE_DEFAULT_TEMPLATE_DIR='multisite_templates', + **TEMPLATE_SETTINGS +) +class TemplateLoaderTests(TestCase): + + def test_get_template_multisite_default_dir(self): + template = get_template("test.html") + self.assertEqual(template.render(), "Test!") + + def test_domain_template(self): + template = get_template("example.html") + self.assertEqual(template.render(), "Test example.com template") + + def test_get_template_old_settings(self): + # tests that we can still get to the template filesystem loader with + # the old setting configuration + with override_settings( + TEMPLATES=[ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join( + os.path.abspath(os.path.dirname(__file__)), + 'test_templates') + ], + 'OPTIONS': { + 'loaders': [ + 'multisite.template_loader.Loader', + ] + }, + } + ] + ): + template = get_template("test.html") + self.assertEqual(template.render(), "Test!") + + +class UpdatePublicSuffixListCommandTestCase(TestCase): + + def setUp(self): + self.cache_file = '/tmp/multisite_tld.dat' + + # save the tldextract logger output to a buffer to test output + self.out = StringIO() + self.logger = logging.getLogger('tldextract') + self.logger.setLevel(logging.DEBUG) + stdout_handler = logging.StreamHandler(self.out) + stdout_handler.setLevel(logging.DEBUG) + self.logger.addHandler(stdout_handler) + + # patch tldextract to avoid actual requests + self.patcher = mock.patch('tldextract.TLDExtract') + self.tldextract = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def tldextract_update_side_effect(self, *args, **kwargs): + self.logger.debug('TLDExtract.update called') + + def test_command(self): + call_command('update_public_suffix_list') + expected_calls = [ + mock.call(cache_file=self.cache_file), + mock.call().update(fetch_now=True) + ] + self.assertEqual(self.tldextract.mock_calls, expected_calls) + + def test_command_output(self): + # make sure that the logger receives output from the method + self.tldextract().update.side_effect = self.tldextract_update_side_effect + + call_command('update_public_suffix_list', verbosity=3) + update_message = 'Updating {}'.format(self.cache_file) + self.assertIn(update_message, self.out.getvalue()) + self.assertIn('TLDExtract.update called', self.out.getvalue()) diff --git a/multisite/threadlocals.py b/multisite/threadlocals.py index 85912f5..ec57c5f 100644 --- a/multisite/threadlocals.py +++ b/multisite/threadlocals.py @@ -1,34 +1,156 @@ # -*- coding: utf-8 -* +from __future__ import unicode_literals +from __future__ import absolute_import + +import sys + +from django.utils import six +from contextlib import contextmanager +from warnings import warn try: from threading import local except ImportError: from django.utils._threading_local import local +from django.core.exceptions import ImproperlyConfigured + + _thread_locals = local() + def get_request(): return getattr(_thread_locals, 'request', None) + class ThreadLocalsMiddleware(object): """Middleware that saves request in thread local starage""" def process_request(self, request): _thread_locals.request = request -class SiteIDHook(object): +class SiteID(local): + """ + Dynamic settings.SITE_ID replacement, which acts like an integer. + + django.contrib.sites can allow multiple Django sites to share the + same database. However, they cannot share the same code by + default. + + SiteID can be used to replace the static settings.SITE_ID integer + when combined with the appropriate middleware. + """ + + def __init__(self, default=None, *args, **kwargs): + """ + ``default``, if specified, determines the default SITE_ID, + if that is unset. + """ + if default is not None and not isinstance(default, six.integer_types): + raise ValueError("%r is not a valid default." % default) + self.default = default + self.reset() + def __repr__(self): + return repr(self.__int__()) + + def __str__(self): return str(self.__int__()) def __int__(self): - try: - return _thread_locals.SITE_ID - except AttributeError: - _thread_locals.SITE_ID = 1 - return _thread_locals.SITE_ID + if self.site_id is None: + return self.get_default() + return self.site_id + + def __lt__(self, other): + if isinstance(other, six.integer_types): + return self.__int__() < other + elif isinstance(other, SiteID): + return self.__int__() < other.__int__() + return True + + def __le__(self, other): + if isinstance(other, six.integer_types): + return self.__int__() <= other + elif isinstance(other, SiteID): + return self.__int__() <= other.__int__() + return True + + def __eq__(self, other): + if isinstance(other, six.integer_types): + return self.__int__() == other + elif isinstance(other, SiteID): + return self.__int__() == other.__int__() + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + return not self.__le__(other) + + def __ge__(self, other): + return not self.__lt__(other) def __hash__(self): return self.__int__() + @contextmanager + def override(self, value): + """ + Overrides SITE_ID temporarily:: + + >>> with settings.SITE_ID.override(2): + ... print settings.SITE_ID + 2 + """ + site_id_original = self.site_id + self.set(value) + try: + yield self + finally: + self.site_id = site_id_original + def set(self, value): - _thread_locals.SITE_ID = value + from django.db.models import Model + if isinstance(value, Model): + value = value.pk + self.site_id = value + + def reset(self): + self.site_id = None + + def get_default(self): + """Returns the default SITE_ID.""" + if self.default is None: + raise ValueError('SITE_ID has not been set.') + return self.default + + +class SiteDomain(SiteID): + def __init__(self, default, *args, **kwargs): + """ + ``default`` is the default domain name, resolved to SITE_ID, if + that is unset. + """ + # make sure they passed us a string; doing this is the single hardest py2/py3 compat headache. + # http://python-future.org/compatible_idioms.html#basestring and + # https://github.com/PythonCharmers/python-future/blob/master/src/past/types/basestring.py + # are not super informative, so just fall back on a literal version check: + if not isinstance(default, basestring if sys.version_info.major == 2 else str): + raise TypeError("%r is not a valid default domain." % default) + self.default_domain = default + self.default = None + self.reset() + + def get_default(self): + """Returns the default SITE_ID that matches the default domain name.""" + from django.contrib.sites.models import Site + if not Site._meta.installed: + raise ImproperlyConfigured('django.contrib.sites is not in ' + 'settings.INSTALLED_APPS') + + if self.default is None: + qset = Site.objects.only('id') + self.default = qset.get(domain=self.default_domain).id + return self.default diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e812a41 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +django_find_project = false +DJANGO_SETTINGS_MODULE = multisite.test_settings +python_files = tests.py test_*.py *_tests.pyc +addopts = --reuse-db +python_paths = multisite diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9af7e6f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[aliases] +test=pytest \ No newline at end of file diff --git a/setup.py b/setup.py index cf29754..89ee7e7 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,60 @@ -from distutils.core import setup import os +import sys -# Compile the list of packages available, because distutils doesn't have -# an easy way to do this. -packages, data_files = [], [] -root_dir = os.path.dirname(__file__) -if root_dir: - os.chdir(root_dir) - -for dirpath, dirnames, filenames in os.walk('multisite'): - # Ignore dirnames that start with '.' - for i, dirname in enumerate(dirnames): - if dirname.startswith('.'): del dirnames[i] - if '__init__.py' in filenames: - pkg = dirpath.replace(os.path.sep, '.') - if os.path.altsep: - pkg = pkg.replace(os.path.altsep, '.') - packages.append(pkg) - elif filenames: - prefix = dirpath[10:] # Strip "multisite/" or "multisite\" - for f in filenames: - data_files.append(os.path.join(prefix, f)) +from setuptools import find_packages, setup +_dir_ = os.path.dirname(__file__) + + +if sys.version_info < (3, 4): + install_requires = ['Django>=1.8,<2.0', 'tldextract>=1.2'] +else: + install_requires = ['Django>=1.8,<=2.2', 'tldextract>=1.2'] + + +def long_description(): + """Returns the value of README.rst""" + with open(os.path.join(_dir_, 'README.rst')) as f: + return f.read() + +here = os.path.abspath(_dir_) +version = {} +with open(os.path.join(here, 'multisite', '__version__.py')) as f: + exec(f.read(), version) + + +files = ["multisite/test_templates/*"] setup(name='django-multisite', - version='0.1', - description='Multisite for Django', + version=version['__version__'], + description='Serve multiple sites from a single Django application', + long_description=long_description(), author='Leonid S Shestera', author_email='leonid@shestera.ru', - url='http://github.com/shestera/django-multisite', - package_dir={'multisite': 'multisite'}, - packages=packages, - package_data={'multisite': data_files}, + maintainer='Ecometrica', + maintainer_email='dev@ecometrica.com', + url='http://github.com/ecometrica/django-multisite', + packages=find_packages(), + include_package_data=True, + package_data={'multisite': files}, + install_requires=install_requires, + setup_requires=['pytest-runner'], + tests_require=['coverage', 'mock', 'pytest', 'pytest-cov', + 'pytest-django', 'pytest-pythonpath', 'tox'], + test_suite="multisite.tests", classifiers=['Development Status :: 4 - Beta', 'Environment :: Web Environment', + 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Libraries', 'Topic :: Utilities'], ) - diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8ccd708 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +# Tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +setenv= + PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} +usedevelop = True +envlist = + py36-django{2.1,2.0,1.11} + py35-django{2.1,2.0,1.11,1.10,1.9,1.8} + py34-django{2.0,1.11,1.10,1.9,1.8} + py27-django{1.11,1.10,1.9,1.8} + +[testenv] +commands = pytest --cov --cov-config .coveragerc --pyargs multisite +deps = + coverage + pytest + pytest-cov + pytest-pythonpath + pytest-django + + py27: mock + django2.1: Django>=2.1,<2.2 + django2.0: Django>=2.0,<2.1 + django1.11: Django>=1.11,<2.0 + django1.10: Django>=1.10,<1.11 + django1.9: Django>=1.9,<1.10 + django1.8: Django>=1.8,<1.9