New subproject admin page (#2957)

* Remove privacy app

The privacy app was a strange mixture of various application models managers,
querysets, and syncers. Instead, logic is moved to where we should be using it,
inside the other applications

* 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.

* Lint fixes

* Require arguments to project form
project-feature-flip-apiv2
Anthony 2017-06-22 14:56:17 -07:00 committed by GitHub
parent a828543dce
commit 2d4e004bee
11 changed files with 396 additions and 183 deletions

View File

@ -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 */

View File

@ -21,7 +21,8 @@ from readthedocs.integrations.models import Integration
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
@ -236,6 +237,46 @@ class UpdateProjectForm(ProjectTriggerBuildMixin, ProjectBasicsForm,
)
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')
self.user = kwargs.pop('user')
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"""
@ -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"""

View File

@ -64,14 +64,6 @@ urlpatterns = [
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'),
@ -165,3 +157,25 @@ integration_urls = [
]
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

View File

@ -28,12 +28,13 @@ from readthedocs.core.utils import trigger_build, broadcast
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
@ -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(
@ -407,44 +408,50 @@ class AliasList(PrivateViewMixin, ListView):
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)
def get_success_url(self):
return reverse('projects_subprojects', args=[self.get_project().slug])
@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]))
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)
@login_required

View File

@ -142,6 +142,7 @@ class ProjectMixin(URLAccessMixin):
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',
@ -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},
@ -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},

View File

@ -381,6 +381,21 @@ class TestPrivateViews(MockBuildTestCase):
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):

View File

@ -1,66 +1,147 @@
from __future__ import absolute_import
import mock
import mock
import django_dynamic_fixture as fixture
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.utils import override_settings
from django.contrib.auth.models import User
from readthedocs.projects.forms import SubprojectForm
from readthedocs.projects.models import Project
from readthedocs.projects.forms import ProjectRelationshipForm
from readthedocs.projects.models import Project, ProjectRelationship
from readthedocs.rtd_tests.utils import create_user
from django_dynamic_fixture import get
class SubprojectFormTests(TestCase):
def test_name_validation(self):
user = get(User)
project = get(Project, slug='mainproject')
form = SubprojectForm({},
parent=project, user=user)
def test_empty_child(self):
user = fixture.get(User)
project = fixture.get(Project, slug='mainproject')
form = ProjectRelationshipForm(
{},
project=project,
user=user
)
form.full_clean()
self.assertTrue('subproject' in form.errors)
self.assertEqual(len(form.errors['child']), 1)
self.assertRegexpMatches(
form.errors['child'][0],
r'This field is required.'
)
form = SubprojectForm({'name': 'not-existent'},
parent=project, user=user)
def test_nonexistent_child(self):
user = fixture.get(User)
project = fixture.get(Project, slug='mainproject')
self.assertFalse(Project.objects.filter(pk=9999).exists())
form = ProjectRelationshipForm(
{'child': 9999},
project=project,
user=user
)
form.full_clean()
self.assertTrue('subproject' in form.errors)
self.assertEqual(len(form.errors['child']), 1)
self.assertRegexpMatches(
form.errors['child'][0],
r'Select a valid choice.'
)
def test_adding_subproject_fails_when_user_is_not_admin(self):
# Make sure that a user cannot add a subproject that he is not the
# admin of.
user = get(User)
project = get(Project, slug='mainproject')
user = fixture.get(User)
project = fixture.get(Project, slug='mainproject')
project.users.add(user)
subproject = get(Project, slug='subproject')
form = SubprojectForm({'subproject': subproject.slug},
parent=project, user=user)
# Fails because user does not own subproject.
subproject = fixture.get(Project, slug='subproject')
self.assertQuerysetEqual(
Project.objects.for_admin_user(user),
[project],
transform=lambda n: n,
)
form = ProjectRelationshipForm(
{'child': subproject.pk},
project=project,
user=user
)
form.full_clean()
self.assertTrue('subproject' in form.errors)
self.assertEqual(len(form.errors['child']), 1)
self.assertRegexpMatches(
form.errors['child'][0],
r'Select a valid choice.'
)
def test_admin_of_subproject_can_add_it(self):
user = get(User)
project = get(Project, slug='mainproject')
def test_adding_subproject_passes_when_user_is_admin(self):
user = fixture.get(User)
project = fixture.get(Project, slug='mainproject')
project.users.add(user)
subproject = get(Project, slug='subproject')
subproject = fixture.get(Project, slug='subproject')
subproject.users.add(user)
# Works now as user is admin of subproject.
form = SubprojectForm({'subproject': subproject.slug},
parent=project, user=user)
# Fails because user does not own subproject.
self.assertQuerysetEqual(
Project.objects.for_admin_user(user),
[project, subproject],
transform=lambda n: n,
)
form = ProjectRelationshipForm(
{'child': subproject.pk},
project=project,
user=user
)
form.full_clean()
self.assertTrue(form.is_valid())
form.save()
self.assertEqual(
[r.child for r in project.subprojects.all()],
[subproject])
[subproject]
)
def test_subproject_form_cant_create_sub_sub_project(self):
user = fixture.get(User)
project = fixture.get(Project, users=[user])
subproject = fixture.get(Project, users=[user])
subsubproject = fixture.get(Project, users=[user])
relation = fixture.get(
ProjectRelationship, parent=project, child=subproject
)
self.assertQuerysetEqual(
Project.objects.for_admin_user(user),
[project, subproject, subsubproject],
transform=lambda n: n,
)
form = ProjectRelationshipForm(
{'child': subsubproject.pk},
project=subproject,
user=user
)
# The subsubproject is valid here, as far as the child check is
# concerned, but the parent check should fail.
self.assertEqual(
[proj_id for (proj_id, __) in form.fields['child'].choices],
['', subsubproject.pk],
)
form.full_clean()
self.assertEqual(len(form.errors['parent']), 1)
self.assertRegexpMatches(
form.errors['parent'][0],
r'Subproject nesting is not supported'
)
def test_excludes_existing_subprojects(self):
user = fixture.get(User)
project = fixture.get(Project, users=[user])
subproject = fixture.get(Project, users=[user])
relation = fixture.get(
ProjectRelationship, parent=project, child=subproject
)
self.assertQuerysetEqual(
Project.objects.for_admin_user(user),
[project, subproject],
transform=lambda n: n,
)
form = ProjectRelationshipForm(
{'child': subproject.pk},
project=project,
user=user
)
self.assertEqual(
[proj_id for (proj_id, __) in form.fields['child'].choices],
[''],
)
@override_settings(PUBLIC_DOMAIN='readthedocs.org')
@ -70,11 +151,13 @@ class ResolverBase(TestCase):
with mock.patch('readthedocs.projects.models.broadcast'):
self.owner = create_user(username='owner', password='test')
self.tester = create_user(username='tester', password='test')
self.pip = get(Project, slug='pip', users=[self.owner], main_language_project=None)
self.subproject = get(Project, slug='sub', language='ja', users=[
self.owner], main_language_project=None)
self.translation = get(Project, slug='trans', language='ja', users=[
self.owner], main_language_project=None)
self.pip = fixture.get(Project, slug='pip', users=[self.owner], main_language_project=None)
self.subproject = fixture.get(Project, slug='sub', language='ja',
users=[ self.owner],
main_language_project=None)
self.translation = fixture.get(Project, slug='trans', language='ja',
users=[ self.owner],
main_language_project=None)
self.pip.add_subproject(self.subproject)
self.pip.translations.add(self.translation)

