Skip to content

New subproject admin page #2957

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 22, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions media/css/core.css
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,12 @@ div.httpexchange div.highlight pre {
font-size: .9em;
}

/* Subprojects */
div.module.project-subprojects div.subproject-meta {
font-size: .9em;
font-style: italic;
}

/* Pygments */
div.highlight pre .hll { background-color: #ffffcc }
div.highlight pre .c { color: #60a0b0; font-style: italic } /* Comment */
Expand Down
81 changes: 42 additions & 39 deletions readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
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
from readthedocs.projects.models import (
Project, ProjectRelationship, EmailHook, WebHook, Domain)
from readthedocs.redirects.models import Redirect

from future import standard_library
Expand Down Expand Up @@ -236,6 +237,46 @@ class Meta(object):
)


class ProjectRelationshipForm(forms.ModelForm):

"""Form to add/update project relationships"""

parent = forms.CharField(widget=forms.HiddenInput(), required=False)

class Meta(object):
model = ProjectRelationship
exclude = []

def __init__(self, *args, **kwargs):
self.project = kwargs.pop('project', None)
self.user = kwargs.pop('user', None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be checking for the existence of these variables here? Seems like it would throw errors accessing None later on.

super(ProjectRelationshipForm, self).__init__(*args, **kwargs)
# Don't display the update form with an editable child, as it will be
# filtered out from the queryset anyways.
if hasattr(self, 'instance') and self.instance.pk is not None:
self.fields['child'].disabled = True
else:
self.fields['child'].queryset = self.get_subproject_queryset()

def clean_parent(self):
if self.project.superprojects.exists():
# This validation error is mostly for testing, users shouldn't see
# this in normal circumstances
raise forms.ValidationError(_("Subproject nesting is not supported"))
return self.project

def get_subproject_queryset(self):
"""Return scrubbed subproject choice queryset

This removes projects that are either already a subproject of another
project, or are a superproject, as neither case is supported.
"""
queryset = (Project.objects.for_admin_user(self.user)
.exclude(subprojects__isnull=False)
.exclude(superprojects__isnull=False))
return queryset


class DualCheckboxWidget(forms.CheckboxInput):

"""Checkbox with link to the version's built documentation"""
Expand Down Expand Up @@ -364,44 +405,6 @@ def build_upload_html_form(project):
return type('UploadHTMLForm', (BaseUploadHTMLForm,), attrs)


class SubprojectForm(forms.Form):

"""Project subproject form"""

subproject = forms.CharField()
alias = forms.CharField(required=False)

def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.parent = kwargs.pop('parent')
super(SubprojectForm, self).__init__(*args, **kwargs)

def clean_subproject(self):
"""Normalize subproject field

Does lookup on against :py:class:`Project` to ensure matching project
exists. Return the :py:class:`Project` object instead.
"""
subproject_name = self.cleaned_data['subproject']
subproject_qs = Project.objects.filter(slug=subproject_name)
if not subproject_qs.exists():
raise forms.ValidationError((_("Project %(name)s does not exist")
% {'name': subproject_name}))
subproject = subproject_qs.first()
if not AdminPermission.is_admin(self.user, subproject):
raise forms.ValidationError(_(
'You need to be admin of {name} in order to add it as '
'a subproject.'.format(name=subproject_name)))
return subproject

def save(self):
relationship = self.parent.add_subproject(
self.cleaned_data['subproject'],
alias=self.cleaned_data['alias'],
)
return relationship


class UserForm(forms.Form):

"""Project user association form"""
Expand Down
30 changes: 22 additions & 8 deletions readthedocs/projects/urls/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,6 @@
private.project_delete,
name='projects_delete'),

url(r'^(?P<project_slug>[-\w]+)/subprojects/delete/(?P<child_slug>[-\w]+)/$', # noqa
private.project_subprojects_delete,
name='projects_subprojects_delete'),

url(r'^(?P<project_slug>[-\w]+)/subprojects/$',
private.project_subprojects,
name='projects_subprojects'),

url(r'^(?P<project_slug>[-\w]+)/users/$',
private.project_users,
name='projects_users'),
Expand Down Expand Up @@ -165,3 +157,25 @@
]

