Projet

Général

Profil

0002-search-search-on-page-and-subpages-40224.patch

Lauréline Guérin, 17 avril 2020 11:33

Télécharger (22,5 ko)

Voir les différences:

Subject: [PATCH 2/2] search: search on page and subpages (#40224)

 combo/apps/search/forms.py                    | 53 ++++++++++++
 combo/apps/search/manager_views.py            | 41 ++++++---
 combo/apps/search/models.py                   | 50 +++++++++--
 .../combo/manager/add-engine-form.html        | 18 ++++
 .../combo/manager/search-cell-form.html       | 10 +--
 .../templates/combo/search-cell-results.html  |  2 +-
 combo/apps/search/utils.py                    |  7 +-
 tests/test_search.py                          | 86 ++++++++++++++++++-
 8 files changed, 243 insertions(+), 24 deletions(-)
 create mode 100644 combo/apps/search/templates/combo/manager/add-engine-form.html
combo/apps/search/forms.py
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
from django import forms
18
from django.utils.translation import ugettext_lazy as _
18 19

  
20
from combo.data.models import Page
19 21
from .models import SearchCell
20 22

  
21 23

  
......
23 25
    class Meta:
24 26
        model = SearchCell
25 27
        fields = ('autofocus', 'input_placeholder')
28

  
29

  
30
class SelectWithDisabled(forms.Select):
31
    """
32
    Subclass of Django's select widget that allows disabling options.
33
    To disable an option, pass a dict instead of a string for its label,
34
    of the form: {'label': 'option label', 'disabled': True}
35
    """
36

  
37
    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
38
        disabled = False
39
        if isinstance(label, dict):
40
            label, disabled = label['label'], label['disabled']
41
        option_dict = super().create_option(name, value, label, selected, index, subindex=subindex, attrs=attrs)
42
        if disabled:
43
            option_dict['attrs']['disabled'] = 'disabled'
44
        return option_dict
45

  
46

  
47
class SelectPageForm(forms.ModelForm):
48
    selected_page = forms.ModelChoiceField(
49
        label=_('Page'),
50
        required=False,
51
        queryset=Page.objects.none(),
52
        help_text=_("Select a page to limit the search on this page and sub pages contents."),
53
        widget=SelectWithDisabled(),
54
    )
55

  
56
    class Meta:
57
        model = SearchCell
58
        fields = []
59

  
60
    def __init__(self, *args, **kwargs):
61
        super().__init__(*args, **kwargs)
62
        used_slugs = [
63
            e['slug'].replace('_text_page_', '') for e in self.instance.search_services
64
            if e['slug'].startswith('_text_page_')]
65
        pages_queryset = (
66
            Page.objects
67
            .filter(snapshot__isnull=True, sub_slug='')
68
            .order_by('title'))
69
        pages = Page.get_as_reordered_flat_hierarchy(pages_queryset)
70
        pages_choices = [('', '---------')] + [
71
            (x.id, {'label': '%s %s' % (u'\u00a0' * x.level * 2, x.title), 'disabled': x.slug in used_slugs})
72
            for x in pages]
73
        # if '_text' without page is already selected, page is required
74
        if any(e['slug'] == '_text' for e in self.instance.search_services):
75
            pages_choices.pop(0)
76
            self.fields['selected_page'].required = True
77
        self.fields['selected_page'].queryset = pages_queryset.exclude(slug__in=used_slugs)
78
        self.fields['selected_page'].choices = pages_choices
combo/apps/search/manager_views.py
17 17
from django.http import HttpResponse
18 18
from django.http import HttpResponseRedirect
19 19
from django.shortcuts import get_object_or_404
20
from django.shortcuts import render
20 21
from django.urls import reverse
21 22
from django.utils.translation import ugettext_lazy as _
22 23

  
24
from combo.apps.search.forms import SelectPageForm
23 25
from combo.apps.search.models import SearchCell
24 26
from combo.data.models import PageSnapshot
25 27

  
......
27 29
def page_search_cell_add_engine(request, page_pk, cell_reference, engine_slug):
28 30
    cell = get_object_or_404(SearchCell, pk=cell_reference.split('-')[1], page=page_pk)
29 31

  
30
    if engine_slug in cell.available_engines:
31
        if not cell._search_services or not cell._search_services.get('data'):
32
            cell._search_services = {'data': []}
33
        cell._search_services['data'].append(engine_slug)
34
        cell.save()
35
        PageSnapshot.take(cell.page, request=request, comment=_('changed cell "%s"') % cell)
36

  
37
    return HttpResponseRedirect('%s#cell-%s' % (
38
        reverse('combo-manager-page-view', kwargs={'pk': page_pk}),
39
        cell_reference))
32
    def add_slug(slug):
33
        if slug in cell.available_engines or slug.startswith('_text_page'):
34
            if not cell._search_services or not cell._search_services.get('data'):
35
                cell._search_services = {'data': []}
36
            cell._search_services['data'].append(slug)
37
            cell.save()
38
            PageSnapshot.take(cell.page, request=request, comment=_('changed cell "%s"') % cell)
39
        return HttpResponseRedirect('%s#cell-%s' % (
40
            reverse('combo-manager-page-view', kwargs={'pk': page_pk}),
41
            cell_reference))
42

  
43
    if engine_slug != '_text':
44
        # add engine without intermediary form and popup
45
        return add_slug(engine_slug)
46

  
47
    if request.method == 'POST':
48
        form = SelectPageForm(instance=cell, data=request.POST)
49
        if form.is_valid():
50
            slug = '_text'
51
            if form.cleaned_data['selected_page'] is not None:
52
                slug = '_text_page_%s' % form.cleaned_data['selected_page'].slug
53
            return add_slug(slug)
54
    else:
55
        form = SelectPageForm(instance=cell)
56
    context = {
57
        'form': form,
58
        'cell': cell,
59
    }
60
    return render(request, 'combo/manager/add-engine-form.html', context)
40 61

  
41 62

  
42 63
def page_search_cell_delete_engine(request, page_pk, cell_reference, engine_slug):
combo/apps/search/models.py
36 36
from . import engines
37 37

  
38 38

  
39
def get_root_page_and_children(service_slug):
40
    if not service_slug.startswith('_text_page_'):
41
        return
42
    page_slug = service_slug.replace('_text_page_', '')
43
    try:
44
        root_page = Page.objects.get(slug=page_slug, sub_slug='')
45
    except (Page.DoesNotExist, Page.MultipleObjectsReturned):
46
        return
47
    return root_page.get_descendants_and_me()
48

  
49

  
39 50
@register_cell_class
40 51
class SearchCell(CellBase):
41 52
    template_name = 'combo/search-cell.html'
......
69 80
        services = []
70 81
        for service_slug in self._search_services.get('data') or []:
71 82
            service = engines.get(service_slug)
83
            if service_slug.startswith('_text_page_'):
84
                service = engines.get('_text')
72 85
            if service and (service.get('url') or service.get('function')):
73 86
                service['slug'] = service_slug
74 87
                services.append(service)
......
77 90
    @cached_property
78 91
    def available_engines(self):
79 92
        all_engines = engines.get_engines()
80
        current_engines = [e['slug'] for e in self.search_services]
93
        # always remove _text engine: we can add search on page and sub pages
94
        current_engines = [e['slug'] for e in self.search_services if e['slug'] != '_text']
81 95
        return {k: v for k, v in all_engines.items() if k not in current_engines}
82 96

  
97
    def get_search_services_for_display(self):
98
        # get pages for _text engines
99
        page_slugs = [
100
            e['slug'].replace('_text_page_', '') for e in self.search_services
101
            if e['slug'].startswith('_text_page_')]
102
        pages = (
103
            Page.objects
104
            .filter(snapshot__isnull=True, sub_slug='', slug__in=page_slugs)
105
            .values('slug', 'title'))
106
        pages_by_slug = {'_text_page_%s' % p['slug']: p['title'] for p in pages}
107
        services = []
108
        for service in self.search_services:
109
            label = service['label']
110
            if service['slug'] in pages_by_slug:
111
                label = _('Page "%(page)s" and sub pages Contents') % {'page': pages_by_slug[service['slug']]}
112
            services.append((service['slug'], label))
113
        return services
114

  
83 115
    @property
84 116
    def has_multiple_search_services(self):
85 117
        return len(self._search_services.get('data') or []) > 1
......
125 157

  
126 158
        query = request.GET.get('q')
127 159

  
128
        def render_response(service={}, results={'err': 0, 'data': []}):
160
        def render_response(service=None, results=None, pages=None):
161
            service = service or {}
162
            results = results or {'err': 0, 'data': []}
129 163
            template_names = ['combo/search-cell-results.html']
130 164
            if cell.slug:
131 165
                template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug)