View File

@ -113,8 +113,13 @@ class PrivateViewsAreProtectedTests(TestCase):
self.assertRedirectToLogin(response)
def test_subprojects_delete(self):
# This URL doesn't exist anymore, 404
response = self.client.get(
'/dashboard/pip/subprojects/delete/a-subproject/')
self.assertEqual(response.status_code, 404)
# New URL
response = self.client.get(
'/dashboard/pip/subprojects/a-subproject/delete/')
self.assertRedirectToLogin(response)
def test_subprojects(self):
@ -213,7 +218,18 @@ class SubprojectViewTests(TestCase):
self.project.users.add(self.user)
self.subproject.users.add(self.user)
response = self.client.get('/dashboard/my-mainproject/subprojects/delete/my-subproject/')
# URL doesn't exist anymore, 404
response = self.client.get(
'/dashboard/my-mainproject/subprojects/delete/my-subproject/')
self.assertEqual(response.status_code, 404)
# This URL still doesn't accept GET, 405
response = self.client.get(
'/dashboard/my-mainproject/subprojects/my-subproject/delete/')
self.assertEqual(response.status_code, 405)
self.assertTrue(self.subproject in [r.child for r in self.project.subprojects.all()])
# Test POST
response = self.client.post(
'/dashboard/my-mainproject/subprojects/my-subproject/delete/')
self.assertEqual(response.status_code, 302)
self.assertTrue(self.subproject not in [r.child for r in self.project.subprojects.all()])
@ -221,6 +237,7 @@ class SubprojectViewTests(TestCase):
self.project.users.add(self.user)
self.assertFalse(AdminPermission.is_admin(self.user, self.subproject))
response = self.client.get('/dashboard/my-mainproject/subprojects/delete/my-subproject/')
response = self.client.post(
'/dashboard/my-mainproject/subprojects/my-subproject/delete/')
self.assertEqual(response.status_code, 302)
self.assertTrue(self.subproject not in [r.child for r in self.project.subprojects.all()])

View File

