diff --git a/.travis.yml b/.travis.yml
index b7d6d4a06b4..84c0abfeabd 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,6 +16,6 @@ after_success:
notifications:
slack:
rooms:
- - readthedocs:y3hjODOi7EIz1JAbD1Zb41sz#general
+ - readthedocs:y3hjODOi7EIz1JAbD1Zb41sz#random
on_success: change
on_failure: always
diff --git a/readthedocs/core/middleware.py b/readthedocs/core/middleware.py
index 275d9318802..f5b28f22e11 100644
--- a/readthedocs/core/middleware.py
+++ b/readthedocs/core/middleware.py
@@ -7,7 +7,7 @@
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.http import Http404
-from readthedocs.projects.models import Project
+from readthedocs.projects.models import Project, Domain
import redis
@@ -71,6 +71,22 @@ def process_request(self, request):
log.debug(LOG_TEMPLATE.format(
msg='CNAME detetected: %s' % request.slug,
**log_kwargs))
+ try:
+ proj = Project.objects.get(slug=slug)
+ domain, created = Domain.objects.get_or_create(
+ project=proj,
+ url=host,
+ )
+ if created:
+ domain.machine = True
+ domain.cname = True
+ # Track basic domain counts so we know which are heavily used
+ domain.count = domain.count + 1
+ domain.save()
+ except (ObjectDoesNotExist, MultipleObjectsReturned):
+ log.debug(LOG_TEMPLATE.format(
+ msg='Project CNAME does not exist: %s' % slug,
+ **log_kwargs))
except:
# Some crazy person is CNAMEing to us. 404.
log.exception(LOG_TEMPLATE.format(msg='CNAME 404', **log_kwargs))
diff --git a/readthedocs/core/mixins.py b/readthedocs/core/mixins.py
index dd10da57967..9c803dad756 100644
--- a/readthedocs/core/mixins.py
+++ b/readthedocs/core/mixins.py
@@ -1,12 +1,15 @@
-'''
+"""
Common mixin classes for views
-'''
+"""
from django.conf import settings
+from vanilla import ListView
+
class StripeMixin(object):
- '''Adds Stripe publishable key to the context data'''
+
+ """Adds Stripe publishable key to the context data"""
def get_context_data(self, **kwargs):
context = super(StripeMixin, self).get_context_data(**kwargs)
@@ -14,3 +17,13 @@ def get_context_data(self, **kwargs):
'publishable': settings.STRIPE_PUBLISHABLE,
})
return context
+
+
+class ListViewWithForm(ListView):
+
+ """List view that also exposes a create form"""
+
+ def get_context_data(self, **kwargs):
+ context = super(ListViewWithForm, self).get_context_data(**kwargs)
+ context['form'] = self.get_form(data=None, files=None)
+ return context
diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py
index 5b88da1542d..83017558254 100644
--- a/readthedocs/projects/admin.py
+++ b/readthedocs/projects/admin.py
@@ -3,9 +3,8 @@
from readthedocs.builds.models import Version
from django.contrib import admin
from readthedocs.redirects.models import Redirect
-from readthedocs.projects.models import (Project, ImportedFile,
- ProjectRelationship, EmailHook,
- WebHook)
+from .models import (Project, ImportedFile,
+ ProjectRelationship, EmailHook, WebHook, Domain)
from guardian.admin import GuardedModelAdmin
@@ -32,6 +31,10 @@ class RedirectInline(admin.TabularInline):
model = Redirect
+class DomainInline(admin.TabularInline):
+ model = Domain
+
+
class ProjectAdmin(GuardedModelAdmin):
"""Project model admin view"""
@@ -42,7 +45,7 @@ class ProjectAdmin(GuardedModelAdmin):
'documentation_type', 'programming_language')
list_editable = ('featured',)
search_fields = ('slug', 'repo')
- inlines = [ProjectRelationshipInline, RedirectInline, VersionInline]
+ inlines = [ProjectRelationshipInline, RedirectInline, VersionInline, DomainInline]
raw_id_fields = ('users', 'main_language_project')
@@ -53,7 +56,12 @@ class ImportedFileAdmin(admin.ModelAdmin):
list_display = ('path', 'name', 'version')
+class DomainAdmin(admin.ModelAdmin):
+ list_display = ('url', 'project')
+ model = Domain
+
admin.site.register(Project, ProjectAdmin)
admin.site.register(ImportedFile, ImportedFileAdmin)
+admin.site.register(Domain, DomainAdmin)
admin.site.register(EmailHook)
admin.site.register(WebHook)
diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py
index 5cb728b4471..ed5a3dda3f0 100644
--- a/readthedocs/projects/forms.py
+++ b/readthedocs/projects/forms.py
@@ -16,7 +16,7 @@
from readthedocs.core.utils import trigger_build
from readthedocs.redirects.models import Redirect
from readthedocs.projects import constants
-from readthedocs.projects.models import Project, EmailHook, WebHook
+from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
from readthedocs.privacy.loader import AdminPermission
@@ -126,20 +126,9 @@ class Meta:
'documentation_type',
'language', 'programming_language',
'project_url',
- 'canonical_url',
'tags',
)
- def __init__(self, *args, **kwargs):
- super(ProjectExtraForm, self).__init__(*args, **kwargs)
- self.fields['canonical_url'].widget.attrs['placeholder'] = self.placehold_canonical_url()
-
- def placehold_canonical_url(self):
- return choice([
- 'http://docs.fabfile.org',
- 'http://example.readthedocs.org',
- ])
-
class ProjectAdvancedForm(ProjectTriggerBuildMixin, ProjectForm):
@@ -198,7 +187,6 @@ class Meta:
'documentation_type',
'language', 'programming_language',
'project_url',
- 'canonical_url',
'tags',
)
@@ -476,3 +464,26 @@ def save(self, **_):
to_url=self.cleaned_data['to_url'],
)
return redirect
+
+
+class DomainForm(forms.ModelForm):
+ project = forms.CharField(widget=forms.HiddenInput(), required=False)
+
+ class Meta:
+ model = Domain
+ exclude = ['machine', 'cname', 'count']
+
+ def __init__(self, *args, **kwargs):
+ self.project = kwargs.pop('project', None)
+ super(DomainForm, self).__init__(*args, **kwargs)
+
+ def clean_project(self):
+ return self.project
+
+ def clean_canonical(self):
+ canonical = self.cleaned_data['canonical']
+ if canonical and Domain.objects.filter(
+ project=self.project, canonical=True
+ ).exclude(url=self.cleaned_data['url']).exists():
+ raise forms.ValidationError(_(u'Only 1 Domain can be canonical at a time.'))
+ return canonical
diff --git a/readthedocs/projects/migrations/0006_add_domain_models.py b/readthedocs/projects/migrations/0006_add_domain_models.py
new file mode 100644
index 00000000000..82e75b4cd0f
--- /dev/null
+++ b/readthedocs/projects/migrations/0006_add_domain_models.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0005_sync_project_model'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Domain',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('url', models.URLField(unique=True, verbose_name='URL')),
+ ('machine', models.BooleanField(default=False, help_text='This URL was auto-created')),
+ ('cname', models.BooleanField(default=False, help_text='This URL is a CNAME for the project')),
+ ('canonical', models.BooleanField(default=False, help_text='This URL is the primary one where the documentation is served from.')),
+ ('count', models.IntegerField(default=0, help_text='Number of times this domain has been hit.')),
+ ('project', models.ForeignKey(related_name='domains', to='projects.Project')),
+ ],
+ options={
+ 'ordering': ('-canonical', '-machine', 'url'),
+ },
+ ),
+ ]
diff --git a/readthedocs/projects/migrations/0007_migrate_canonical_data.py b/readthedocs/projects/migrations/0007_migrate_canonical_data.py
new file mode 100644
index 00000000000..9494ac13f07
--- /dev/null
+++ b/readthedocs/projects/migrations/0007_migrate_canonical_data.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+def migrate_canonical(apps, schema_editor):
+ Project = apps.get_model("projects", "Project")
+ for project in Project.objects.all():
+ if project.canonical_url:
+ domain = project.domains.create(
+ url=project.canonical_url,
+ canonical=True,
+ )
+ print "Added {url} to {project}".format(url=domain.url, project=project.name)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0006_add_domain_models'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_canonical)
+ ]
diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py
index 2078e5451a7..cfce70b943f 100644
--- a/readthedocs/projects/models.py
+++ b/readthedocs/projects/models.py
@@ -7,6 +7,7 @@
from django.conf import settings
from django.contrib.auth.models import User
+from django.core.exceptions import MultipleObjectsReturned
from django.core.urlresolvers import reverse
from django.db import models
from django.template.defaultfilters import slugify
@@ -437,22 +438,26 @@ def canonical_domain(self):
@property
def clean_canonical_url(self):
- """Normalize canonical URL field"""
- if not self.canonical_url:
- return ""
- parsed = urlparse(self.canonical_url)
+ try:
+ domain = self.domains.get(canonical=True)
+ except (Domain.DoesNotExist, MultipleObjectsReturned):
+ return ''
+
+ parsed = urlparse(domain.url)
if parsed.scheme:
scheme, netloc = parsed.scheme, parsed.netloc
elif parsed.netloc:
scheme, netloc = "http", parsed.netloc
else:
scheme, netloc = "http", parsed.path
+
if getattr(settings, 'DONT_HIT_DB', True):
if parsed.path:
netloc = netloc + parsed.path
else:
if self.superprojects.count() and parsed.path:
netloc = netloc + parsed.path
+
return "%s://%s/" % (scheme, netloc)
@property
@@ -933,3 +938,35 @@ class WebHook(Notification):
def __unicode__(self):
return self.url
+
+
+class Domain(models.Model):
+ project = models.ForeignKey(Project, related_name='domains')
+ url = models.URLField(_('URL'), unique=True)
+ machine = models.BooleanField(
+ default=False, help_text=_('This URL was auto-created')
+ )
+ cname = models.BooleanField(
+ default=False, help_text=_('This URL is a CNAME for the project')
+ )
+ canonical = models.BooleanField(
+ default=False,
+ help_text=_('This URL is the primary one where the documentation is served from.')
+ )
+ count = models.IntegerField(default=0, help_text=_('Number of times this domain has been hit.'))
+
+ objects = RelatedProjectManager()
+
+ class Meta:
+ ordering = ('-canonical', '-machine', 'url')
+
+ def __unicode__(self):
+ return "{url} pointed at {project}".format(url=self.url, project=self.project.name)
+
+ @property
+ def clean_host(self):
+ parsed = urlparse(self.url)
+ if parsed.scheme or parsed.netloc:
+ return parsed.netloc
+ else:
+ return parsed.path
diff --git a/readthedocs/projects/symlinks.py b/readthedocs/projects/symlinks.py
index b6054980f37..58d7c9a513d 100644
--- a/readthedocs/projects/symlinks.py
+++ b/readthedocs/projects/symlinks.py
@@ -4,10 +4,10 @@
import logging
from django.conf import settings
-import redis
from readthedocs.core.utils import run_on_app_servers
from readthedocs.projects.constants import LOG_TEMPLATE
+from readthedocs.projects.models import Domain
from readthedocs.restapi.client import api
log = logging.getLogger(__name__)
@@ -23,29 +23,27 @@ def symlink_cnames(version):
Link from HOME/user_builds/cnametoproject/ ->
HOME/user_builds//
"""
- try:
- redis_conn = redis.Redis(**settings.REDIS)
- cnames = redis_conn.smembers('rtd_slug:v1:%s' % version.project.slug)
- except redis.ConnectionError:
- log.error(LOG_TEMPLATE
- .format(project=version.project.slug, version=version.slug,
- msg='Failed to symlink cnames, Redis error.'),
- exc_info=True)
- return
- for cname in cnames:
- log.debug(LOG_TEMPLATE
- .format(project=version.project.slug, version=version.slug,
- msg="Symlinking CNAME: %s" % cname))
+ domains = Domain.objects.filter(project=version.project, cname=True)
+ for domain in domains:
+ log.debug(LOG_TEMPLATE.format(
+ project=version.project.slug,
+ version=version.slug,
+ msg="Symlinking CNAME: %s" % domain.clean_host)
+ )
docs_dir = version.project.rtd_build_path(version.slug)
# Chop off the version from the end.
docs_dir = '/'.join(docs_dir.split('/')[:-1])
# Old symlink location -- Keep this here til we change nginx over
- symlink = version.project.cnames_symlink_path(cname)
+ symlink = version.project.cnames_symlink_path(domain.clean_host)
run_on_app_servers('mkdir -p %s' % '/'.join(symlink.split('/')[:-1]))
run_on_app_servers('ln -nsf %s %s' % (docs_dir, symlink))
# New symlink location
new_docs_dir = version.project.doc_path
- new_cname_symlink = os.path.join(getattr(settings, 'SITE_ROOT'), 'cnametoproject', cname)
+ new_cname_symlink = os.path.join(
+ getattr(settings, 'SITE_ROOT'),
+ 'cnametoproject',
+ domain.clean_host,
+ )
run_on_app_servers('mkdir -p %s' % '/'.join(new_cname_symlink.split('/')[:-1]))
run_on_app_servers('ln -nsf %s %s' % (new_docs_dir, new_cname_symlink))
diff --git a/readthedocs/projects/urls/private.py b/readthedocs/projects/urls/private.py
index e6c65ac54a6..7cb95dc505c 100644
--- a/readthedocs/projects/urls/private.py
+++ b/readthedocs/projects/urls/private.py
@@ -2,7 +2,9 @@
from django.conf.urls import patterns, url
-from readthedocs.projects.views.private import AliasList, ProjectDashboard, ImportView
+from readthedocs.projects.views.private import (
+ AliasList, ProjectDashboard, ImportView,
+ DomainList, DomainCreate, DomainDelete, DomainUpdate)
from readthedocs.projects.backends.views import ImportWizardView, ImportDemoView
@@ -122,3 +124,21 @@
'readthedocs.projects.views.private.project_redirects_delete',
name='projects_redirects_delete'),
)
+
+domain_urls = patterns(
+ '',
+ url(r'^(?P[-\w]+)/domains/$',
+ DomainList.as_view(),
+ name='projects_domains'),
+ url(r'^(?P[-\w]+)/domains/create/$',
+ DomainCreate.as_view(),
+ name='projects_domains_create'),
+ url(r'^(?P[-\w]+)/domains/(?P[-\w]+)/edit/$',
+ DomainUpdate.as_view(),
+ name='projects_domains_edit'),
+ url(r'^(?P[-\w]+)/domains/(?P[-\w]+)/delete/$',
+ DomainDelete.as_view(),
+ name='projects_domains_delete'),
+)
+
+urlpatterns += domain_urls
diff --git a/readthedocs/projects/views/base.py b/readthedocs/projects/views/base.py
index 14b4042ffe0..6e67cb46fe9 100644
--- a/readthedocs/projects/views/base.py
+++ b/readthedocs/projects/views/base.py
@@ -1,4 +1,5 @@
-"""Base project views used for subclassing"""
+from django.shortcuts import get_object_or_404
+from django.core.urlresolvers import reverse
from readthedocs.projects.models import Project
@@ -28,3 +29,45 @@ def get_context_data(self, **kwargs):
context['onboard'] = onboard
return context
+
+
+# Mixins
+class ProjectAdminMixin(object):
+
+ """Mixin class that provides project sublevel objects
+
+ This mixin uses several class level variables
+
+ project_url_field
+ The URL kwarg name for the project slug
+
+ """
+
+ project_url_field = 'project'
+
+ def get_queryset(self):
+ self.project = self.get_project()
+ return self.model.objects.filter(project=self.project)
+
+ def get_project(self):
+ """Return project determined by url kwarg"""
+ if self.project_url_field not in self.kwargs:
+ return None
+ return get_object_or_404(
+ Project.objects.for_admin_user(user=self.request.user),
+ slug=self.kwargs[self.project_url_field]
+ )
+
+ def get_context_data(self, **kwargs):
+ """Add project to context data"""
+ context = super(ProjectAdminMixin, self).get_context_data(**kwargs)
+ context['project'] = self.get_project()
+ return context
+
+ def get_form(self, data=None, files=None, **kwargs):
+ """Pass in project to form class instance"""
+ kwargs['project'] = self.get_project()
+ return self.form_class(data, files, **kwargs)
+
+ def get_success_url(self, **kwargs):
+ return reverse('projects_domains', args=[self.get_project().slug])
diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py
index d390ec1de8d..a37c39bf464 100644
--- a/readthedocs/projects/views/private.py
+++ b/readthedocs/projects/views/private.py
@@ -11,25 +11,29 @@
from django.db.models import Q
from django.shortcuts import get_object_or_404, render_to_response, render
from django.template import RequestContext
-from django.views.generic import View, ListView, TemplateView
+from django.views.generic import View, TemplateView, ListView
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from formtools.wizard.views import SessionWizardView
+from vanilla import CreateView, DeleteView, UpdateView
+
from readthedocs.bookmarks.models import Bookmark
from readthedocs.builds.models import Version
from readthedocs.builds.forms import AliasForm, VersionForm
from readthedocs.builds.filters import VersionFilter
from readthedocs.builds.models import VersionAlias
from readthedocs.core.utils import trigger_build
+from readthedocs.core.mixins import ListViewWithForm
from readthedocs.oauth.models import GithubProject, BitbucketProject
from readthedocs.oauth import utils as oauth_utils
from readthedocs.projects.forms import (
ProjectBasicsForm, ProjectExtraForm,
ProjectAdvancedForm, UpdateProjectForm, SubprojectForm,
build_versions_form, UserForm, EmailHookForm, TranslationForm,
- RedirectForm, WebHookForm)
-from readthedocs.projects.models import Project, EmailHook, WebHook
+ RedirectForm, WebHookForm, DomainForm)
+from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
+from readthedocs.projects.views.base import ProjectAdminMixin
from readthedocs.projects import constants, tasks
from readthedocs.projects.tasks import remove_path_from_web
@@ -40,6 +44,7 @@
class LoginRequiredMixin(object):
+
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super(LoginRequiredMixin, self).dispatch(*args, **kwargs)
@@ -687,3 +692,24 @@ def project_version_delete_html(request, project_slug, version_slug):
raise Http404
return HttpResponseRedirect(
reverse('project_version_list', kwargs={'project_slug': project_slug}))
+
+
+class DomainMixin(ProjectAdminMixin, PrivateViewMixin):
+ model = Domain
+ form_class = DomainForm
+
+
+class DomainList(DomainMixin, ListViewWithForm):
+ pass
+
+
+class DomainCreate(DomainMixin, CreateView):
+ pass
+
+
+class DomainUpdate(DomainMixin, UpdateView):
+ pass
+
+
+class DomainDelete(DomainMixin, DeleteView):
+ pass
diff --git a/readthedocs/restapi/serializers.py b/readthedocs/restapi/serializers.py
index 0fe06223a72..b4f3c2b5793 100644
--- a/readthedocs/restapi/serializers.py
+++ b/readthedocs/restapi/serializers.py
@@ -1,7 +1,7 @@
from rest_framework import serializers
from readthedocs.builds.models import Build, BuildCommandResult, Version
-from readthedocs.projects.models import Project
+from readthedocs.projects.models import Project, Domain
class ProjectSerializer(serializers.ModelSerializer):
@@ -41,6 +41,7 @@ class Meta:
class BuildSerializer(serializers.ModelSerializer):
+
"""Readonly version of the build serializer, used for user facing display"""
commands = BuildCommandSerializer(many=True, read_only=True)
@@ -52,6 +53,7 @@ class Meta:
class BuildSerializerFull(BuildSerializer):
+
"""Writeable Build instance serializer, for admin access by builders"""
class Meta:
@@ -63,3 +65,17 @@ class SearchIndexSerializer(serializers.Serializer):
project = serializers.CharField(max_length=500, required=False)
version = serializers.CharField(max_length=500, required=False)
page = serializers.CharField(max_length=500, required=False)
+
+
+class DomainSerializer(serializers.ModelSerializer):
+
+ class Meta:
+ model = Domain
+ fields = (
+ 'id',
+ 'project',
+ 'url',
+ 'canonical',
+ 'machine',
+ 'cname',
+ )
diff --git a/readthedocs/restapi/urls.py b/readthedocs/restapi/urls.py
index e69da967008..deb6c0d1ab4 100644
--- a/readthedocs/restapi/urls.py
+++ b/readthedocs/restapi/urls.py
@@ -4,7 +4,7 @@
from .views.model_views import (BuildViewSet, BuildCommandViewSet,
ProjectViewSet, NotificationViewSet,
- VersionViewSet)
+ VersionViewSet, DomainViewSet)
from readthedocs.comments.views import CommentViewSet
router = routers.DefaultRouter()
@@ -13,6 +13,7 @@
router.register(r'version', VersionViewSet)
router.register(r'project', ProjectViewSet)
router.register(r'notification', NotificationViewSet)
+router.register(r'domain', DomainViewSet)
router.register(r'comments', CommentViewSet, base_name="comments")
urlpatterns = patterns(
diff --git a/readthedocs/restapi/views/model_views.py b/readthedocs/restapi/views/model_views.py
index 309d3eae6d7..bed17c5c592 100644
--- a/readthedocs/restapi/views/model_views.py
+++ b/readthedocs/restapi/views/model_views.py
@@ -1,7 +1,6 @@
import logging
from django.shortcuts import get_object_or_404
-from docutils.utils.math.math2html import Link
from rest_framework import decorators, permissions, viewsets, status
from rest_framework.decorators import detail_route
from rest_framework.renderers import JSONPRenderer, JSONRenderer, BrowsableAPIRenderer
@@ -11,19 +10,18 @@
from readthedocs.builds.constants import TAG
from readthedocs.builds.filters import VersionFilter
from readthedocs.builds.models import Build, BuildCommandResult, Version
+from readthedocs.restapi import utils as api_utils
from readthedocs.core.utils import trigger_build
from readthedocs.oauth import utils as oauth_utils
-from readthedocs.builds.constants import STABLE
from readthedocs.projects.filters import ProjectFilter
-from readthedocs.projects.models import Project, EmailHook
+from readthedocs.projects.models import Project, EmailHook, Domain
from readthedocs.projects.version_handling import determine_stable_version
from ..permissions import (APIPermission, APIRestrictedPermission,
RelatedProjectIsOwner)
from ..serializers import (BuildSerializerFull, BuildSerializer,
BuildCommandSerializer, ProjectSerializer,
- VersionSerializer)
-from .. import utils as api_utils
+ VersionSerializer, DomainSerializer)
log = logging.getLogger(__name__)
@@ -205,8 +203,14 @@ class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
model = EmailHook
def get_queryset(self):
- """
- This view should return a list of all the purchases
- for the currently authenticated user.
- """
+ return self.model.objects.api(self.request.user)
+
+
+class DomainViewSet(viewsets.ReadOnlyModelViewSet):
+ permission_classes = (RelatedProjectIsOwner,)
+ renderer_classes = (JSONRenderer, BrowsableAPIRenderer)
+ serializer_class = DomainSerializer
+ model = Domain
+
+ def get_queryset(self):
return self.model.objects.api(self.request.user)
diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py
index bb8575fbd1c..638b1434f1c 100644
--- a/readthedocs/rtd_tests/tests/test_api.py
+++ b/readthedocs/rtd_tests/tests/test_api.py
@@ -67,7 +67,7 @@ def _try_post():
_try_post()
api_user = get(User, staff=False, password='test')
- assert api_user.is_staff == False
+ assert api_user.is_staff is False
client.force_authenticate(user=api_user)
_try_post()
diff --git a/readthedocs/rtd_tests/tests/test_canonical.py b/readthedocs/rtd_tests/tests/test_canonical.py
deleted file mode 100644
index 8b754b8bd74..00000000000
--- a/readthedocs/rtd_tests/tests/test_canonical.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from django.test import TestCase
-from readthedocs.projects.models import Project
-
-
-class TestCanonical(TestCase):
-
- def setUp(self):
- self.p = Project(
- name='foo',
- repo='http://github.com/ericholscher/django-kong',
- )
- self.p.save()
-
-
- def test_canonical_clean(self):
- # Only a url
- self.p.canonical_url = "djangokong.com"
- self.assertEqual(self.p.clean_canonical_url, "http://djangokong.com/")
- # Extra bits in the URL
- self.p.canonical_url = "http://djangokong.com/en/latest/"
- self.assertEqual(self.p.clean_canonical_url, "http://djangokong.com/")
- self.p.canonical_url = "http://djangokong.com//"
- self.assertEqual(self.p.clean_canonical_url, "http://djangokong.com/")
- # Subdomain
- self.p.canonical_url = "foo.djangokong.com"
- self.assertEqual(self.p.clean_canonical_url, "http://foo.djangokong.com/")
- # Https
- self.p.canonical_url = "https://djangokong.com//"
- self.assertEqual(self.p.clean_canonical_url, "https://djangokong.com/")
- self.p.canonical_url = "https://foo.djangokong.com//"
- self.assertEqual(self.p.clean_canonical_url, "https://foo.djangokong.com/")
-
- """
- # Turn this feature off for now, until we fix the UI.
- def test_canonical_subdomain(self):
- self.p.canonical_url = "https://djangokong.com//"
- with self.settings(USE_SUBDOMAIN=True):
- self.assertEqual(self.p.get_docs_url(), "http://djangokong.com/en/latest/")
- """
diff --git a/readthedocs/rtd_tests/tests/test_domains.py b/readthedocs/rtd_tests/tests/test_domains.py
new file mode 100644
index 00000000000..3f41645524f
--- /dev/null
+++ b/readthedocs/rtd_tests/tests/test_domains.py
@@ -0,0 +1,126 @@
+import json
+
+from django.core.cache import cache
+from django.test import TestCase
+from django.test.client import RequestFactory
+from django.test.utils import override_settings
+
+from django_dynamic_fixture import get
+
+from readthedocs.core.middleware import SubdomainMiddleware
+from readthedocs.projects.models import Project, Domain
+
+
+class MiddlewareTests(TestCase):
+
+ def setUp(self):
+ self.factory = RequestFactory()
+ self.middleware = SubdomainMiddleware()
+ self.url = '/'
+ self.old_cache_get = cache.get
+
+ def tearDown(self):
+ cache.get = self.old_cache_get
+
+ @override_settings(PRODUCTION_DOMAIN='readthedocs.org')
+ def test_cname_creation(self):
+ self.assertEqual(Domain.objects.count(), 0)
+ self.project = get(Project, slug='my_slug')
+ cache.get = lambda x: 'my_slug'
+ request = self.factory.get(self.url, HTTP_HOST='my.valid.hostname')
+ self.middleware.process_request(request)
+ self.assertEqual(Domain.objects.count(), 1)
+ self.assertEqual(Domain.objects.first().url, 'my.valid.hostname')
+
+ @override_settings(PRODUCTION_DOMAIN='readthedocs.org')
+ def test_no_readthedocs_domain(self):
+ self.assertEqual(Domain.objects.count(), 0)
+ self.project = get(Project, slug='pip')
+ cache.get = lambda x: 'my_slug'
+ request = self.factory.get(self.url, HTTP_HOST='pip.readthedocs.org')
+ self.middleware.process_request(request)
+ self.assertEqual(Domain.objects.count(), 0)
+
+ @override_settings(PRODUCTION_DOMAIN='readthedocs.org')
+ def test_cname_count(self):
+ self.assertEqual(Domain.objects.count(), 0)
+ self.project = get(Project, slug='my_slug')
+ cache.get = lambda x: 'my_slug'
+ request = self.factory.get(self.url, HTTP_HOST='my.valid.hostname')
+
+ self.middleware.process_request(request)
+ self.assertEqual(Domain.objects.count(), 1)
+ self.assertEqual(Domain.objects.first().url, 'my.valid.hostname')
+ self.assertEqual(Domain.objects.first().count, 1)
+
+ self.middleware.process_request(request)
+ self.assertEqual(Domain.objects.count(), 1)
+ self.assertEqual(Domain.objects.first().count, 2)
+
+
+class ModelTests(TestCase):
+
+ def setUp(self):
+ self.project = get(Project, slug='kong')
+
+ def test_save_parsing(self):
+ domain = get(Domain, url='http://google.com')
+ self.assertEqual(domain.clean_host, 'google.com')
+
+ domain.url = 'google.com'
+ self.assertEqual(domain.clean_host, 'google.com')
+
+ domain.url = 'https://google.com'
+ domain.save()
+ self.assertEqual(domain.clean_host, 'google.com')
+
+ domain.url = 'www.google.com'
+ domain.save()
+ self.assertEqual(domain.clean_host, 'www.google.com')
+
+
+class TestCanonical(TestCase):
+
+ def setUp(self):
+ self.project = get(Project)
+ self.domain = self.project.domains.create(canonical=True)
+
+ def test_canonical_clean(self):
+ # Only a url
+ self.domain.url = "djangokong.com"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "http://djangokong.com/")
+ # Extra bits in the URL
+ self.domain.url = "http://djangokong.com/en/latest/"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "http://djangokong.com/")
+
+ self.domain.url = "http://djangokong.com//"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "http://djangokong.com/")
+ # Subdomain
+ self.domain.url = "foo.djangokong.com"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "http://foo.djangokong.com/")
+ # Https
+ self.domain.url = "https://djangokong.com//"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "https://djangokong.com/")
+
+ self.domain.url = "https://foo.djangokong.com//"
+ self.domain.save()
+ self.assertEqual(self.project.clean_canonical_url, "https://foo.djangokong.com/")
+
+
+class TestAPI(TestCase):
+
+ def setUp(self):
+ self.project = get(Project)
+ self.domain = self.project.domains.create(url='djangokong.com')
+
+ def test_basic_api(self):
+ resp = self.client.get('/api/v2/domain/')
+ self.assertEqual(resp.status_code, 200)
+ obj = json.loads(resp.content)
+ self.assertEqual(obj['results'][0]['url'], 'djangokong.com')
+ self.assertEqual(obj['results'][0]['canonical'], False)
diff --git a/readthedocs/rtd_tests/tests/test_middleware.py b/readthedocs/rtd_tests/tests/test_middleware.py
index c56ee22769c..39321877912 100644
--- a/readthedocs/rtd_tests/tests/test_middleware.py
+++ b/readthedocs/rtd_tests/tests/test_middleware.py
@@ -1,13 +1,13 @@
from django.http import Http404
from django.core.cache import cache
-from django.utils import unittest
+from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from readthedocs.core.middleware import SubdomainMiddleware
-class MiddlewareTests(unittest.TestCase):
+class MiddlewareTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
diff --git a/readthedocs/rtd_tests/tests/test_project_symlinks.py b/readthedocs/rtd_tests/tests/test_project_symlinks.py
index 2227c836db4..e878363c893 100644
--- a/readthedocs/rtd_tests/tests/test_project_symlinks.py
+++ b/readthedocs/rtd_tests/tests/test_project_symlinks.py
@@ -2,11 +2,13 @@
from functools import wraps
from mock import patch
+from django.conf import settings
from django.test import TestCase
+from django_dynamic_fixture import get
from readthedocs.builds.models import Version
-from readthedocs.projects.models import Project
-from readthedocs.projects.symlinks import symlink_translations
+from readthedocs.projects.models import Project, Domain
+from readthedocs.projects.symlinks import symlink_translations, symlink_cnames
def patched(fn):
@@ -24,23 +26,49 @@ def _collect_commands(cmd):
return wrapper
+class TestSymlinkCnames(TestCase):
+
+ def setUp(self):
+ self.project = get(Project, slug='kong')
+ self.version = get(Version, verbose_name='latest', active=True, project=self.project)
+ self.args = {
+ 'project': self.project.doc_path,
+ 'cnames_root': settings.CNAME_ROOT,
+ 'new_cnames_root': os.path.join(getattr(settings, 'SITE_ROOT'), 'cnametoproject'),
+ 'build_path': self.project.doc_path,
+ }
+ self.commands = []
+
+ @patched
+ def test_symlink_cname(self):
+ self.cname = get(Domain, project=self.project, url='http://woot.com', cname=True)
+ symlink_cnames(self.project.versions.first())
+ self.args['cname'] = self.cname.clean_host
+ commands = [
+ 'mkdir -p {cnames_root}',
+ 'ln -nsf {build_path}/rtd-builds {cnames_root}/{cname}',
+ 'mkdir -p {new_cnames_root}',
+ 'ln -nsf {build_path} {new_cnames_root}/{cname}'
+ ]
+
+ for index, command in enumerate(commands):
+ self.assertEqual(self.commands[index], command.format(**self.args))
+
+
class TestSymlinkTranslations(TestCase):
- fixtures = ['eric', 'test_data']
commands = []
def setUp(self):
- self.project = Project.objects.get(slug='kong')
- self.translation = Project.objects.get(slug='pip')
+ self.project = get(Project, slug='kong')
+ self.translation = get(Project, slug='pip')
self.translation.language = 'de'
self.translation.main_lanuage_project = self.project
self.project.translations.add(self.translation)
self.translation.save()
self.project.save()
- Version.objects.create(verbose_name='master',
- active=True, project=self.project)
- Version.objects.create(verbose_name='master',
- active=True, project=self.translation)
+ get(Version, verbose_name='master', active=True, project=self.project)
+ get(Version, verbose_name='master', active=True, project=self.translation)
self.args = {
'project': self.project.doc_path,
'translation': self.translation.doc_path,
diff --git a/readthedocs/templates/projects/domain_confirm_delete.html b/readthedocs/templates/projects/domain_confirm_delete.html
new file mode 100644
index 00000000000..48cd4fc6f94
--- /dev/null
+++ b/readthedocs/templates/projects/domain_confirm_delete.html
@@ -0,0 +1,23 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Edit Domains" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block editing-option-edit-proj %}class="active"{% endblock %}
+
+{% block project-domains-active %}active{% endblock %}
+
+{% block project_edit_content_header %}{% trans "Confirm Delete" %}{% endblock %}
+
+{% block project_edit_content %}
+
+
+{% endblock %}
+
+
diff --git a/readthedocs/templates/projects/domain_form.html b/readthedocs/templates/projects/domain_form.html
new file mode 100644
index 00000000000..48a1ac8a1b2
--- /dev/null
+++ b/readthedocs/templates/projects/domain_form.html
@@ -0,0 +1,36 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Edit Domains" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block editing-option-edit-proj %}class="active"{% endblock %}
+
+{% block project-domains-active %}active{% endblock %}
+
+{% block project_edit_content_header %}
+
+{% if domain %}
+{% trans "Edit Domain" %}
+{% else %}
+{% trans "Create Domain" %}
+{% endif %}
+
+{% endblock %}
+
+{% block project_edit_content %}
+ {% if domain %}
+ {% url 'projects_domains_edit' project.slug domain.pk as action_url %}
+ {% else %}
+ {% url 'projects_domains_create' project.slug as action_url %}
+ {% endif %}
+
+{% endblock %}
+
diff --git a/readthedocs/templates/projects/domain_list.html b/readthedocs/templates/projects/domain_list.html
new file mode 100644
index 00000000000..bd08e713ce0
--- /dev/null
+++ b/readthedocs/templates/projects/domain_list.html
@@ -0,0 +1,44 @@
+{% extends "projects/project_edit_base.html" %}
+
+{% load i18n %}
+
+{% block title %}{% trans "Edit Domains" %}{% endblock %}
+
+{% block nav-dashboard %} class="active"{% endblock %}
+
+{% block editing-option-edit-proj %}class="active"{% endblock %}
+
+{% block project-domains-active %}active{% endblock %}
+{% block project_edit_content_header %}{% trans "Domains" %}{% endblock %}
+
+{% block project_edit_content %}
+
+ {% trans "This allows you to add domains to your project. This allows them to live in the same namespace in the URLConf for a subdomain or CNAME." %}
+
+
+ {% if object_list %}
+ {% trans "Existing Domains" %}
+
+
+ {% for domain in object_list %}
+ -
+ {% if domain.canonical %}{% endif %}
+ {{ domain.url }}
+ {% if domain.canonical %}{% endif %}
+ ({% trans "Edit" %})
+ ({% trans "Remove" %})
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% trans "Add new Domain" %}
+
+{% endblock %}
+
diff --git a/readthedocs/templates/projects/project_edit_base.html b/readthedocs/templates/projects/project_edit_base.html
index d0a1229f093..5862356a9af 100644
--- a/readthedocs/templates/projects/project_edit_base.html
+++ b/readthedocs/templates/projects/project_edit_base.html
@@ -17,6 +17,7 @@
{% trans "Settings" %}
{% trans "Advanced Settings" %}
{% trans "Versions" %}
+ {% trans "Domains" %}
{% trans "Maintainers" %}
{% trans "Redirects" %}
{% trans "Translations" %}
diff --git a/requirements/pip.txt b/requirements/pip.txt
index 21a1a880847..511c60249bf 100644
--- a/requirements/pip.txt
+++ b/requirements/pip.txt
@@ -14,6 +14,7 @@ celery-haystack==0.7.2
django-guardian==1.3.0
django-extensions==1.3.8
djangorestframework==3.0.4
+django-vanilla-views==1.0.4
pytest-django==2.8.0
requests==2.3.0