132 166
            tmpl = template.loader.select_template(template_names)
167
            service_label = service.get('label')
168
            if pages:
169
                service_label = _('Page "%(page)s" and sub pages Contents') % {'page': pages[0].title}
133 170
            context = {
134 171
                'cell': cell,
135 172
                'results': results,
136 173
                'search_service': service,
174
                'search_service_label': service_label,
137 175
                'query': query
138 176
            }
139 177
            return HttpResponse(tmpl.render(context, request), content_type='text/html')
......
147 185
        if not query:
148 186
            return render_response(service)
149 187

  
188
        pages = None
150 189
        if service.get('function'):  # internal search engine
151
            results = {'data': service['function'](request, query)}
190
            pages = get_root_page_and_children(service_slug)
191
            results = {'data': service['function'](request, query, pages=pages)}
152 192
        else:
153 193
            url = get_templated_url(service['url'],
154 194
                    context={'request': request, 'q': query, 'search_service': service})
......
189 229
                for k, v in hit_templates.items():
190 230
                    hit[k] = v.render(RequestContext(request, hit))
191 231

  
192
        return render_response(service, results)
232
        return render_response(service, results, pages=pages)
193 233

  
194 234
    def has_text_search_service(self):
195
        return '_text' in self._search_services.get('data', [])