urlpatterns += integration_urls

subproject_urls = [
url(r'^(?P<project_slug>{project_slug})/subprojects/$'.format(**pattern_opts),
private.ProjectRelationshipList.as_view(),
name='projects_subprojects'),
url((r'^(?P<project_slug>{project_slug})/subprojects/create/$'
.format(**pattern_opts)),
private.ProjectRelationshipCreate.as_view(),
name='projects_subprojects_create'),
url((r'^(?P<project_slug>{project_slug})/'
r'subprojects/(?P<subproject_slug>{project_slug})/edit/$'
.format(**pattern_opts)),
private.ProjectRelationshipUpdate.as_view(),
name='projects_subprojects_update'),
url((r'^(?P<project_slug>{project_slug})/'
r'subprojects/(?P<subproject_slug>{project_slug})/delete/$'
.format(**pattern_opts)),
private.ProjectRelationshipDelete.as_view(),
name='projects_subprojects_delete'),
]

urlpatterns += subproject_urls
81 changes: 44 additions & 37 deletions readthedocs/projects/views/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@
from readthedocs.core.mixins import ListViewWithForm
from readthedocs.integrations.models import HttpExchange, Integration
from readthedocs.projects.forms import (
ProjectBasicsForm, ProjectExtraForm,
ProjectAdvancedForm, UpdateProjectForm, SubprojectForm,
ProjectBasicsForm, ProjectExtraForm, ProjectAdvancedForm,
UpdateProjectForm, ProjectRelationshipForm,
build_versions_form, UserForm, EmailHookForm, TranslationForm,
RedirectForm, WebHookForm, DomainForm, IntegrationForm,
ProjectAdvertisingForm)
from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
from readthedocs.projects.models import (
Project, ProjectRelationship, EmailHook, WebHook, Domain)
from readthedocs.projects.views.base import ProjectAdminMixin, ProjectSpamMixin
from readthedocs.projects import tasks
from readthedocs.oauth.services import registry
Expand Down Expand Up @@ -398,7 +399,7 @@ def edit_alias(request, project_slug, alias_id=None):
class AliasList(PrivateViewMixin, ListView):
model = VersionAlias
template_context_name = 'alias'
template_name = 'projects/alias_list.html',
template_name = 'projects/alias_list.html'