@ -1,54 +0,0 @@
{% extends "projects/project_edit_base.html" %}
{% load i18n %}
{% block title %}{% trans "Edit Subprojects" %}{% endblock %}
{% block nav-dashboard %} class="active"{% endblock %}
{% block editing-option-edit-proj %}class="active"{% endblock %}
{% block project-subprojects-active %}active{% endblock %}
{% block project_edit_content_header %}{% trans "Subprojects" %}{% endblock %}
{% block project_edit_content %}
<p class="help_text">
{% trans "This allows you to add subprojects to your project. This allows them to live in the same namespace in the URLConf for a subdomain or CNAME." %}
</p>
<h3> {% trans "Existing Subprojects" %} </h3>
<p>
<ul>
{% for relationship in subprojects %}
<li>
<a href="{{ relationship.get_absolute_url }}">
{{ relationship.child }}
</a>
(/projects/{% if relationship.alias %}{{ relationship.alias }}{% else %}{{ relationship.child.slug }}{%endif %}/)
(<a href="{% url "projects_subprojects_delete" relationship.parent.slug relationship.child.slug %}">{% trans "Remove" %}</a>)
</li>
{% endfor %}
</ul>
<p>
{% trans "Choose which project you would like to add as a subproject." %}
</p>
<form method="post" action=".">{% csrf_token %}
{{ form.as_p }}
<p>
<input style="display: inline;" type="submit" value="{% trans "Submit" %}">
</p>
</form>
{% endblock %}
{% block footerjs %}
$('#id_subproject').autocomplete({
source: '{% url "search_autocomplete" %}',
minLength: 2,
open: function(event, ui) {
ac_top = $('.ui-autocomplete').css('top');
$('.ui-autocomplete').css({'width': '233px', 'top': ac_top + 10 });
}
});
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "projects/project_edit_base.html" %}
{% load i18n %}
{% block title %}{% trans "Subprojects" %}{% endblock %}
{% block nav-dashboard %} class="active"{% endblock %}
{% block editing-option-edit-proj %}class="active"{% endblock %}
{% block project-subprojects-active %}active{% endblock %}
{% block project_edit_content_header %}{% trans "Subprojects" %}{% endblock %}
{% block project_edit_content %}
{% if object %}
<form
method="post"
class="form-wide"
action="{% url 'projects_subprojects_update' project_slug=project.slug subproject_slug=object.child.slug %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="{% trans "Update subproject" %}">
</form>
<form
method="post"
action="{% url 'projects_subprojects_delete' project_slug=project.slug subproject_slug=object.child.slug %}">
{% csrf_token %}
<input type="submit" value="{% trans "Delete subproject" %}">
</form>
{% else %}
<form
method="post"
class="form-wide"
action="{% url 'projects_subprojects_create' project_slug=project.slug %}">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="{% trans "Add subproject" %}">
</form>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "projects/project_edit_base.html" %}
{% load i18n %}
{% block title %}{% trans "Subprojects" %}{% endblock %}
{% block nav-dashboard %} class="active"{% endblock %}
{% block editing-option-edit-proj %}class="active"{% endblock %}
{% block project-subprojects-active %}active{% endblock %}
{% block project_edit_content_header %}{% trans "Subprojects" %}{% endblock %}
{% block project_edit_content %}
<p>
{% blocktrans trimmed %}
Subprojects are projects hosted from the same URL as their parent project.
This is useful for organizing multiple projects under a custom domain.
{% endblocktrans %}
</p>
{% if superproject %}
<p>
{% blocktrans trimmed with project=superproject.parent.name %}
This project is already configured as a subproject of {{ project }}.
Nested subprojects are not currently supported.
{% endblocktrans %}
</p>
<a href="{% url 'projects_subprojects' project_slug=superproject.parent.slug %}">
{% blocktrans trimmed with project=superproject.parent.name %}
View subprojects of {{ project }}
{% endblocktrans %}
</a>
{% else %}
<div class="button-bar">
<ul>
<li>
<a class="button"
href="{% url 'projects_subprojects_create' project_slug=project.slug %}">
{% trans "Add subproject" %}
</a>
</li>
</ul>
</div>
<div class="module project-subprojects">
<div class="module-list-wrapper">
<ul>
{% for subproject in object_list %}
<li class="module-item">
<div class="subproject-name">
<a href="{% url 'projects_subprojects_update' project_slug=project.slug subproject_slug=subproject.child.slug %}">
{{ subproject.child }}
</a>
</div>
<div class="subproject-meta">
<a href="{% url 'projects_manage' project_slug=subproject.child.slug %}">
{% trans "Project page" %}</a>
&nbsp;&dash;&nbsp;
<a href="{{ subproject.get_absolute_url }}">
{{ subproject.get_absolute_url }}
</a>
</div>
</li>
{% empty %}
<li class="module-item">
<p class="quiet">
{% trans 'No subprojects are currently configured' %}
</p>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endblock %}