235
        return any(key.startswith('_text') for key in self._search_services.get('data', []))
196 236

  
197 237
    def missing_index(self):
198 238
        return IndexedCell.objects.all().count() == 0
combo/apps/search/templates/combo/manager/add-engine-form.html
1
{% extends "combo/manager_base.html" %}
2
{% load i18n %}
3

  
4
{% block appbar %}
5
<h2>{% trans 'Add a "Page Contents" engine' %}</h2>
6
{% endblock %}
7

  
8
{% block content %}
9

  
10
<form method="post" enctype="multipart/form-data">
11
  {% csrf_token %}
12
  {{ form.as_p }}
13
  <div class="buttons">
14
    <button class="submit-button">{% trans "Save" %}</button>
15
    <a class="cancel" href="{% url 'combo-manager-page-view' pk=cell.page_id %}">{% trans 'Cancel' %}</a>
16
  </div>
17
</form>
18
{% endblock %}
combo/apps/search/templates/combo/manager/search-cell-form.html
13 13
</div>
14 14
{% endif %}
15 15
{{ form.as_p }}
16
{% with cell.search_services as engines %}
16
{% with cell.get_search_services_for_display as engines %}
17 17
{% if engines %}
18 18
<p><label>{% trans "Engines:" %}</label></p>
19 19
<div>
20 20
  <ul class="objects-list list-of-links" id="list-of-links-{{ cell.pk }}"
21 21
     data-link-list-order-url="{% url 'combo-manager-search-engines-order' page_pk=page.pk cell_reference=cell.get_reference %}">
22 22
    {% for engine in engines %}
23
    <li data-link-item-id="{{ engine.slug }}"><span class="handle">⣿</span>
24
      <span>{{ engine.label }}</span>
25
      <a title="{% trans "Delete" %}" class="link-action-icon delete" href="{% url 'combo-manager-page-search-cell-delete-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.slug %}">{% trans "Delete" %}</a>
23
    <li data-link-item-id="{{ engine.0 }}"><span class="handle">⣿</span>
24
      <span>{{ engine.1 }}</span>
25
      <a title="{% trans "Delete" %}" class="link-action-icon delete" href="{% url 'combo-manager-page-search-cell-delete-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.0 %}">{% trans "Delete" %}</a>
26 26
    </li>
27 27
    {% endfor %}
28 28
  </ul>
......
51 51
<div class="buttons">
52 52
  {% trans "Add an engine:" %}
53 53
  {% for key, engine in cell.available_engines.items %}
54
  <a href="{% url 'combo-manager-page-search-cell-add-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=key %}">{{ engine.label }}</a> {% if not forloop.last %}|{% endif %}
54
  <a {% if key == '_text' %}rel="popup"{% endif %} href="{% url 'combo-manager-page-search-cell-add-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=key %}">{{ engine.label }}</a> {% if not forloop.last %}|{% endif %}