def get_queryset(self):
self.project = get_object_or_404(
Expand All @@ -407,44 +408,50 @@ def get_queryset(self):
return self.project.aliases.all()


@login_required
def project_subprojects(request, project_slug):
"""Project subprojects view and form view"""
project = get_object_or_404(Project.objects.for_admin_user(request.user),
slug=project_slug)
class ProjectRelationshipMixin(ProjectAdminMixin, PrivateViewMixin):

form_kwargs = {
'parent': project,
'user': request.user,
}
if request.method == 'POST':
form = SubprojectForm(request.POST, **form_kwargs)
if form.is_valid():
form.save()
broadcast(type='app', task=tasks.symlink_subproject, args=[project.pk])
project_dashboard = reverse(
'projects_subprojects', args=[project.slug])
return HttpResponseRedirect(project_dashboard)
else:
form = SubprojectForm(**form_kwargs)
model = ProjectRelationship
form_class = ProjectRelationshipForm
lookup_field = 'child__slug'
lookup_url_kwarg = 'subproject_slug'

subprojects = project.subprojects.all()
def get_queryset(self):
self.project = self.get_project()
return self.model.objects.filter(parent=self.project)

return render_to_response(
'projects/project_subprojects.html',
{'form': form, 'project': project, 'subprojects': subprojects},
context_instance=RequestContext(request)
)
def get_form(self, data=None, files=None, **kwargs):
kwargs['user'] = self.request.user
return super(ProjectRelationshipMixin, self).get_form(data, files, **kwargs)

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

@login_required
def project_subprojects_delete(request, project_slug, child_slug):
parent = get_object_or_404(Project.objects.for_admin_user(request.user), slug=project_slug)
child = get_object_or_404(Project.objects.all(), slug=child_slug)
parent.remove_subproject(child)
broadcast(type='app', task=tasks.symlink_subproject, args=[parent.pk])
return HttpResponseRedirect(reverse('projects_subprojects',
args=[parent.slug]))
def get_success_url(self):
return reverse('projects_subprojects', args=[self.get_project().slug])


class ProjectRelationshipList(ProjectRelationshipMixin, ListView):

def get_context_data(self, **kwargs):
ctx = super(ProjectRelationshipList, self).get_context_data(**kwargs)
ctx['superproject'] = self.project.superprojects.first()
return ctx


class ProjectRelationshipCreate(ProjectRelationshipMixin, CreateView):
pass


class ProjectRelationshipUpdate(ProjectRelationshipMixin, UpdateView):
pass


class ProjectRelationshipDelete(ProjectRelationshipMixin, DeleteView):

def get(self, request, *args, **kwargs):
return self.http_method_not_allowed(request, *args, **kwargs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not already defined on the DeleteView? Seems odd it wouldn't disallow GET's already.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, get on deleteview displays a form that asks for confirmation. I'd like to settle on a deleteview override and never use confirmation forms, but we do use them in some places currently.



@login_required
Expand Down
3 changes: 3 additions & 0 deletions readthedocs/rtd_tests/tests/test_privacy_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def setUp(self):
self.domain = get(Domain, url='http://docs.foobar.com', project=self.pip)
self.default_kwargs = {
'project_slug': self.pip.slug,
'subproject_slug': self.subproject.slug,
'version_slug': self.pip.versions.all()[0].slug,
'filename': 'index.html',
'type_': 'pdf',
Expand Down Expand Up @@ -227,6 +228,7 @@ class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase):
'/dashboard/pip/users/delete/': {'status_code': 405},
'/dashboard/pip/notifications/delete/': {'status_code': 405},
'/dashboard/pip/redirects/delete/': {'status_code': 405},
'/dashboard/pip/subprojects/sub/delete/': {'status_code': 405},
'/dashboard/pip/integrations/sync/': {'status_code': 405},
'/dashboard/pip/integrations/1/sync/': {'status_code': 405},
'/dashboard/pip/integrations/1/delete/': {'status_code': 405},
Expand Down Expand Up @@ -255,6 +257,7 @@ class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase):
'/dashboard/pip/users/delete/': {'status_code': 405},
'/dashboard/pip/notifications/delete/': {'status_code': 405},
'/dashboard/pip/redirects/delete/': {'status_code': 405},
'/dashboard/pip/subprojects/sub/delete/': {'status_code': 405},
'/dashboard/pip/integrations/sync/': {'status_code': 405},
'/dashboard/pip/integrations/1/sync/': {'status_code': 405},
'/dashboard/pip/integrations/1/delete/': {'status_code': 405},
Expand Down
15 changes: 15 additions & 0 deletions readthedocs/rtd_tests/tests/test_project_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,21 @@ def test_delete_project(self):
task=tasks.remove_dir,
args=[project.doc_path])

def test_subproject_create(self):
project = get(Project, slug='pip', users=[self.user])
subproject = get(Project, users=[self.user])

with patch('readthedocs.projects.views.private.broadcast') as broadcast:
response = self.client.post(
'/dashboard/pip/subprojects/create/',
data={'child': subproject.pk},
)
self.assertEqual(response.status_code, 302)
broadcast.assert_called_with(
type='app',
task=tasks.symlink_subproject,
args=[project.pk])


class TestPrivateMixins(MockBuildTestCase):

Expand Down
Loading