general: add "sub slug" to create variable pages (#23535)

This commit is contained in:
Frédéric Péters 2018-08-08 22:18:36 +02:00
parent d349635d07
commit cae8d0cda5
10 changed files with 199 additions and 30 deletions

View File

@ -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'),
),
]

View File

@ -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)

View File

@ -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')

View File

@ -372,3 +372,7 @@ span.error {
color: red;
font-weight: bold;
}
#id_sub_slug + span.helptext {
max-width: 35rem;
}

View File

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

View File

@ -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',

View File

@ -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 }}

View File

@ -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)

View File

@ -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:

View File

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