general: add "sub slug" to create variable pages (#23535)
This commit is contained in:
parent
d349635d07
commit
cae8d0cda5
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.12 on 2018-08-08 18:30
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('data', '0035_page_related_cells'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='sub_slug',
|
||||
field=models.CharField(blank=True, max_length=150, verbose_name='Sub Slug'),
|
||||
),
|
||||
]
|
|
@ -124,6 +124,10 @@ class Page(models.Model):
|
|||
|
||||
title = models.CharField(_('Title'), max_length=150)
|
||||
slug = models.SlugField(_('Slug'))
|
||||
sub_slug = models.CharField(_('Sub Slug'), max_length=150, blank=True,
|
||||
help_text=_('Regular expression to create variadic subpages. '
|
||||
'Matching named groups are exposed as context variables.'
|
||||
))
|
||||
description = models.TextField(_('Description'), blank=True)
|
||||
template_name = models.CharField(_('Template'), max_length=50)
|
||||
parent = models.ForeignKey('self', null=True, blank=True)
|
||||
|
|
|
@ -32,7 +32,7 @@ class PageEditTitleForm(forms.ModelForm):
|
|||
class PageEditSlugForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = ('slug',)
|
||||
fields = ('slug', 'sub_slug')
|
||||
|
||||
def clean_slug(self):
|
||||
value = self.cleaned_data.get('slug')
|
||||
|
|
|
@ -372,3 +372,7 @@ span.error {
|
|||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#id_sub_slug + span.helptext {
|
||||
max-width: 35rem;
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
<p>
|
||||
<label>{% trans 'Slug:' %}</label>
|
||||
<tt>{{ object.slug }}</tt>
|
||||
<tt>{{ object.slug }}{% if object.sub_slug %}/{{ object.sub_slug }}{% endif %}</tt>
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-slug' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,8 +1,18 @@
|
|||
function combo_load_cell(elem) {
|
||||
var $elem = $(elem);
|
||||
var url = $elem.data('ajax-cell-url');
|
||||
var extra_context = $elem.data('extra-context');
|
||||
$.support.cors = true; /* IE9 */
|
||||
$.ajax({url: url + window.location.search,
|
||||
var qs;
|
||||
if (window.location.search) {
|
||||
qs = window.location.search + '&';
|
||||
} else {
|
||||
qs = '?';
|
||||
}
|
||||
if (extra_context) {
|
||||
qs += 'ctx=' + extra_context;
|
||||
}
|
||||
$.ajax({url: url + qs,
|
||||
xhrFields: { withCredentials: true },
|
||||
async: true,
|
||||
dataType: 'html',
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
{% if cell.slug %}id="{{ cell.slug }}"{% endif %}
|
||||
data-ajax-cell-url="{{ site_base }}{% url 'combo-public-ajax-page-cell' page_pk=cell.page.id cell_reference=cell.get_reference %}"
|
||||
data-ajax-cell-loading-message="{{ cell.loading_message }}"
|
||||
{% if cell.ajax_refresh %}
|
||||
data-ajax-cell-refresh="{{ cell.ajax_refresh }}"
|
||||
{% endif %}><div>{% render_cell cell %}</div></div>
|
||||
{% if cell.ajax_refresh %}data-ajax-cell-refresh="{{ cell.ajax_refresh }}"{% endif %}
|
||||
{% if request.extra_context_data %}data-extra-context="{{ request.extra_context_data|signed|urlencode }}"{% endif %}
|
||||
><div>{% render_cell cell %}</div></div>
|
||||
{% endfor %}
|
||||
{% if render_skeleton %}
|
||||
{{ skeleton }}
|
||||
|
|
|
@ -19,6 +19,7 @@ from __future__ import absolute_import
|
|||
import datetime
|
||||
|
||||
from django import template
|
||||
from django.core import signing
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.template.base import TOKEN_BLOCK, TOKEN_VAR
|
||||
from django.template.defaultfilters import stringfilter
|
||||
|
@ -224,3 +225,7 @@ def is_empty_placeholder(page, placeholder_name):
|
|||
@register.filter(name='list')
|
||||
def as_list(obj):
|
||||
return list(obj)
|
||||
|
||||
@register.filter
|
||||
def signed(obj):
|
||||
return signing.dumps(obj)
|
||||
|
|
|
@ -15,12 +15,15 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import signing
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.http import (Http404, HttpResponse, HttpResponseRedirect,
|
||||
|
@ -42,8 +45,10 @@ from haystack.query import SearchQuerySet, SQ
|
|||
|
||||
if 'mellon' in settings.INSTALLED_APPS:
|
||||
from mellon.utils import get_idps
|
||||
from mellon.models import UserSAMLIdentifier
|
||||
else:
|
||||
get_idps = lambda: []
|
||||
UserSAMLIdentifier = None
|
||||
|
||||
from combo.data.models import (CellBase, PostException, Page, Redirect,
|
||||
ParentContentCell, TextCell, PageSnapshot)
|
||||
|
@ -70,6 +75,18 @@ def logout(request, next_page=None):
|
|||
next_page = '/'
|
||||
return HttpResponseRedirect(next_page)
|
||||
|
||||
def modify_global_context(request, ctx):
|
||||
if 'user_id' in ctx:
|
||||
try:
|
||||
ctx['selected_user'] = User.objects.get(id=ctx['user_id'])
|
||||
except (User.DoesNotExist, ValueError):
|
||||
pass
|
||||
if 'name_id' in ctx and UserSAMLIdentifier:
|
||||
try:
|
||||
ctx['selected_user'] = UserSAMLIdentifier.objects.get(name_id=ctx['name_id']).user
|
||||
except UserSAMLIdentifier.DoesNotExist:
|
||||
pass
|
||||
|
||||
@csrf_exempt
|
||||
def ajax_page_cell(request, page_pk, cell_reference):
|
||||
try:
|
||||
|
@ -121,6 +138,9 @@ def render_cell(request, cell):
|
|||
'synchronous': True,
|
||||
'site_base': request.build_absolute_uri('/')[:-1],
|
||||
}
|
||||
if request.GET.get('ctx'):
|
||||
context.update(signing.loads(request.GET['ctx']))
|
||||
modify_global_context(request, context)
|
||||
|
||||
if cell.page_id:
|
||||
other_cells = []
|
||||
|
@ -327,6 +347,7 @@ def empty_site(request):
|
|||
|
||||
|
||||
def page(request):
|
||||
request.extra_context_data = {}
|
||||
url = request.path_info
|
||||
parts = [x for x in request.path_info.strip('/').split('/') if x]
|
||||
if len(parts) == 1 and parts[0] == 'index':
|
||||
|
@ -350,30 +371,49 @@ def page(request):
|
|||
request.session['visited'] = True
|
||||
return HttpResponseRedirect(settings.COMBO_WELCOME_PAGE_PATH)
|
||||
|
||||
slugs = {'parent__'*len(parts) + 'isnull': True}
|
||||
for i, part in enumerate(reversed(parts)):
|
||||
slugs['parent__'*i + 'slug'] = part
|
||||
try:
|
||||
page = Page.objects.get(**slugs)
|
||||
except Page.DoesNotExist:
|
||||
if Page.objects.count() == 0 and parts == ['index']:
|
||||
return empty_site(request)
|
||||
# maybe the page is a children of /index/, as /index/ is silent the
|
||||
# page would appear directly under /; this is not a suggested practice.
|
||||
parts = ['index'] + parts
|
||||
slugs = {'parent__'*len(parts) + 'isnull': True}
|
||||
for i, part in enumerate(reversed(parts)):
|
||||
slugs['parent__'*i + 'slug'] = part
|
||||
try:
|
||||
page = Page.objects.get(**slugs)
|
||||
except Page.DoesNotExist:
|
||||
page = None
|
||||
pages = {x.slug: x for x in Page.objects.filter(slug__in=parts)}
|
||||
if pages == {} and parts == ['index'] and Page.objects.count() == 0:
|
||||
return empty_site(request)
|
||||
|
||||
if page is None or page.get_online_url() != url:
|
||||
if not url.endswith('/') and settings.APPEND_SLASH:
|
||||
# this is useful to allow /login, /manage, and other non-page
|
||||
# URLs to work.
|
||||
return HttpResponsePermanentRedirect(url + '/')
|
||||
i = 0
|
||||
hierarchy_ids = [None]
|
||||
while i < len(parts):
|
||||
try:
|
||||
page = pages[parts[i]]
|
||||
except KeyError:
|
||||
page = None
|
||||
break
|
||||
if page.parent_id != hierarchy_ids[-1]:
|
||||
if i == 0:
|
||||
# root page should be at root but maybe the page is a child of
|
||||
# /index/, and as /index/ is silent the page would appear
|
||||
# directly under /; this is not a suggested practice.
|
||||
if page.parent.slug != 'index' and page.parent.parent_id is not None:
|
||||
page = None
|
||||
break
|
||||
else:
|
||||
page = None
|
||||
break
|
||||
if page.sub_slug:
|
||||
if parts[i+1:] == []:
|
||||
# a sub slug is expected but was not found; redirect to parent
|
||||
# page as a mitigation.
|
||||
return HttpResponseRedirect('..')
|
||||
match = re.match('^' + page.sub_slug + '$', parts[i+1])
|
||||
if match is None:
|
||||
page = None
|
||||
break
|
||||
request.extra_context_data.update(match.groupdict())
|
||||
parts = parts[:i+1] + parts[i+2:] # skip variable component
|
||||
i += 1
|
||||
hierarchy_ids.append(page.id)
|
||||
|
||||
if not url.endswith('/') and settings.APPEND_SLASH:
|
||||
# this is useful to allow /login, /manage, and other non-page
|
||||
# URLs to work.
|
||||
return HttpResponsePermanentRedirect(url + '/')
|
||||
|
||||
if page is None:
|
||||
redirect = Redirect.objects.filter(old_url=url).last()
|
||||
if redirect:
|
||||
return HttpResponseRedirect(redirect.page.get_online_url())
|
||||
|
@ -405,6 +445,8 @@ def publish_page(request, page, status=200, template_name=None):
|
|||
'request': request,
|
||||
'media': sum((cell.media for cell in cells), Media())
|
||||
}
|
||||
ctx.update(getattr(request, 'extra_context_data', {}))
|
||||
modify_global_context(request, ctx)
|
||||
|
||||
for cell in cells:
|
||||
if cell.modify_global_context:
|
||||
|
|
|
@ -4,9 +4,11 @@ from webtest import TestApp
|
|||
import datetime
|
||||
import json
|
||||
import pytest
|
||||
import re
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import connection
|
||||
from django.utils.http import quote
|
||||
|
@ -16,7 +18,7 @@ from django.test.utils import CaptureQueriesContext
|
|||
|
||||
from combo.wsgi import application
|
||||
from combo.data.models import (Page, CellBase, TextCell, ParentContentCell,
|
||||
FeedCell, LinkCell, ConfigJsonCell, Redirect)
|
||||
FeedCell, LinkCell, ConfigJsonCell, Redirect, JsonCell)
|
||||
from combo.apps.family.models import FamilyInfosCell
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
@ -628,3 +630,85 @@ def test_redirects(app):
|
|||
page3.save()
|
||||
assert urlparse.urlparse(app.get('/second/third/', status=302).location).path == '/third2/'
|
||||
assert urlparse.urlparse(app.get('/second2/third2/', status=302).location).path == '/third2/'
|
||||
|
||||
def test_sub_slug(app, john_doe, jane_doe):
|
||||
Page.objects.all().delete()
|
||||
page = Page(title='Home', slug='index', template_name='standard')
|
||||
page.save()
|
||||
|
||||
page2 = Page(title='User', slug='users', sub_slug='(?P<blah>[a-z]+)', template_name='standard')
|
||||
page2.save()
|
||||
|
||||
page3 = Page(title='Blah', slug='blah', parent=page2, template_name='standard')
|
||||
page3.save()
|
||||
|
||||
# without passing sub slug
|
||||
assert app.get('/users/', status=302).location in ('..', 'http://testserver/')
|
||||
# (result vary between django versions)
|
||||
|
||||
# json cell so we can display the parameter value
|
||||
cell = JsonCell(page=page2, url='http://example.net', order=0, placeholder='content')
|
||||
cell.template_string = 'XX{{ blah }}YY'
|
||||
cell.save()
|
||||
|
||||
cell2 = JsonCell(page=page3, url='http://example.net', order=0, placeholder='content')
|
||||
cell2.template_string = 'AA{{ blah }}BB'
|
||||
cell2.save()
|
||||
|
||||
with mock.patch('combo.utils.requests.get') as requests_get:
|
||||
data = {'data': [{'url': 'http://a.b', 'text': 'xxx'}]}
|
||||
requests_get.return_value = mock.Mock(content=json.dumps(data), status_code=200)
|
||||
resp = app.get('/users/whatever/', status=200)
|
||||
assert 'XXwhateverYY' in resp.text
|
||||
cell_url = re.findall(r'data-ajax-cell-url="(.*)"', resp.text)[0]
|
||||
extra_ctx = re.findall(r'data-extra-context="(.*)"', resp.text)[0]
|
||||
resp = app.get(cell_url + '?ctx=' + extra_ctx)
|
||||
assert resp.text == 'XXwhateverYY'
|
||||
|
||||
# 404 on value that doesn't match the regex
|
||||
resp = app.get('/users/WHATEVER/', status=404)
|
||||
|
||||
# check sub page
|
||||
resp = app.get('/users/whatever/plop/', status=404)
|
||||
resp = app.get('/users/whatever/blah/', status=200)
|
||||
assert 'AAwhateverBB' in resp.text
|
||||
|
||||
# custom behaviour for <user_id>, it will add the user to context
|
||||
page2.sub_slug = '(?P<user_id>[0-9]+)'
|
||||
page2.save()
|
||||
|
||||
cell.template_string = 'XX{{ selected_user.username }}YY'
|
||||
cell.save()
|
||||
|
||||
resp = app.get('/users/%s/' % john_doe.id, status=200)
|
||||
assert 'XXjohn.doeYY' in resp.text
|
||||
|
||||
# bad user id => no selected_user
|
||||
page2.sub_slug = '(?P<user_id>[0-9a-z]+)'
|
||||
page2.save()
|
||||
resp = app.get('/users/9999999/', status=200)
|
||||
assert 'XXYY' in resp.text
|
||||
resp = app.get('/users/abc/', status=200)
|
||||
assert 'XXYY' in resp.text
|
||||
|
||||
# custom behaviour for <name_id>, it will add the SAML user to context
|
||||
with mock.patch('combo.public.views.UserSAMLIdentifier') as user_saml:
|
||||
class DoesNotExist(Exception):
|
||||
pass
|
||||
user_saml.DoesNotExist = DoesNotExist
|
||||
def side_effect(*args, **kwargs):
|
||||
name_id = kwargs['name_id']
|
||||
if name_id == 'foo':
|
||||
raise user_saml.DoesNotExist
|
||||
return mock.Mock(user=john_doe)
|
||||
mocked_objects = mock.Mock()
|
||||
mocked_objects.get = mock.Mock(side_effect=side_effect)
|
||||
user_saml.objects = mocked_objects
|
||||
|
||||
page2.sub_slug = '(?P<name_id>[0-9a-z.]+)'
|
||||
page2.save()
|
||||
resp = app.get('/users/john.doe/', status=200)
|
||||
assert 'XXjohn.doeYY' in resp.text
|
||||
# unknown name id => no selected_user
|
||||
resp = app.get('/users/foo/', status=200)
|
||||
assert 'XXYY' in resp.text
|
||||
|
|
Loading…
Reference in New Issue