diff --git a/readthedocs/core/static/core/font/Inconsolata-Bold.ttf b/readthedocs/core/static/core/font/Inconsolata-Bold.ttf index 58c9fef3a01..809c1f5828f 100644 Binary files a/readthedocs/core/static/core/font/Inconsolata-Bold.ttf and b/readthedocs/core/static/core/font/Inconsolata-Bold.ttf differ diff --git a/readthedocs/core/static/core/font/Inconsolata-Regular.ttf b/readthedocs/core/static/core/font/Inconsolata-Regular.ttf index a87ffba6bef..fc981ce7ad6 100644 Binary files a/readthedocs/core/static/core/font/Inconsolata-Regular.ttf and b/readthedocs/core/static/core/font/Inconsolata-Regular.ttf differ diff --git a/readthedocs/core/static/core/font/Lato-Bold.ttf b/readthedocs/core/static/core/font/Lato-Bold.ttf index 74343694e2b..1d23c7066e0 100644 Binary files a/readthedocs/core/static/core/font/Lato-Bold.ttf and b/readthedocs/core/static/core/font/Lato-Bold.ttf differ diff --git a/readthedocs/core/static/core/font/Lato-Regular.ttf b/readthedocs/core/static/core/font/Lato-Regular.ttf index 04ea8efb136..0f3d0f837d2 100644 Binary files a/readthedocs/core/static/core/font/Lato-Regular.ttf and b/readthedocs/core/static/core/font/Lato-Regular.ttf differ diff --git a/readthedocs/oauth/migrations/0008_add-project-relation.py b/readthedocs/oauth/migrations/0008_add-project-relation.py new file mode 100644 index 00000000000..d12433c4690 --- /dev/null +++ b/readthedocs/oauth/migrations/0008_add-project-relation.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.12 on 2017-03-22 20:10 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth', '0007_org_slug_nonunique'), + ] + + operations = [ + migrations.AddField( + model_name='remoterepository', + name='project', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='remote_repository', to='projects.Project'), + ), + ] diff --git a/readthedocs/oauth/models.py b/readthedocs/oauth/models.py index 218b19af710..c81316aa3b3 100644 --- a/readthedocs/oauth/models.py +++ b/readthedocs/oauth/models.py @@ -84,6 +84,9 @@ class RemoteRepository(models.Model): related_name='repositories', null=True, blank=True) active = models.BooleanField(_('Active'), default=False) + project = models.OneToOneField(Project, on_delete=models.SET_NULL, + related_name='remote_repository', null=True, + blank=True) name = models.CharField(_('Name'), max_length=255) full_name = models.CharField(_('Full Name'), max_length=255) description = models.TextField(_('Description'), blank=True, null=True, diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index 426deed87aa..68752e21819 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -16,6 +16,7 @@ from readthedocs.builds.constants import TAG from readthedocs.core.utils import trigger_build, slugify from readthedocs.redirects.models import Redirect +from readthedocs.oauth.models import RemoteRepository from readthedocs.projects import constants from readthedocs.projects.exceptions import ProjectSpamError from readthedocs.projects.models import Project, EmailHook, WebHook, Domain @@ -74,6 +75,11 @@ class Meta: model = Project fields = ('name', 'repo', 'repo_type') + remote_repository = forms.CharField( + widget=forms.HiddenInput(), + required=False, + ) + def __init__(self, *args, **kwargs): show_advanced = kwargs.pop('show_advanced', False) super(ProjectBasicsForm, self).__init__(*args, **kwargs) @@ -85,6 +91,18 @@ def __init__(self, *args, **kwargs): self.fields['repo'].widget.attrs['placeholder'] = self.placehold_repo() self.fields['repo'].widget.attrs['required'] = True + def save(self, commit=True): + """Add remote repository relationship to the project instance""" + instance = super(ProjectBasicsForm, self).save(commit) + remote_repo = self.cleaned_data.get('remote_repository', None) + if remote_repo: + if commit: + remote_repo.project = self.instance + remote_repo.save() + else: + instance.remote_repository = remote_repo + return instance + def clean_name(self): name = self.cleaned_data.get('name', '') if not self.instance.pk: @@ -105,6 +123,18 @@ def clean_repo(self): u'public (http:// or git://) clone url')) return repo + def clean_remote_repository(self): + remote_repo = self.cleaned_data.get('remote_repository', None) + if not remote_repo: + return None + try: + return RemoteRepository.objects.get( + pk=remote_repo, + users=self.user, + ) + except RemoteRepository.DoesNotExist: + raise forms.ValidationError(_(u'Repository invalid')) + def placehold_repo(self): return choice([ 'https://bitbucket.org/cherrypy/cherrypy', @@ -126,7 +156,8 @@ class Meta: fields = ( 'description', 'documentation_type', - 'language', 'programming_language', + 'language', + 'programming_language', 'project_url', 'tags', ) diff --git a/readthedocs/projects/static-src/projects/js/import.js b/readthedocs/projects/static-src/projects/js/import.js index ca09b715174..10e46de1565 100644 --- a/readthedocs/projects/static-src/projects/js/import.js +++ b/readthedocs/projects/static-src/projects/js/import.js @@ -90,6 +90,7 @@ function Project (instance, view) { repo_type: self.vcs(), description: self.description(), project_url: self.html_url(), + remote_repository: self.id(), }, form = $('
'); diff --git a/readthedocs/projects/static/projects/js/import.js b/readthedocs/projects/static/projects/js/import.js index 4b860506453..738fc023af5 100644 --- a/readthedocs/projects/static/projects/js/import.js +++ b/readthedocs/projects/static/projects/js/import.js @@ -1 +1 @@ -require=function e(r,t,n){function a(s,i){if(!t[s]){if(!r[s]){var u="function"==typeof require&&require;if(!i&&u)return u(s,!0);if(o)return o(s,!0);var c=new Error("Cannot find module '"+s+"'");throw c.code="MODULE_NOT_FOUND",c}var l=t[s]={exports:{}};r[s][0].call(l.exports,function(e){var t=r[s][1][e];return a(t?t:e)},l,l.exports,e,r,t,n)}return t[s].exports}for(var o="function"==typeof require&&require,s=0;s0)setTimeout(r,2e3);else{var a=e.responseJSON.detail||e.statusText;t.reject({message:a})}})}var t=o.Deferred(),n=5;return setTimeout(r,2e3),t}function a(e){var r=o.Deferred(),t=e.url,a=e.token,s={csrfmiddlewaretoken:a};return $.ajax({method:"POST",url:t,data:s,success:function(e){n(e).then(function(){r.resolve()}).fail(function(e){r.reject(e)})},error:function(e){var t=e.responseJSON.detail||e.statusText;r.reject({message:t})}}),r}var o=e("jquery");r.exports={poll_task:n,trigger_task:a}},{jquery:"jquery"}],"projects/import":[function(e,r,t){function a(e,r){var t=this;t.id=u.observable(e.id),t.name=u.observable(e.name),t.slug=u.observable(e.slug),t.active=u.observable(e.active),t.avatar_url=u.observable(i(e.avatar_url,{size:32})),t.display_name=u.computed(function(){return t.name()||t.slug()}),t.filtered=u.computed(function(){var e=r.filter_org();return e&&e!=t.id()})}function o(e,r){var t=this;t.id=u.observable(e.id),t.name=u.observable(e.name),t.full_name=u.observable(e.full_name),t.description=u.observable(e.description),t.vcs=u.observable(e.vcs),t.organization=u.observable(),t.html_url=u.observable(e.html_url),t.clone_url=u.observable(e.clone_url),t.ssh_url=u.observable(e.ssh_url),t.matches=u.observable(e.matches),t.match=u.computed(function(){var e=t.matches();if(e&&e.length>0)return e[0]}),t["private"]=u.observable(e["private"]),t.active=u.observable(e.active),t.admin=u.observable(e.admin),t.is_locked=u.computed(function(){return t["private"]()&&!t.admin()}),t.avatar_url=u.observable(i(e.avatar_url,{size:32})),t.import_repo=function(){var e={name:t.name(),repo:t.clone_url(),repo_type:t.vcs(),description:t.description(),project_url:t.html_url()},n=c("");n.attr("action",r.urls.projects_import).attr("method","POST").hide(),Object.keys(e).map(function(r){var t=c("").attr("type","hidden").attr("name",r).attr("value",e[r]);n.append(t)});var a=c("").attr("type","hidden").attr("name","csrfmiddlewaretoken").attr("value",r.csrf_token);n.append(a);var o=c("").attr("type","submit");n.append(o),c("body").append(n),n.submit()}}function s(e,r){var t=this;t.config=r||{},t.urls=r.urls||{},t.csrf_token=r.csrf_token||"",t.error=u.observable(null),t.is_syncing=u.observable(!1),t.is_ready=u.observable(!1),t.page_count=u.observable(null),t.page_current=u.observable(null),t.page_next=u.observable(null),t.page_previous=u.observable(null),t.filter_org=u.observable(null),t.organizations_raw=u.observableArray(),t.organizations=u.computed(function(){var e=[],r=t.organizations_raw();for(n in r){var o=new a(r[n],t);e.push(o)}return e}),t.projects=u.observableArray(),u.computed(function(){var e=t.filter_org(),r=(t.organizations(),t.page_current()||t.urls["remoterepository-list"]);e&&(r=i(t.urls["remoterepository-list"],{org:e})),t.error(null),c.getJSON(r).success(function(e){var r=[];t.page_next(e.next),t.page_previous(e.previous);for(n in e.results){var a=new o(e.results[n],t);r.push(a)}t.projects(r)}).error(function(e){var r=e.responseJSON.detail||e.statusText;t.error({message:r})}).always(function(){t.is_ready(!0)})}),t.get_organizations=function(){c.getJSON(t.urls["remoteorganization-list"]).success(function(e){t.organizations_raw(e.results)}).error(function(e){var r=e.responseJSON.detail||e.statusText;t.error({message:r})})},t.sync_projects=function(){var e=t.urls.api_sync_remote_repositories;t.error(null),t.is_syncing(!0),l.trigger_task({url:e,token:t.csrf_token}).then(function(e){t.get_organizations()}).fail(function(e){t.error(e)}).always(function(){t.is_syncing(!1)})},t.has_projects=u.computed(function(){return t.projects().length>0}),t.next_page=function(){t.page_current(t.page_next())},t.previous_page=function(){t.page_current(t.page_previous())},t.set_filter_org=function(e){var r=t.filter_org();r==e&&(e=null),t.filter_org(e)}}function i(e,r){var t=c("").attr("href",e).get(0);return Object.keys(r).map(function(e){t.search&&(t.search+="&"),t.search+=e+"="+r[e]}),t.href}var u=e("knockout"),c=e("jquery"),l=e("readthedocs/core/static-src/core/js/tasks");c(function(){var e=c("#id_repo"),r=c("#id_repo_type");e.blur(function(){var t,n=e.val();switch(!0){case/^hg/.test(n):t="hg";break;case/^bzr/.test(n):case/launchpad/.test(n):t="bzr";break;case/trunk/.test(n):case/^svn/.test(n):t="svn";break;default:case/github/.test(n):case/(^git|\.git$)/.test(n):t="git"}r.val(t)})}),s.init=function(e,r,t){var n=new s(r,t);return n.get_organizations(),u.applyBindings(n,e),n},r.exports.ProjectImportView=s},{jquery:"jquery",knockout:"knockout","readthedocs/core/static-src/core/js/tasks":1}]},{},[]); \ No newline at end of file +require=function e(r,t,n){function o(s,i){if(!t[s]){if(!r[s]){var u="function"==typeof require&&require;if(!i&&u)return u(s,!0);if(a)return a(s,!0);var c=new Error("Cannot find module '"+s+"'");throw c.code="MODULE_NOT_FOUND",c}var l=t[s]={exports:{}};r[s][0].call(l.exports,function(e){var t=r[s][1][e];return o(t?t:e)},l,l.exports,e,r,t,n)}return t[s].exports}for(var a="function"==typeof require&&require,s=0;s0)setTimeout(r,2e3);else{var o=e.responseJSON.detail||e.statusText;t.reject({message:o})}})}var t=a.Deferred(),n=5;return setTimeout(r,2e3),t}function o(e){var r=a.Deferred(),t=e.url,o=e.token,s={csrfmiddlewaretoken:o};return $.ajax({method:"POST",url:t,data:s,success:function(e){n(e).then(function(){r.resolve()}).fail(function(e){r.reject(e)})},error:function(e){var t=e.responseJSON.detail||e.statusText;r.reject({message:t})}}),r}var a=e("jquery");r.exports={poll_task:n,trigger_task:o}},{jquery:"jquery"}],"projects/import":[function(e,r,t){function o(e,r){var t=this;t.id=u.observable(e.id),t.name=u.observable(e.name),t.slug=u.observable(e.slug),t.active=u.observable(e.active),t.avatar_url=u.observable(i(e.avatar_url,{size:32})),t.display_name=u.computed(function(){return t.name()||t.slug()}),t.filtered=u.computed(function(){var e=r.filter_org();return e&&e!=t.id()})}function a(e,r){var t=this;t.id=u.observable(e.id),t.name=u.observable(e.name),t.full_name=u.observable(e.full_name),t.description=u.observable(e.description),t.vcs=u.observable(e.vcs),t.organization=u.observable(),t.html_url=u.observable(e.html_url),t.clone_url=u.observable(e.clone_url),t.ssh_url=u.observable(e.ssh_url),t.matches=u.observable(e.matches),t.match=u.computed(function(){var e=t.matches();if(e&&e.length>0)return e[0]}),t["private"]=u.observable(e["private"]),t.active=u.observable(e.active),t.admin=u.observable(e.admin),t.is_locked=u.computed(function(){return t["private"]()&&!t.admin()}),t.avatar_url=u.observable(i(e.avatar_url,{size:32})),t.import_repo=function(){var e={name:t.name(),repo:t.clone_url(),repo_type:t.vcs(),description:t.description(),project_url:t.html_url(),remote_repository:t.id()},n=c("");n.attr("action",r.urls.projects_import).attr("method","POST").hide(),Object.keys(e).map(function(r){var t=c("").attr("type","hidden").attr("name",r).attr("value",e[r]);n.append(t)});var o=c("").attr("type","hidden").attr("name","csrfmiddlewaretoken").attr("value",r.csrf_token);n.append(o);var a=c("").attr("type","submit");n.append(a),c("body").append(n),n.submit()}}function s(e,r){var t=this;t.config=r||{},t.urls=r.urls||{},t.csrf_token=r.csrf_token||"",t.error=u.observable(null),t.is_syncing=u.observable(!1),t.is_ready=u.observable(!1),t.page_count=u.observable(null),t.page_current=u.observable(null),t.page_next=u.observable(null),t.page_previous=u.observable(null),t.filter_org=u.observable(null),t.organizations_raw=u.observableArray(),t.organizations=u.computed(function(){var e=[],r=t.organizations_raw();for(n in r){var a=new o(r[n],t);e.push(a)}return e}),t.projects=u.observableArray(),u.computed(function(){var e=t.filter_org(),r=(t.organizations(),t.page_current()||t.urls["remoterepository-list"]);e&&(r=i(t.urls["remoterepository-list"],{org:e})),t.error(null),c.getJSON(r).success(function(e){var r=[];t.page_next(e.next),t.page_previous(e.previous);for(n in e.results){var o=new a(e.results[n],t);r.push(o)}t.projects(r)}).error(function(e){var r=e.responseJSON.detail||e.statusText;t.error({message:r})}).always(function(){t.is_ready(!0)})}),t.get_organizations=function(){c.getJSON(t.urls["remoteorganization-list"]).success(function(e){t.organizations_raw(e.results)}).error(function(e){var r=e.responseJSON.detail||e.statusText;t.error({message:r})})},t.sync_projects=function(){var e=t.urls.api_sync_remote_repositories;t.error(null),t.is_syncing(!0),l.trigger_task({url:e,token:t.csrf_token}).then(function(e){t.get_organizations()}).fail(function(e){t.error(e)}).always(function(){t.is_syncing(!1)})},t.has_projects=u.computed(function(){return t.projects().length>0}),t.next_page=function(){t.page_current(t.page_next())},t.previous_page=function(){t.page_current(t.page_previous())},t.set_filter_org=function(e){var r=t.filter_org();r==e&&(e=null),t.filter_org(e)}}function i(e,r){var t=c("").attr("href",e).get(0);return Object.keys(r).map(function(e){t.search&&(t.search+="&"),t.search+=e+"="+r[e]}),t.href}var u=e("knockout"),c=e("jquery"),l=e("readthedocs/core/static-src/core/js/tasks");c(function(){var e=c("#id_repo"),r=c("#id_repo_type");e.blur(function(){var t,n=e.val();switch(!0){case/^hg/.test(n):t="hg";break;case/^bzr/.test(n):case/launchpad/.test(n):t="bzr";break;case/trunk/.test(n):case/^svn/.test(n):t="svn";break;default:case/github/.test(n):case/(^git|\.git$)/.test(n):t="git"}r.val(t)})}),s.init=function(e,r,t){var n=new s(r,t);return n.get_organizations(),u.applyBindings(n,e),n},r.exports.ProjectImportView=s},{jquery:"jquery",knockout:"knockout","readthedocs/core/static-src/core/js/tasks":1}]},{},[]); \ No newline at end of file diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index f1648b9715e..1be167ee1db 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -227,20 +227,8 @@ def get_form_kwargs(self, step=None): kwargs['user'] = self.request.user if step == 'basics': kwargs['show_advanced'] = True - if step == 'extra': - extra_form = self.get_form_from_step('basics') - project = extra_form.save(commit=False) - kwargs['instance'] = project return kwargs - def get_form_from_step(self, step): - form = self.form_list[step]( - data=self.get_cleaned_data_for_step(step), - **self.get_form_kwargs(step) - ) - form.full_clean() - return form - def get_template_names(self): """Return template names based on step name""" return 'projects/import_{0}.html'.format(self.steps.current) @@ -370,7 +358,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): initial_data = {} initial_data['basics'] = {} - for key in ['name', 'repo', 'repo_type']: + for key in ['name', 'repo', 'repo_type', 'remote_repository']: initial_data['basics'][key] = request.POST.get(key) initial_data['extra'] = {} for key in ['description', 'project_url']: diff --git a/readthedocs/rtd_tests/base.py b/readthedocs/rtd_tests/base.py index 6b0b5303c74..5565c6b0829 100644 --- a/readthedocs/rtd_tests/base.py +++ b/readthedocs/rtd_tests/base.py @@ -35,66 +35,114 @@ class MockBuildTestCase(TestCase): pass -class WizardTestCase(TestCase): - '''Test case for testing wizard forms''' +class RequestFactoryTestMixin(object): + + """Adds helper methods for testing with :py:class:`RequestFactory` + + This handles setting up authentication, messages, and session handling + """ + + def request(self, *args, **kwargs): + """Perform request from factory + + :param method: Request method as string + :returns: Request instance + + Several additional keywords arguments can be passed in: + + user + User instance to use for the request, will default to an + :py:class:`AnonymousUser` instance otherwise. + + session + Dictionary to instantiate the session handler with. + + Other keyword arguments are passed into the request method + """ + factory = RequestFactory() + method = kwargs.pop('method', 'get') + fn = getattr(factory, method) + request = fn(*args, **kwargs) + + # Mock user, session, and messages + request.user = kwargs.pop('user', AnonymousUser()) + + session = kwargs.pop('session', {}) + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.update(session) + request.session.save() + + messages = FallbackStorage(request) + setattr(request, '_messages', messages) + + return request + + +class WizardTestCase(RequestFactoryTestMixin, TestCase): + + """Test case for testing wizard forms""" step_data = OrderedDict({}) url = None wizard_class_slug = None + wizard_class = None @patch('readthedocs.projects.views.private.trigger_build', lambda x, basic: None) - @patch('readthedocs.projects.views.private.trigger_build', lambda x, basic: None) - def post_step(self, step, **data): - '''Post step form data to `url`, using supplementary `kwargs` + def post_step(self, step, **kwargs): + """Post step form data to `url`, using supplementary `kwargs` Use data from kwargs to build dict to pass into form - ''' + """ if not self.url: raise Exception('Missing wizard URL') try: - data = {} - for key in self.step_data: - data.update({('{0}-{1}'.format(key, k), v) - for (k, v) in self.step_data[key].items()}) - if key == step: - break + data = dict( + ('{0}-{1}'.format(step, k), v) + for (k, v) in self.step_data[step].items() + ) except KeyError: pass # Update with prefixed step data data['{0}-current_step'.format(self.wizard_class_slug)] = step - resp = self.client.post(self.url, data) + view = self.wizard_class.as_view() + req = self.request(self.url, method='post', data=data, **kwargs) + resp = view(req) self.assertIsNotNone(resp) return resp # We use camelCase on purpose here to conform with unittest's naming # conventions. def assertWizardResponse(self, response, step=None): # noqa - '''Assert successful wizard response''' - # Is is the last form + """Assert successful wizard response""" + # This is the last form if step is None: try: - wizard = response.context['wizard'] + wizard = response.context_data['wizard'] self.assertEqual(wizard['form'].errors, {}) except (TypeError, KeyError): pass self.assertEqual(response.status_code, 302) else: - self.assertIn('wizard', response.context) - wizard = response.context['wizard'] + self.assertIn('wizard', response.context_data) + wizard = response.context_data['wizard'] try: self.assertEqual(wizard['form'].errors, {}) except AssertionError: self.assertIsNone(wizard['form'].errors) self.assertEqual(response.status_code, 200) - self.assertIsNotNone(response.context['wizard']) + self.assertIsNotNone(response.context_data['wizard']) self.assertEqual(wizard['steps'].current, step) - self.assertIn('{0}-current_step'.format(self.wizard_class_slug), - response.content) + response.render() + self.assertIn( + 'name="{0}-current_step"'.format(self.wizard_class_slug), + response.content + ) # We use camelCase on purpose here to conform with unittest's naming # conventions. def assertWizardFailure(self, response, field, match=None): # noqa - '''Assert field threw a validation error + """Assert field threw a validation error response Client response object @@ -104,54 +152,11 @@ def assertWizardFailure(self, response, field, match=None): # noqa match Regex match for field validation error - ''' + """ self.assertEqual(response.status_code, 200) - self.assertIn('wizard', response.context) - self.assertIn('form', response.context['wizard']) - self.assertIn(field, response.context['wizard']['form'].errors) + self.assertIn('wizard', response.context_data) + self.assertIn('form', response.context_data['wizard']) + self.assertIn(field, response.context_data['wizard']['form'].errors) if match is not None: - error = response.context['wizard']['form'].errors[field] + error = response.context_data['wizard']['form'].errors[field] self.assertRegexpMatches(unicode(error), match) - - -class RequestFactoryTestMixin(object): - - """Adds helper methods for testing with :py:class:`RequestFactory` - - This handles setting up authentication, messages, and session handling - """ - - def request(self, *args, **kwargs): - """Perform request from factory - - :param method: Request method as string - :returns: Request instance - - Several additional keywords arguments can be passed in: - - user - User instance to use for the request, will default to an - :py:class:`AnonymousUser` instance otherwise. - - session - Dictionary to instantiate the session handler with. - - Other keyword arguments are passed into the request method - """ - factory = RequestFactory() - method = kwargs.pop('method', 'get') - fn = getattr(factory, method) - request = fn(*args, **kwargs) - - # Mock user, session, and messages - request.user = kwargs.pop('user', AnonymousUser()) - - session = kwargs.pop('session', {}) - middleware = SessionMiddleware() - middleware.process_request(request) - request.session.update(session) - - messages = FallbackStorage(request) - setattr(request, '_messages', messages) - - return request diff --git a/readthedocs/rtd_tests/tests/test_project_views.py b/readthedocs/rtd_tests/tests/test_project_views.py index b27d10c0af4..8e1b3f0a82f 100644 --- a/readthedocs/rtd_tests/tests/test_project_views.py +++ b/readthedocs/rtd_tests/tests/test_project_views.py @@ -4,6 +4,7 @@ from django.test import TestCase from django.contrib.auth.models import User from django.contrib.messages import constants as message_const +from django.http.response import HttpResponseRedirect from django.views.generic.base import ContextMixin from django_dynamic_fixture import get from django_dynamic_fixture import new @@ -11,7 +12,9 @@ from readthedocs.core.models import UserProfile from readthedocs.rtd_tests.base import (WizardTestCase, MockBuildTestCase, RequestFactoryTestMixin) +from readthedocs.oauth.models import RemoteRepository from readthedocs.projects.exceptions import ProjectSpamError +from readthedocs.projects.forms import ProjectBasicsForm from readthedocs.projects.models import Project, Domain from readthedocs.projects.views.private import ImportWizardView from readthedocs.projects.views.mixins import ProjectRelationMixin @@ -51,10 +54,10 @@ def test_profile_middleware_no_profile(self): self.assertEqual(resp.status_code, 302) self.assertEqual(resp['location'], '/projects/foobar/') - @patch.object(ImportWizardView, 'done') - def test_profile_middleware_spam(self, view): + @patch('readthedocs.projects.views.private.ProjectBasicsForm.clean') + def test_profile_middleware_spam(self, form): """User will be banned""" - view.side_effect = ProjectSpamError + form.side_effect = ProjectSpamError req = self.request('/projects/import', method='post', data=self.data) req.user = get(User) resp = ImportWizardView.as_view()(req) @@ -77,23 +80,30 @@ def test_profile_middleware_banned(self): class TestBasicsForm(WizardTestCase): wizard_class_slug = 'import_wizard_view' + wizard_class = ImportWizardView url = '/dashboard/import/manual/' def setUp(self): - self.eric = User(username='eric') - self.eric.set_password('test') - self.eric.save() - self.client.login(username='eric', password='test') + self.user = get(User) self.step_data['basics'] = { 'name': 'foobar', 'repo': 'http://example.com/foobar', 'repo_type': 'git', } + def tearDown(self): + Project.objects.filter(slug='foobar').delete() + + def request(self, *args, **kwargs): + kwargs['user'] = self.user + return super(TestBasicsForm, self).request(*args, **kwargs) + def test_form_pass(self): - '''Only submit the basics''' + """Only submit the basics""" resp = self.post_step('basics') - self.assertWizardResponse(resp) + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/projects/foobar/') proj = Project.objects.get(name='foobar') self.assertIsNotNone(proj) @@ -101,8 +111,27 @@ def test_form_pass(self): self.assertEqual(getattr(proj, key), val) self.assertEqual(proj.documentation_type, 'sphinx') + def test_remote_repository_is_added(self): + remote_repo = get(RemoteRepository, users=[self.user]) + self.step_data['basics']['remote_repository'] = remote_repo.pk + resp = self.post_step('basics') + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/projects/foobar/') + + proj = Project.objects.get(name='foobar') + self.assertIsNotNone(proj) + self.assertEqual(proj.remote_repository, remote_repo) + + def test_remote_repository_is_not_added_for_wrong_user(self): + user = get(User) + remote_repo = get(RemoteRepository, users=[user]) + self.step_data['basics']['remote_repository'] = remote_repo.pk + resp = self.post_step('basics') + self.assertWizardFailure(resp, 'remote_repository') + def test_form_missing(self): - '''Submit form with missing data, expect to get failures''' + """Submit form with missing data, expect to get failures""" self.step_data['basics'] = {'advanced': True} resp = self.post_step('basics') self.assertWizardFailure(resp, 'name') @@ -122,11 +151,13 @@ def setUp(self): } def test_form_pass(self): - '''Test all forms pass validation''' + """Test all forms pass validation""" resp = self.post_step('basics') self.assertWizardResponse(resp, 'extra') - resp = self.post_step('extra') - self.assertWizardResponse(resp) + resp = self.post_step('extra', session=resp._request.session.items()) + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/projects/foobar/') proj = Project.objects.get(name='foobar') self.assertIsNotNone(proj) @@ -142,60 +173,78 @@ def test_form_pass(self): self.assertEqual(getattr(proj, key), val) def test_form_missing_extra(self): - '''Submit extra form with missing data, expect to get failures''' + """Submit extra form with missing data, expect to get failures""" # Remove extra data to trigger validation errors self.step_data['extra'] = {} resp = self.post_step('basics') self.assertWizardResponse(resp, 'extra') - resp = self.post_step('extra') + resp = self.post_step('extra', session=resp._request.session.items()) self.assertWizardFailure(resp, 'language') self.assertWizardFailure(resp, 'documentation_type') - @patch('readthedocs.projects.forms.ProjectExtraForm.clean_description', + def test_remote_repository_is_added(self): + remote_repo = get(RemoteRepository, users=[self.user]) + self.step_data['basics']['remote_repository'] = remote_repo.pk + resp = self.post_step('basics') + self.assertWizardResponse(resp, 'extra') + resp = self.post_step('extra', session=resp._request.session.items()) + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/projects/foobar/') + + proj = Project.objects.get(name='foobar') + self.assertIsNotNone(proj) + self.assertEqual(proj.remote_repository, remote_repo) + + @patch('readthedocs.projects.views.private.ProjectExtraForm.clean_description', create=True) def test_form_spam(self, mocked_validator): - '''Don't add project on a spammy description''' - self.eric.date_joined = datetime.now() - timedelta(days=365) - self.eric.save() - mocked_validator.side_effect=ProjectSpamError + """Don't add project on a spammy description""" + self.user.date_joined = datetime.now() - timedelta(days=365) + self.user.save() + mocked_validator.side_effect = ProjectSpamError with self.assertRaises(Project.DoesNotExist): proj = Project.objects.get(name='foobar') resp = self.post_step('basics') self.assertWizardResponse(resp, 'extra') - resp = self.post_step('extra') - self.assertWizardResponse(resp) + resp = self.post_step('extra', session=resp._request.session.items()) + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/') with self.assertRaises(Project.DoesNotExist): proj = Project.objects.get(name='foobar') - self.assertFalse(self.eric.profile.banned) + self.assertFalse(self.user.profile.banned) - @patch('readthedocs.projects.forms.ProjectExtraForm.clean_description', + @patch('readthedocs.projects.views.private.ProjectExtraForm.clean_description', create=True) def test_form_spam_ban_user(self, mocked_validator): - '''Don't add spam and ban new user''' - self.eric.date_joined = datetime.now() - self.eric.save() - mocked_validator.side_effect=ProjectSpamError + """Don't add spam and ban new user""" + self.user.date_joined = datetime.now() + self.user.save() + mocked_validator.side_effect = ProjectSpamError with self.assertRaises(Project.DoesNotExist): proj = Project.objects.get(name='foobar') resp = self.post_step('basics') self.assertWizardResponse(resp, 'extra') - resp = self.post_step('extra') - self.assertWizardResponse(resp) + resp = self.post_step('extra', session=resp._request.session.items()) + self.assertIsInstance(resp, HttpResponseRedirect) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp['location'], '/') with self.assertRaises(Project.DoesNotExist): proj = Project.objects.get(name='foobar') - self.assertTrue(self.eric.profile.banned) + self.assertTrue(self.user.profile.banned) class TestImportDemoView(MockBuildTestCase): - '''Test project import demo view''' + """Test project import demo view""" fixtures = ['test_data', 'eric'] @@ -212,7 +261,7 @@ def test_import_demo_pass(self): self.assertEqual(messages[0].level, message_const.SUCCESS) def test_import_demo_already_imported(self): - '''Import demo project multiple times, expect failure 2nd post''' + """Import demo project multiple times, expect failure 2nd post""" self.test_import_demo_pass() project = Project.objects.get(slug='eric-demo') @@ -229,7 +278,7 @@ def test_import_demo_already_imported(self): Project.objects.get(slug='eric-demo')) def test_import_demo_another_user_imported(self): - '''Import demo project after another user, expect success''' + """Import demo project after another user, expect success""" self.test_import_demo_pass() project = Project.objects.get(slug='eric-demo') @@ -245,7 +294,7 @@ def test_import_demo_another_user_imported(self): self.assertEqual(messages[0].level, message_const.SUCCESS) def test_import_demo_imported_renamed(self): - '''If the demo project is renamed, don't import another''' + """If the demo project is renamed, don't import another""" self.test_import_demo_pass() project = Project.objects.get(slug='eric-demo') project.name = 'eric-demo-foobar' @@ -266,12 +315,12 @@ def test_import_demo_imported_renamed(self): Project.objects.get(slug='eric-demo')) def test_import_demo_imported_duplicate(self): - '''If a project exists with same name, expect a failure importing demo + """If a project exists with same name, expect a failure importing demo This should be edge case, user would have to import a project (not the demo project), named user-demo, and then manually enter the demo import URL, as the onboarding isn't shown when projects > 0 - ''' + """ self.test_import_demo_pass() project = Project.objects.get(slug='eric-demo') project.repo = 'file:///foobar'