diff --git a/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py new file mode 100644 index 0000000000..16686e0a9b --- /dev/null +++ b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Removing unique constraint on 'Site', fields ['hostname'] + db.delete_unique(u'wagtailcore_site', ['hostname']) + + # Adding unique constraint on 'Site', fields ['hostname', 'port'] + db.create_unique(u'wagtailcore_site', ['hostname', 'port']) + + + def backwards(self, orm): + # Removing unique constraint on 'Site', fields ['hostname', 'port'] + db.delete_unique(u'wagtailcore_site', ['hostname', 'port']) + + # Adding unique constraint on 'Site', fields ['hostname'] + db.create_unique(u'wagtailcore_site', ['hostname']) + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'wagtailcore.grouppagepermission': { + 'Meta': {'object_name': 'GroupPagePermission'}, + 'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': u"orm['wagtailcore.Page']"}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}) + }, + u'wagtailcore.page': { + 'Meta': {'object_name': 'Page'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}), + 'depth': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['auth.User']"}), + 'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}) + }, + u'wagtailcore.pagerevision': { + 'Meta': {'object_name': 'PageRevision'}, + 'content_json': ('django.db.models.fields.TextField', [], {}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': u"orm['wagtailcore.Page']"}), + 'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}) + }, + u'wagtailcore.site': { + 'Meta': {'unique_together': "(('hostname', 'port'),)", 'object_name': 'Site'}, + 'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}), + 'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': u"orm['wagtailcore.Page']"}) + } + } + + complete_apps = ['wagtailcore'] \ No newline at end of file diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py index 19af022fe6..95730e334d 100644 --- a/wagtail/wagtailcore/models.py +++ b/wagtail/wagtailcore/models.py @@ -17,6 +17,7 @@ from django.template.response import TemplateResponse from django.utils import timezone from django.utils.translation import ugettext from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError from django.utils.functional import cached_property from treebeard.mp_tree import MP_Node @@ -28,29 +29,47 @@ from wagtail.wagtailsearch import Indexed, get_search_backend class SiteManager(models.Manager): - def get_by_natural_key(self, hostname): - return self.get(hostname=hostname) + def get_by_natural_key(self, hostname, port): + return self.get(hostname=hostname, port=port) class Site(models.Model): - hostname = models.CharField(max_length=255, unique=True, db_index=True) + hostname = models.CharField(max_length=255, db_index=True) port = models.IntegerField(default=80, help_text=_("Set this to something other than 80 if you need a specific port number to appear in URLs (e.g. development on port 8000). Does not affect request handling (so port forwarding still works).")) root_page = models.ForeignKey('Page', related_name='sites_rooted_here') is_default_site = models.BooleanField(default=False, help_text=_("If true, this site will handle requests for all other hostnames that do not have a site entry of their own")) + class Meta: + unique_together = ('hostname', 'port') + def natural_key(self): - return (self.hostname,) + return (self.hostname, self.port) def __unicode__(self): return self.hostname + ("" if self.port == 80 else (":%d" % self.port)) + (" [default]" if self.is_default_site else "") @staticmethod def find_for_request(request): - """Find the site object responsible for responding to this HTTP request object""" + """ + Find the site object responsible for responding to this HTTP + request object. Try: + - unique hostname first + - then hostname and port + - if there is no matching hostname at all, or no matching + hostname:port combination, fall back to the unique default site, + or raise an exception + NB this means that high-numbered ports on an extant hostname may + still be routed to a different hostname which is set as the default + """ try: - hostname = request.META['HTTP_HOST'].split(':')[0] - # find a Site matching this specific hostname - return Site.objects.get(hostname=hostname) + hostname = request.META['HTTP_HOST'].split(':')[0] # KeyError here goes to the final except clause + try: + # find a Site matching this specific hostname + return Site.objects.get(hostname=hostname) # Site.DoesNotExist here goes to the final except clause + except Site.MultipleObjectsReturned: + # as there were more than one, try matching by port too + port = request.META['SERVER_PORT'] # KeyError here goes to the final except clause + return Site.objects.get(hostname=hostname, port=int(port)) # Site.DoesNotExist here goes to the final except clause except (Site.DoesNotExist, KeyError): # If no matching site exists, or request does not specify an HTTP_HOST (which # will often be the case for the Django test client), look for a catch-all Site. @@ -66,6 +85,24 @@ class Site(models.Model): else: return 'http://%s:%d' % (self.hostname, self.port) + def clean_fields(self, exclude=None): + super(Site, self).clean_fields(exclude) + # Only one site can have the is_default_site flag set + try: + default = Site.objects.get(is_default_site=True) + except Site.DoesNotExist: + pass + except Site.MultipleObjectsReturned: + raise + else: + if self.is_default_site and self.pk != default.pk: + raise ValidationError( + {'is_default_site': [ + _("%(hostname)s is already configured as the default site. You must unset that before you can save this site as default.") + % { 'hostname': default.hostname } + ]} + ) + # clear the wagtail_site_root_paths cache whenever Site records are updated def save(self, *args, **kwargs): result = super(Site, self).save(*args, **kwargs) diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py index 24eabd8667..4cbdd99934 100644 --- a/wagtail/wagtailcore/tests/test_page_model.py +++ b/wagtail/wagtailcore/tests/test_page_model.py @@ -9,30 +9,96 @@ from wagtail.wagtailcore.models import Page, Site, UserPagePermissionsProxy from wagtail.tests.models import EventPage, EventIndex, SimplePage -class TestRouting(TestCase): +class TestSiteRouting(TestCase): fixtures = ['test.json'] - def test_find_site_for_request(self): - default_site = Site.objects.get(is_default_site=True) + def setUp(self): + self.default_site = Site.objects.get(is_default_site=True) events_page = Page.objects.get(url_path='/home/events/') - events_site = Site.objects.create(hostname='events.example.com', root_page=events_page) + about_page = Page.objects.get(url_path='/home/about-us/') + self.events_site = Site.objects.create(hostname='events.example.com', root_page=events_page) + self.alternate_port_events_site = Site.objects.create(hostname='events.example.com', root_page=events_page, port='8765') + self.about_site = Site.objects.create(hostname='about.example.com', root_page=about_page) + self.unrecognised_port = '8000' + self.unrecognised_hostname = 'unknown.site.com' + def test_no_host_header_routes_to_default_site(self): # requests without a Host: header should be directed to the default site request = HttpRequest() request.path = '/' - self.assertEqual(Site.find_for_request(request), default_site) + self.assertEqual(Site.find_for_request(request), self.default_site) + def test_valid_headers_route_to_specific_site(self): # requests with a known Host: header should be directed to the specific site request = HttpRequest() request.path = '/' - request.META['HTTP_HOST'] = 'events.example.com' - self.assertEqual(Site.find_for_request(request), events_site) + request.META['HTTP_HOST'] = self.events_site.hostname + request.META['SERVER_PORT'] = self.events_site.port + self.assertEqual(Site.find_for_request(request), self.events_site) + def test_ports_in_request_headers_are_respected(self): + # ports in the Host: header should be respected + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.alternate_port_events_site.hostname + request.META['SERVER_PORT'] = self.alternate_port_events_site.port + self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site) + + def test_unrecognised_host_header_routes_to_default_site(self): # requests with an unrecognised Host: header should be directed to the default site request = HttpRequest() request.path = '/' - request.META['HTTP_HOST'] = 'unknown.example.com' - self.assertEqual(Site.find_for_request(request), default_site) + request.META['HTTP_HOST'] = self.unrecognised_hostname + request.META['SERVER_PORT'] = '80' + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_and_default_host_routes_to_default_site(self): + # requests to the default host on an unrecognised port should be directed to the default site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.default_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_and_unrecognised_host_routes_to_default_site(self): + # requests with an unrecognised Host: header _and_ an unrecognised port + # hould be directed to the default site + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.unrecognised_hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_unrecognised_port_on_known_hostname_routes_there_if_no_ambiguity(self): + # requests on an unrecognised port should be directed to the site with + # matching hostname if there is no ambiguity + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.about_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.about_site) + + def test_unrecognised_port_on_known_hostname_routes_to_default_site_if_ambiguity(self): + # requests on an unrecognised port should be directed to the default + # site, even if their hostname (but not port) matches more than one + # other entry + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = self.events_site.hostname + request.META['SERVER_PORT'] = self.unrecognised_port + self.assertEqual(Site.find_for_request(request), self.default_site) + + def test_port_in_http_host_header_is_ignored(self): + # port in the HTTP_HOST header is ignored + request = HttpRequest() + request.path = '/' + request.META['HTTP_HOST'] = "%s:%s" % (self.events_site.hostname, self.events_site.port) + request.META['SERVER_PORT'] = self.alternate_port_events_site.port + self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site) + + +class TestRouting(TestCase): + fixtures = ['test.json'] def test_urls(self): default_site = Site.objects.get(is_default_site=True)