55 55
  {% endfor %}
56 56
</div>
57 57
{% endif %}
combo/apps/search/templates/combo/search-cell-results.html
1 1
{% load i18n %}
2 2
{% if query %}
3
{% if cell.has_multiple_search_services %}<p class="search-service-label">{{ search_service.label }}</p>{% endif %}
3
{% if cell.has_multiple_search_services %}<p class="search-service-label">{{ search_service_label }}</p>{% endif %}
4 4
{% if results.data %}
5 5
<div class="links-list">
6 6
<ul>
combo/apps/search/utils.py
105 105
                    indexed_cell.save()
106 106

  
107 107

  
108
def search_site(request, query):
108
def search_site(request, query, pages=None):
109
    pages = pages or []
110

  
109 111
    if connection.vendor == 'postgresql':
110 112
        config = settings.POSTGRESQL_FTS_SEARCH_CONFIG
111 113
        vector = SearchVector('title', config=config, weight='A') + SearchVector('indexed_text', config=config, weight='A')
......
122 124
                Q(restricted_groups__in=request.user.groups.all()))
123 125
        qs = qs.exclude(excluded_groups__in=request.user.groups.all())
124 126

  
127
    if pages:
128
        qs = qs.filter(page__in=pages)
129

  
125 130
    hits = []
126 131
    seen = {}
127 132
    for hit in qs:
tests/test_search.py
15 15
from combo.apps.search.engines import engines
16 16
from combo.apps.search.models import SearchCell, IndexedCell
17 17
from combo.apps.search.utils import index_site, search_site
18
from combo.data.models import Page, JsonCell, TextCell, MenuCell, LinkCell
18
from combo.data.models import Page, JsonCell, TextCell, MenuCell, LinkCell, PageSnapshot
19 19

  
20 20
from .test_manager import login
21 21

  
......
314 314
    assert resp.text.count('<li') == 0
315 315

  
316 316

  
317
def test_search_on_root_page_api(app):
318
    # not indexed: with sub_slug
319
    page = Page.objects.create(title='example page', slug='example-page', sub_slug='foo')
320
    TextCell.objects.create(page=page, placeholder='content', text='<p>foobar baz</p>', order=0)
321

  
322
    second_page = Page.objects.create(title='second page', slug='second-page')
323
    TextCell.objects.create(page=second_page, placeholder='content', text='<p>other baz</p>', order=0)
324
    sub_second_page = Page.objects.create(parent=second_page, title='sub second page', slug='sub-second-page')
325
    TextCell.objects.create(page=sub_second_page, placeholder='content', text='<p>other baz</p>', order=0)
326
    # not indexed: with snapshot
327
    third_page = Page.objects.create(title='second page', slug='third-page')
328
    TextCell.objects.create(page=third_page, placeholder='content', text='<p>other baz again</p>', order=0)
329
    third_page.snapshot = PageSnapshot.objects.create(page=third_page)
330
    third_page.save()
331

  
332
    index_site()
333

  
334
    cell = SearchCell.objects.create(page=page, placeholder='content', _search_services={'data': ['_text']}, order=1)
335

  
336
    resp = app.get('/ajax/search/%s/_text/?q=baz' % cell.pk, status=200)
337
    assert resp.text.count('<li') == 2
338
    cell._search_services = {'data': ['_text_page_second-page']}
339
    cell.save()
340
    resp = app.get('/ajax/search/%s/_text_page_second-page/?q=baz' % cell.pk, status=200)
341
    assert resp.text.count('<li') == 2
342
    cell._search_services = {'data': ['_text_page_sub-second-page']}
343
    cell.save()
344
    resp = app.get('/ajax/search/%s/_text_page_sub-second-page/?q=baz' % cell.pk, status=200)
345
    assert resp.text.count('<li') == 1
346
    # invalid page, search everywhere
347
    # with sub_slug
348
    cell._search_services = {'data': ['_text_page_example-page']}
349
    cell.save()
350
    resp = app.get('/ajax/search/%s/_text_page_example-page/?q=baz' % cell.pk, status=200)
351
    assert resp.text.count('<li') == 2
352
    # with snapshot
353
    cell._search_services = {'data': ['_text_page_third-page']}
354
    cell.save()
355
    resp = app.get('/ajax/search/%s/_text_page_third-page/?q=baz' % cell.pk, status=200)
