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 %} +
{% csrf_token %} +

Are you sure you want to delete {{ object.url }}?

+ +
+ +{% 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 %} +
{% csrf_token %} + {{ form.as_p }} +

+ +

+
+{% 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" %}

+
{% csrf_token %} + {{ form.as_p }} +

+ +

+
+{% 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