Skip to content

Commit a23df78

Browse files
committed
New subproject admin page
This replaces the list of text with a table that is more navigable and also replaces the create form with a dropdown form of the projects you can add. This list of projects is limited to projects you are the admin of, and which have not been added as a sub or super project anywhere else. This depends on #2954 and others.
1 parent bb2a1b9 commit a23df78

File tree

11 files changed

+399
-185
lines changed

11 files changed

+399
-185
lines changed

media/css/core.css

+6
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,12 @@ div.httpexchange div.highlight pre {
11021102
font-size: .9em;
11031103
}
11041104

1105+
/* Subprojects */
1106+
div.module.project-subprojects div.subproject-meta {
1107+
font-size: .9em;
1108+
font-style: italic;
1109+
}
1110+
11051111
/* Pygments */
11061112
div.highlight pre .hll { background-color: #ffffcc }
11071113
div.highlight pre .c { color: #60a0b0; font-style: italic } /* Comment */

readthedocs/projects/forms.py

+43-39
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from readthedocs.oauth.models import RemoteRepository
2222
from readthedocs.projects import constants
2323
from readthedocs.projects.exceptions import ProjectSpamError
24-
from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
24+
from readthedocs.projects.models import (
25+
Project, ProjectRelationship, EmailHook, WebHook, Domain)
2526
from readthedocs.redirects.models import Redirect
2627

2728
from future import standard_library
@@ -236,6 +237,47 @@ class Meta(object):
236237
)
237238

238239

240+
class ProjectRelationshipForm(forms.ModelForm):
241+
242+
"""Form to add/update project relationships"""
243+
244+
parent = forms.CharField(widget=forms.HiddenInput(), required=False)
245+
246+
class Meta(object):
247+
model = ProjectRelationship
248+
exclude = []
249+
250+
def __init__(self, *args, **kwargs):
251+
self.project = kwargs.pop('project', None)
252+
self.user = kwargs.pop('user', None)
253+
super(ProjectRelationshipForm, self).__init__(*args, **kwargs)
254+
# Don't display the update form with an editable child, as it will be
255+
# filtered out from the queryset anyways.
256+
if hasattr(self, 'instance') and self.instance.pk is not None:
257+
self.fields['child'].disabled = True
258+
else:
259+
self.fields['child'].queryset = self.get_subproject_queryset()
260+
261+
def clean_parent(self):
262+
if self.project.superprojects.exists():
263+
# This validation error is mostly for testing, users shouldn't see
264+
# this in normal circumstances
265+
raise forms.ValidationError(_("Subproject nesting is not supported"))
266+
return self.project
267+
268+
def get_subproject_queryset(self):
269+
"""Return scrubbed subproject choice queryset
270+
271+
This removes projects that are either already a subproject of another
272+
project, or are a superproject, as neither case is supported.
273+
"""
274+
queryset = (Project.objects.for_admin_user(self.user)
275+
.exclude(subprojects__isnull=False)
276+
.exclude(superprojects__isnull=False))
277+
return queryset
278+
279+
280+
239281
class DualCheckboxWidget(forms.CheckboxInput):
240282

241283
"""Checkbox with link to the version's built documentation"""
@@ -364,44 +406,6 @@ def build_upload_html_form(project):
364406
return type('UploadHTMLForm', (BaseUploadHTMLForm,), attrs)
365407

366408

367-
class SubprojectForm(forms.Form):
368-
369-
"""Project subproject form"""
370-
371-
subproject = forms.CharField()
372-
alias = forms.CharField(required=False)
373-
374-
def __init__(self, *args, **kwargs):
375-
self.user = kwargs.pop('user')
376-
self.parent = kwargs.pop('parent')
377-
super(SubprojectForm, self).__init__(*args, **kwargs)
378-
379-
def clean_subproject(self):
380-
"""Normalize subproject field
381-
382-
Does lookup on against :py:class:`Project` to ensure matching project
383-
exists. Return the :py:class:`Project` object instead.
384-
"""
385-
subproject_name = self.cleaned_data['subproject']
386-
subproject_qs = Project.objects.filter(slug=subproject_name)
387-
if not subproject_qs.exists():
388-
raise forms.ValidationError((_("Project %(name)s does not exist")
389-
% {'name': subproject_name}))
390-
subproject = subproject_qs.first()
391-
if not AdminPermission.is_admin(self.user, subproject):
392-
raise forms.ValidationError(_(
393-
'You need to be admin of {name} in order to add it as '
394-
'a subproject.'.format(name=subproject_name)))
395-
return subproject
396-
397-
def save(self):
398-
relationship = self.parent.add_subproject(
399-
self.cleaned_data['subproject'],
400-
alias=self.cleaned_data['alias'],
401-
)
402-
return relationship
403-
404-
405409
class UserForm(forms.Form):
406410

407411
"""Project user association form"""

readthedocs/projects/urls/private.py

+22-8
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,6 @@
6464
private.project_delete,
6565
name='projects_delete'),
6666