356
    assert resp.text.count('<li') == 2
357
    # page does not exists, search everywhere
358
    cell._search_services = {'data': ['_text_page_foo']}
359
    cell.save()
360
    resp = app.get('/ajax/search/%s/_text_page_foo/?q=baz' % cell.pk, status=200)
361
    assert resp.text.count('<li') == 2
362
    # slug is not unique, search everywhere
363
    page.slug = 'sub-second-page'
364
    page.sub_slug = ''
365
    page.save()
366
    cell._search_services = {'data': ['_text_page_sub-second-page']}
367
    cell.save()
368
    resp = app.get('/ajax/search/%s/_text_page_sub-second-page/?q=baz' % cell.pk, status=200)
369
    assert resp.text.count('<li') == 2
370

  
371

  
317 372
def test_search_external_links(app):
318 373
    page = Page(title='example page', slug='example-page')
319 374
    page.save()
......
374 429

  
375 430
    # add engines
376 431
    resp = resp.click(href='.*/search_searchcell-%s/engine/_text/add/' % cell.pk)
432
    resp = resp.form.submit('submit')
377 433
    assert resp.status_int == 302
378 434
    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
379 435
    resp = app.get('/manage/pages/%s/' % page.pk)
......
383 439
    cell.refresh_from_db()
384 440
    assert cell._search_services == {'data': ['_text', 'search1', 'search_tmpl']}
385 441
    resp = app.get('/manage/pages/%s/' % page.pk)
386
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/add/' % (page.pk, cell.pk) not in resp.text
442
    # '_text' is always available
443
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/add/' % (page.pk, cell.pk) in resp.text
387 444
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/search1/add/' % (page.pk, cell.pk) not in resp.text
388 445
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/search_tmpl/add/' % (page.pk, cell.pk) not in resp.text
389 446
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/search_alternate_key/add/' % (page.pk, cell.pk) in resp.text
......
409 466
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/search_tmpl/delete/' % (page.pk, cell.pk) not in resp.text
410 467
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/search_alternate_key/delete/' % (page.pk, cell.pk) not in resp.text
411 468

  
469
    # add engines on page and sub pages
470
    resp = resp.click(href='.*/search_searchcell-%s/engine/_text/add/' % cell.pk)
471
    assert list(resp.context['form']['selected_page'].field.queryset) == [page]
472
    resp = resp.form.submit('submit')
473
    assert resp.status_int == 302
474
    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
475
    cell.refresh_from_db()
476
    assert cell._search_services == {'data': ['search1', 'search_tmpl', '_text']}
477
    resp = app.get('/manage/pages/%s/' % page.pk)
478
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/add/' % (page.pk, cell.pk) in resp.text
479
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/delete/' % (page.pk, cell.pk) in resp.text
480
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text_page_one/delete/' % (page.pk, cell.pk) not in resp.text
481
    resp = resp.click(href='.*/search_searchcell-%s/engine/_text/add/' % cell.pk)
482
    resp.form['selected_page'] = page.pk
483
    resp = resp.form.submit('submit')
484
    assert resp.status_int == 302
485
    assert resp.location.endswith('/manage/pages/%s/#cell-%s' % (page.pk, cell.get_reference()))
486
    cell.refresh_from_db()
487
    assert cell._search_services == {'data': ['search1', 'search_tmpl', '_text', '_text_page_one']}
488
    resp = app.get('/manage/pages/%s/' % page.pk)
489
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/add/' % (page.pk, cell.pk) in resp.text
490
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text/delete/' % (page.pk, cell.pk) in resp.text
491
    assert '/manage/search/pages/%s/cell/search_searchcell-%s/engine/_text_page_one/delete/' % (page.pk, cell.pk) in resp.text
492

  
412 493

  
413 494
def test_manager_search_cell_order(settings, app, admin_user):
414 495
    settings.COMBO_SEARCH_SERVICES = SEARCH_SERVICES
......
437 518
    assert 'Content indexing has been scheduled' not in resp.text
438 519

  
439 520
    resp = resp.click(href='.*/search_searchcell-%s/engine/_text/add/' % cell.pk)
521
    resp = resp.form.submit('submit')
440 522
    resp = app.get('/manage/pages/%s/' % page.pk)
441 523
    assert 'Content indexing has been scheduled' in resp.text
442 524

  
443
-