67-
url(r'^(?P<project_slug>[-\w]+)/subprojects/delete/(?P<child_slug>[-\w]+)/$', # noqa
68-
private.project_subprojects_delete,
69-
name='projects_subprojects_delete'),
70-
71-
url(r'^(?P<project_slug>[-\w]+)/subprojects/$',
72-
private.project_subprojects,
73-
name='projects_subprojects'),
74-
7567
url(r'^(?P<project_slug>[-\w]+)/users/$',
7668
private.project_users,
7769
name='projects_users'),
@@ -165,3 +157,25 @@
165157
]
166158

167159
urlpatterns += integration_urls
160+
161+
subproject_urls = [
162+
url(r'^(?P<project_slug>{project_slug})/subprojects/$'.format(**pattern_opts),
163+
private.ProjectRelationshipList.as_view(),
164+
name='projects_subprojects'),
165+
url((r'^(?P<project_slug>{project_slug})/subprojects/create/$'
166+
.format(**pattern_opts)),
167+
private.ProjectRelationshipCreate.as_view(),
168+
name='projects_subprojects_create'),
169+
url((r'^(?P<project_slug>{project_slug})/'
170+
r'subprojects/(?P<subproject_slug>{project_slug})/edit/$'
171+
.format(**pattern_opts)),
172+
private.ProjectRelationshipUpdate.as_view(),
173+
name='projects_subprojects_update'),
174+
url((r'^(?P<project_slug>{project_slug})/'
175+
r'subprojects/(?P<subproject_slug>{project_slug})/delete/$'
176+
.format(**pattern_opts)),
177+
private.ProjectRelationshipDelete.as_view(),
178+
name='projects_subprojects_delete'),
179+
]
180+
181+
urlpatterns += subproject_urls

readthedocs/projects/views/private.py

+44-37
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@
2828
from readthedocs.core.mixins import ListViewWithForm
2929
from readthedocs.integrations.models import HttpExchange, Integration
3030
from readthedocs.projects.forms import (
31-
ProjectBasicsForm, ProjectExtraForm,
32-
ProjectAdvancedForm, UpdateProjectForm, SubprojectForm,
31+
ProjectBasicsForm, ProjectExtraForm, ProjectAdvancedForm,
32+
UpdateProjectForm, ProjectRelationshipForm,
3333
build_versions_form, UserForm, EmailHookForm, TranslationForm,
3434
RedirectForm, WebHookForm, DomainForm, IntegrationForm,
3535
ProjectAdvertisingForm)
36-
from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
36+
from readthedocs.projects.models import (
37+
Project, ProjectRelationship, EmailHook, WebHook, Domain)
3738
from readthedocs.projects.views.base import ProjectAdminMixin, ProjectSpamMixin
3839
from readthedocs.projects import tasks
3940
from readthedocs.oauth.services import registry
@@ -398,7 +399,7 @@ def edit_alias(request, project_slug, alias_id=None):
398399
class AliasList(PrivateViewMixin, ListView):
399400
model = VersionAlias
400401
template_context_name = 'alias'
401-
template_name = 'projects/alias_list.html',
402+
template_name = 'projects/alias_list.html'
402403

403404
def get_queryset(self):
404405
self.project = get_object_or_404(
@@ -407,44 +408,50 @@ def get_queryset(self):
407408
return self.project.aliases.all()
408409

409410

410-
@login_required
411-
def project_subprojects(request, project_slug):
412-
"""Project subprojects view and form view"""
413-
project = get_object_or_404(Project.objects.for_admin_user(request.user),
414-
slug=project_slug)
411+
class ProjectRelationshipMixin(ProjectAdminMixin, PrivateViewMixin):
415412

416-
form_kwargs = {
417-
'parent': project,
418-
'user': request.user,
419-
}
420-
if request.method == 'POST':
421-
form = SubprojectForm(request.POST, **form_kwargs)
422-
if form.is_valid():
423-
form.save()
424-
broadcast(type='app', task=tasks.symlink_subproject, args=[project.pk])
425-
project_dashboard = reverse(
426-
'projects_subprojects', args=[project.slug])
427-
return HttpResponseRedirect(project_dashboard)
428-
else:
429-
form = SubprojectForm(**form_kwargs)
413+
model = ProjectRelationship
414+
form_class = ProjectRelationshipForm
415+
lookup_field = 'child__slug'
416+
lookup_url_kwarg = 'subproject_slug'
430417

431-
subprojects = project.subprojects.all()
418+
def get_queryset(self):
419+
self.project = self.get_project()
420+
return self.model.objects.filter(parent=self.project)
432421

433-
return render_to_response(
434-
'projects/project_subprojects.html',
435-
{'form': form, 'project': project, 'subprojects': subprojects},
436-
context_instance=RequestContext(request)
437-
)
422+
def get_form(self, data=None, files=None, **kwargs):
423+
kwargs['user'] = self.request.user
424+
return super(ProjectRelationshipMixin, self).get_form(data, files, **kwargs)
438425

426+
def form_valid(self, form):
427+
broadcast(type='app', task=tasks.symlink_subproject,
428+
args=[self.get_project().pk])
429+
return super(ProjectRelationshipMixin, self).form_valid(form)
439430

440-
@login_required
441-
def project_subprojects_delete(request, project_slug, child_slug):
442-
parent = get_object_or_404(Project.objects.for_admin_user(request.user), slug=project_slug)
443-
child = get_object_or_404(Project.objects.all(), slug=child_slug)
444-
parent.remove_subproject(child)
445-
broadcast(type='app', task=tasks.symlink_subproject, args=[parent.pk])
446-
return HttpResponseRedirect(reverse('projects_subprojects',
447-
args=[parent.slug]))
431+
def get_success_url(self):
432+
return reverse('projects_subprojects', args=[self.get_project().slug])
433+
434+
435+
class ProjectRelationshipList(ProjectRelationshipMixin, ListView):
436+
437+
def get_context_data(self, **kwargs):
438+
ctx = super(ProjectRelationshipMixin, self).get_context_data(**kwargs)
439+
ctx['superproject'] = self.project.superprojects.first()
440+
return ctx
441+
442+
443+
class ProjectRelationshipCreate(ProjectRelationshipMixin, CreateView):
444+
pass
445+
446+
447+
class ProjectRelationshipUpdate(ProjectRelationshipMixin, UpdateView):
448+
pass
449+
450+
451+
class ProjectRelationshipDelete(ProjectRelationshipMixin, DeleteView):
452+
453+
def get(self, request, *args, **kwargs):
454+
return self.http_method_not_allowed(request, *args, **kwargs)
448455

449456

450457
@login_required

readthedocs/rtd_tests/tests/test_privacy_urls.py

+3
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def setUp(self):
142142
self.domain = get(Domain, url='http://docs.foobar.com', project=self.pip)
143143
self.default_kwargs = {
144144
'project_slug': self.pip.slug,
145+
'subproject_slug': self.subproject.slug,
145146
'version_slug': self.pip.versions.all()[0].slug,
146147
'filename': 'index.html',
147148
'type_': 'pdf',
@@ -227,6 +228,7 @@ class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase):
227228
'/dashboard/pip/users/delete/': {'status_code': 405},
228229
'/dashboard/pip/notifications/delete/': {'status_code': 405},
229230
'/dashboard/pip/redirects/delete/': {'status_code': 405},
231+
'/dashboard/pip/subprojects/sub/delete/': {'status_code': 405},
230232
'/dashboard/pip/integrations/sync/': {'status_code': 405},
231233
'/dashboard/pip/integrations/1/sync/': {'status_code': 405},
232234
'/dashboard/pip/integrations/1/delete/': {'status_code': 405},
@@ -255,6 +257,7 @@ class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase):
255257
'/dashboard/pip/users/delete/': {'status_code': 405},
256258
'/dashboard/pip/notifications/delete/': {'status_code': 405},
257259
'/dashboard/pip/redirects/delete/': {'status_code': 405},
260+
'/dashboard/pip/subprojects/sub/delete/': {'status_code': 405},
258261
'/dashboard/pip/integrations/sync/': {'status_code': 405},
259262
'/dashboard/pip/integrations/1/sync/': {'status_code': 405},
260263
'/dashboard/pip/integrations/1/delete/': {'status_code': 405},

readthedocs/rtd_tests/tests/test_project_views.py

+15
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,21 @@ def test_delete_project(self):
381381
task=tasks.remove_dir,
382382
args=[project.doc_path])
383383

384+
def test_subproject_create(self):
385+
project = get(Project, slug='pip', users=[self.user])
386+
subproject = get(Project, users=[self.user])
387+
388+
with patch('readthedocs.projects.views.private.broadcast') as broadcast:
389+
response = self.client.post(
390+
'/dashboard/pip/subprojects/create/',
391+
data={'child': subproject.pk},
392+
)
393+
self.assertEqual(response.status_code, 302)
394+
broadcast.assert_called_with(
395+
type='app',
396+
task=tasks.symlink_subproject,
397+
args=[project.pk])
398+
384399

385400
class TestPrivateMixins(MockBuildTestCase):
386401

0 commit comments

Comments
 (0)