Compare commits

...

6 Commits

34 changed files with 796 additions and 124 deletions

View File

@ -4,6 +4,8 @@ Configure search services in settings.COMBO_SEARCH_SERVICES:
'user': {
'label': 'Search a user',
'url': 'https://.../api/user/?q=%(q)s',
# 'cache_duration': 60, # in seconds, default is 0
# 'signature': True, # boolean, default is False
},
}

View File

@ -17,6 +17,9 @@
import django.apps
from django.utils.translation import ugettext_lazy as _
from .engines import engines
class AppConfig(django.apps.AppConfig):
name = 'combo.apps.search'
verbose_name = _('Search')

View File

@ -0,0 +1,43 @@
# combo - content management system
# Copyright (C) 2014-2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
class Engines(object):
"""Singleton object that serves as a registry of classes providing search
engines."""
def __init__(self):
self.engines = {}
def register(self, key, **options):
self.engines[key] = options
def get(self, key):
if key in settings.COMBO_SEARCH_SERVICES:
return settings.COMBO_SEARCH_SERVICES[key]
return self.engines.get(key)
def get_engines(self):
data = {}
for key in settings.COMBO_SEARCH_SERVICES:
data[key] = settings.COMBO_SEARCH_SERVICES[key]
for key in self.engines:
data[key] = self.engines[key]
return data
engines = Engines() # singleton object

View File

@ -0,0 +1,34 @@
# combo - content management system
# Copyright (C) 2014-2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from combo.utils.forms import MultiSortWidget
from . import engines
from .models import SearchCell
class SearchCellForm(forms.ModelForm):
class Meta:
model = SearchCell
fields = ('_search_services',)
def __init__(self, *args, **kwargs):
super(SearchCellForm, self).__init__(*args, **kwargs)
options = [(x, engines.get_engines()[x]['label']) for x in engines.get_engines().keys()]
self.fields['_search_services'].widget = MultiSortWidget(choices=options,
with_checkboxes=True)

View File

@ -30,7 +30,7 @@ class Command(UpdateIndexCommand):
dest='skip_external_links_collection')
def handle(self, **options):
if SearchCell.objects.filter(_search_service='_text').count() == 0:
if not any(SearchCell.get_cells_by_search_service('_text')):
# do not index site if there's no matching search cell
return
if not options.get('skip_external_links_collection', False):

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('search', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='searchcell',
name='_search_services',
field=jsonfield.fields.JSONField(default=dict,
verbose_name='Search Services',
blank=True),
),
]

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def create_search_services(apps, schema_editor):
SearchCell = apps.get_model('search', 'SearchCell')
for searchcell in SearchCell.objects.all():
if searchcell._search_service:
searchcell._search_services = {'data': [searchcell._search_service]}
else:
searchcell._search_services = {'data': []}
searchcell.save()
def back_to_search_service(apps, schema_editor):
SearchCell = apps.get_model('search', 'SearchCell')
for searchcell in SearchCell.objects.all():
if searchcell._search_services.get('data'):
searchcell._search_service = searchcell._search_services.get('data')[0]
else:
searchcell._search_service = ''
searchcell.save()
class Migration(migrations.Migration):
dependencies = [
('search', '0002_auto_20180720_1511'),
]
operations = [
migrations.RunPython(create_search_services, back_to_search_service),
]

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('search', '0003_create_search_services'),
]
operations = [
migrations.RemoveField(
model_name='searchcell',
name='_search_service',
),
]

View File

@ -15,43 +15,40 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django import template
from django.http import HttpResponse
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.forms import models as model_forms, Select
from django.utils.http import quote
from jsonfield import JSONField
from combo.utils import requests
from combo.data.models import CellBase
from combo.data.library import register_cell_class
from combo.utils import NothingInCacheException, get_templated_url
from combo.utils import get_templated_url
from . import engines
@register_cell_class
class SearchCell(CellBase):
template_name = 'combo/search-cell.html'
_search_service = models.CharField(verbose_name=_('Search Service'), max_length=64)
_search_services = JSONField(_('Search Services'), default=dict, blank=True)
class Meta:
verbose_name = _('Search')
def is_visible(self, user=None):
if not self.search_service:
if not self.search_services:
return False
return super(SearchCell, self).is_visible(user=user)
def get_default_form_class(self):
search_services = [(None, _('Not configured'))]
search_services.append(('_text', _('Page Contents')))
search_services.extend([(code, service['label'])
for code, service in settings.COMBO_SEARCH_SERVICES.items()])
widgets = {'_search_service': Select(choices=search_services)}
return model_forms.modelform_factory(self.__class__,
fields=['_search_service'],
widgets=widgets)
from .forms import SearchCellForm
return SearchCellForm
@property
def varname(self):
@ -61,10 +58,24 @@ class SearchCell(CellBase):
return ''
@property
def search_service(self):
if self._search_service == '_text':
return {'url': reverse('api-search') + '?q=%(q)s', 'label': _('Page Contents')}
return settings.COMBO_SEARCH_SERVICES.get(self._search_service) or {}
def search_services(self):
services = []
for service_slug in self._search_services.get('data') or []:
service = engines.get(service_slug)
if service and service.get('url'):
service['slug'] = service_slug
services.append(service)
return services
@property
def has_multiple_search_services(self):
return len(self._search_services.get('data') or []) > 1
@classmethod
def get_cells_by_search_service(cls, search_service):
for cell in cls.objects.all():
if search_service in (cell._search_services.get('data') or []):
yield cell
def modify_global_context(self, context, request):
# if self.varname is in the query string (of the page),
@ -94,25 +105,39 @@ class SearchCell(CellBase):
return extra_context
@classmethod
def ajax_results_view(cls, request, cell_pk):
def ajax_results_view(cls, request, cell_pk, service_slug):
cell = cls.objects.get(pk=cell_pk)
if not cell.is_visible(request.user) or not cell.page.is_visible(request.user):
raise PermissionDenied
query = request.GET.get('q')
if query and cell.search_service.get('url'):
url = cell.search_service.get('url')
url = get_templated_url(url)
url = url % {'q': quote(query.encode('utf-8'))}
if url.startswith('/'):
url = request.build_absolute_uri(url)
results = requests.get(url, cache_duration=0).json()
else:
results = {'err': 0, 'data': []}
def render_response(service={}, results={'err': 0, 'data': []}):
template_names = ['combo/search-cell-results.html']
if cell.slug:
template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug)
tmpl = template.loader.select_template(template_names)
context = {'cell': cell, 'results': results, 'search_service': service}
return HttpResponse(tmpl.render(context, request), content_type='text/html')
template_names = ['combo/search-cell-results.html']
if cell.slug:
template_names.insert(0, 'combo/cells/%s/search-cell-results.html' % cell.slug)
tmpl = template.loader.select_template(template_names)
context= {'cell': cell, 'results': results}
return HttpResponse(tmpl.render(context, request), content_type='text/html')
for service in cell.search_services:
if service.get('slug') == service_slug:
break
else:
return render_response()
query = request.GET.get('q')
if not query:
return render_response(service)
url = get_templated_url(service['url'], context={'q': query, 'search_service': service})
url = url % {'q': quote(query.encode('utf-8'))} # if url contains %(q)s
if url.startswith('/'):
url = request.build_absolute_uri(url)
if not url:
return render_response(service)
kwargs = {}
kwargs['cache_duration'] = service.get('cache_duration', 0)
kwargs['remote_service'] = 'auto' if service.get('signature') else None
results = requests.get(url, **kwargs).json()
return render_response(service, results)

View File

@ -1,3 +1,5 @@
{% if results.data %}
{% if cell.has_multiple_search_services %}<p class="search-service-label">{{ search_service.label }}</p>{% endif %}
<div class="links-list">
<ul>
{% for item in results.data %}
@ -7,3 +9,4 @@
{% endfor %}
</ul>
</div>
{% endif %}

View File

@ -2,30 +2,36 @@
{% block cell-content %}
<form id="combo-search-form-{{ cell.pk }}" class="combo-search-form">
<input type="text" name="q" autocomplete="off" id="combo-search-input-{{ cell.pk }}" class="combo-search-input"
data-autocomplete-json="{% url 'combo-search-ajax-results' cell_pk=cell.pk %}{% if initial_query_string %}?{{ initial_query_string }}{% endif %}" />{# initial_query_string pass some context to ajax call #}
<input type="text" name="q" autocomplete="off" id="combo-search-input-{{ cell.pk }}" class="combo-search-input" />
<button class="submit-button">{% trans "Search" %}</button>
</form>
<div id="combo-search-results-{{ cell.pk }}" class="combo-search-results"></div>
{% for search_service in cell.search_services %}
<div id="combo-search-results-{{ cell.pk }}-{{ forloop.counter }}" class="combo-search-results combo-search-results-{{ search_service.slug }}"></div>
{% endfor %}
<script>
$(function() {
var combo_search_timeout_{{ cell.pk }};
var combo_search_form_{{ cell.pk }} = $('#combo-search-form-{{ cell.pk }}');
var combo_search_input_{{ cell.pk }} = $('#combo-search-input-{{ cell.pk }}');
var combo_search_results_{{ cell.pk }} = $('#combo-search-results-{{ cell.pk }}');
var xhr = null;
{% for search_service in cell.search_services %}
var combo_search_results_{{ cell.pk }}_{{ forloop.counter }} = $('#combo-search-results-{{ cell.pk }}-{{ forloop.counter }}');
var xhr_{{ forloop.counter }} = null;
var url_{{ forloop.counter }} = '{% url 'combo-search-ajax-results' cell_pk=cell.pk service_slug=search_service.slug %}{% if initial_query_string %}?{{ intial_query_string }}{% endif %}';
{% endfor %}
function combo_search_update_{{ cell.pk }}() {
if (xhr) xhr.abort();
xhr = $.get(combo_search_input_{{ cell.pk }}.data('autocomplete-json'),
{% for search_service in cell.search_services %}
if (xhr_{{ forloop.counter }}) xhr_{{ forloop.counter }}.abort();
xhr_{{ forloop.counter }} = $.get(url_{{ forloop.counter }},
{'q': combo_search_input_{{ cell.pk }}.val()},
function (response) {
xhr = null;
combo_search_results_{{ cell.pk }}.html(response);
xhr_{{ forloop.counter }} = null;
combo_search_results_{{ cell.pk }}_{{ forloop.counter }}.html(response);
}
);
{% endfor %}
};
combo_search_input_{{ cell.pk }}.on('paste keyup', function() {

View File

@ -19,6 +19,6 @@ from django.conf.urls import url
from .models import SearchCell
urlpatterns = [
url(r'^ajax/search/(?P<cell_pk>\w+)/$', SearchCell.ajax_results_view,
url(r'^ajax/search/(?P<cell_pk>\w+)/(?P<service_slug>[\w-]+)/$', SearchCell.ajax_results_view,
name='combo-search-ajax-results'),
]

View File

@ -15,12 +15,21 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
class AppConfig(django.apps.AppConfig):
name = 'combo.apps.wcs'
verbose_name = _('Forms')
def ready(self):
from combo.apps.search import engines
engines.register('tracking-code',
url=reverse('wcs-tracking-code-search') + '?q=%(q)s',
label=_('Tracking Code')
)
def get_before_urls(self):
from . import urls
return urls.urlpatterns

View File

@ -20,10 +20,13 @@ from django.utils.datastructures import MultiValueDict
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from combo.utils.forms import MultiSortWidget
from .models import (WcsFormCell, WcsCategoryCell, WcsFormsOfCategoryCell,
WcsCurrentFormsCell)
from .utils import get_wcs_options, get_wcs_services
class WcsFormCellForm(forms.ModelForm):
class Meta:
model = WcsFormCell
@ -46,45 +49,6 @@ class WcsCategoryCellForm(forms.ModelForm):
self.fields['category_reference'].widget = forms.Select(choices=references)
class MultiSortWidget(forms.SelectMultiple):
def render(self, name, value, attrs=None, choices=()):
# reorder choices to get them in the current value order
self_choices = self.choices[:]
choices_dict = dict(self_choices)
if value:
for option in reversed(value.get('data')):
if not option in choices_dict:
continue
option_tuple = (option, choices_dict[option])
self.choices.remove(option_tuple)
self.choices.insert(0, option_tuple)
# render the <select multiple>
if django.VERSION < (1, 11, 0):
rendered = super(MultiSortWidget, self).render(name, value,
attrs=attrs, choices=choices)
else:
rendered = super(MultiSortWidget, self).render(name, value,
attrs=attrs)
# include it in a <div> that will be turned into an appropriate widget
# in javascript
id_ = 'wid-%s' % name
return mark_safe('''<div class="multisort" id="%s">%s</div>
<script type="text/javascript">multisort($("#%s"));</script>
''' % (id_, rendered, id_))
def render_options(self, choices, value):
value = value.get('data') or []
return super(MultiSortWidget, self).render_options(choices, value)
def value_from_datadict(self, data, files, name):
if isinstance(data, MultiValueDict):
return {'data': data.getlist(name)}
return data.get(name, None)
class WcsFormsOfCategoryCellForm(forms.ModelForm):
class Meta:
model = WcsFormsOfCategoryCell

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-08-10 08:05
from __future__ import unicode_literals
import combo.apps.wcs.models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('data', '0035_page_related_cells'),
('wcs', '0015_wcscurrentformscell_categories'),
]
operations = [
migrations.CreateModel(
name='BackofficeSubmissionCell',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('placeholder', models.CharField(max_length=20)),
('order', models.PositiveIntegerField()),
('slug', models.SlugField(blank=True, verbose_name='Slug')),
('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
('public', models.BooleanField(default=True, verbose_name='Public')),
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
('last_update_timestamp', models.DateTimeField(auto_now=True)),
('wcs_site', models.CharField(blank=True, max_length=50, verbose_name='Site')),
('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
],
options={
'verbose_name': 'Backoffice Submission',
},
bases=(models.Model, combo.apps.wcs.models.WcsBlurpMixin),
),
]

View File

@ -503,3 +503,28 @@ class TrackingCodeInputCell(CellBase):
self.wcs_site = list(get_wcs_services().keys())[0]
extra_context['url'] = get_wcs_services().get(self.wcs_site).get('url')
return extra_context
@register_cell_class
class BackofficeSubmissionCell(WcsDataBaseCell):
api_url = '/api/formdefs/?backoffice-submission=on'
variable_name = 'all_formdefs'
template_name = 'combo/wcs/backoffice_submission.html'
cache_duration = 600
class Meta:
verbose_name = _('Backoffice Submission')
def get_concerned_user(self, context):
# always return connected user; the selected user will be used in the
# query string when creating a new formdata.
return getattr(context.get('request'), 'user', None)
def get_cell_extra_context(self, context):
context = super(BackofficeSubmissionCell, self).get_cell_extra_context(context)
# add a fake category where it's missing
for site_formdefs in context['all_formdefs'].values():
for formdef in site_formdefs['data']:
if not 'category' in formdef:
formdef['category'] = _('Misc')
return context

View File

@ -0,0 +1,18 @@
{% load i18n %}
{% block cell-content %}
<h2>{% trans "New Form" %}</h2>
{% for site_formdefs in all_formdefs.values %}
<div class="links-list">
{% if all_formdefs.keys|length > 1 %}<h3>{{ site_formdefs.title }}</h3>{% endif %}
<ul>
{% regroup site_formdefs.data|dictsort:"category" by category as categories_formdefs %}
{% for category_formdefs in categories_formdefs %}
<li><h4>{{ category_formdefs.grouper }}</h4></li>
{% for formdef in category_formdefs.list|dictsort:"title" %}
<li><a href="{{formdef.backoffice_submission_url}}?NameID={{name_id}}">{{formdef.title}}</a></li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -16,8 +16,9 @@
from django.conf.urls import url
from .views import TrackingCodeView
from .views import TrackingCodeView, tracking_code_search
urlpatterns = [
url(r'^tracking-code/$', TrackingCodeView.as_view(), name='wcs-tracking-code'),
url(r'^api/search/tracking-code/$', tracking_code_search, name='wcs-tracking-code-search'),
]

View File

@ -14,9 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
from django.contrib import messages
from django.http import HttpResponseRedirect, HttpResponseBadRequest
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
from django.http import JsonResponse, HttpResponseRedirect, HttpResponseBadRequest
from django.utils.six.moves.urllib import parse as urlparse
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
@ -37,14 +39,10 @@ class TrackingCodeView(View):
# sites in a skeleton.
return super(TrackingCodeView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
try:
cell = TrackingCodeInputCell.objects.get(id=request.POST['cell'])
except (ValueError, TrackingCodeInputCell.DoesNotExist):
return HttpResponseBadRequest('Invalid cell id')
code = request.POST['code']
if cell.wcs_site:
wcs_sites = [get_wcs_services().get(cell.wcs_site)]
@classmethod
def search(self, code, wcs_site=None):
if wcs_site:
wcs_sites = [get_wcs_services().get(wcs_site)]
else:
wcs_sites = get_wcs_services().values()
@ -52,12 +50,24 @@ class TrackingCodeView(View):
response = requests.get('/api/code/' + code,
remote_service=wcs_site, log_errors=False)
if response.status_code == 200 and response.json().get('err') == 0:
url = response.json().get('load_url')
return HttpResponseRedirect(url)
return response.json().get('load_url')
return None
def post(self, request, *args, **kwargs):
try:
cell = TrackingCodeInputCell.objects.get(id=request.POST['cell'])
except (ValueError, TrackingCodeInputCell.DoesNotExist):
return HttpResponseBadRequest('Invalid cell id')
code = request.POST['code']
url = self.search(code, wcs_site=cell.wcs_site)
if url:
return HttpResponseRedirect(url)
next_url = request.POST.get('url') or '/'
next_netloc = urlparse.urlparse(next_url).netloc
if not (next_netloc and next_netloc != urlparse.urlparse(request.build_absolute_uri()).netloc):
if not (next_netloc and next_netloc != urlparse.urlparse(self.request.build_absolute_uri()).netloc):
messages.error(self.request,
_(u'The tracking code could not been found.'))
else:
@ -68,3 +78,16 @@ class TrackingCodeView(View):
next_url += 'unknown-tracking-code'
return HttpResponseRedirect(next_url)
def tracking_code_search(request):
hits = []
query = request.GET.get('q') or ''
if re.match(r'^[BCDFGHJKLMNPQRSTVWXZ]{8}$', query):
url = TrackingCodeView.search(query)
if url:
hits.append({
'text': _('Use tracking code %s') % query,
'url': url,
})
return JsonResponse({'data': hits})

View File

@ -15,7 +15,18 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import AppConfig
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
class DataConfig(AppConfig):
name = 'combo.data'
verbose_name = 'data'
def ready(self):
# register built-in search engine for page contents
from combo.apps.search import engines
engines.register('_text',
url=reverse('api-search') + '?q=%(q)s',
label=_('Page Contents')
)

View File

@ -1043,6 +1043,7 @@ class JsonCellBase(CellBase):
# },
# ...
# ]
first_data_key = 'json'
_json_content = None
@ -1063,7 +1064,9 @@ class JsonCellBase(CellBase):
context[varname] = context['request'].GET[varname]
self._json_content = None
data_urls = [{'key': 'json', 'url': self.url, 'cache_duration': self.cache_duration,
context['concerned_user'] = self.get_concerned_user(context)
data_urls = [{'key': self.first_data_key, 'url': self.url, 'cache_duration': self.cache_duration,
'log_errors': self.log_errors, 'timeout': self.timeout}]
data_urls.extend(self.additional_data or [])
@ -1128,7 +1131,7 @@ class JsonCellBase(CellBase):
# keep cache of first response as it may be used to find the
# appropriate template.
self._json_content = extra_context['json']
self._json_content = extra_context[self.first_data_key]
return extra_context

View File

@ -27,24 +27,54 @@ function multisort(element)
var $ul = $('<ul class="multisort"></ul>');
var checkboxes = $(element).data('checkboxes');
$(element).find('option').each(function(i, x) {
if (category_value && $(x).val().indexOf(category_value + ':') != 0) {
return;
}
$('<li data-value="' + $(x).val() + '"><span class="handle">⣿</span>' + $(x).text() + '</li>').appendTo($ul);
var checkbox = '';
if (checkboxes) {
if ($(x).attr('selected')) {
checkbox = '<input type="checkbox" checked/>'
} else {
checkbox = '<input type="checkbox"/>'
}
}
$('<li data-value="' + $(x).val() + '"><span class="handle">⣿</span>'+ checkbox + $(x).text() + '</li>').appendTo($ul);
});
$ul.appendTo(element);
function multisort_sync() {
var $select = $(element).find('select');
var options = Array();
$ul.find('li').each(function(i, x) {
var selected = true;
if (checkboxes && $(x).find('input[type=checkbox]:checked').length == 0) {
selected = false;
}
var value = $(x).data('value');
var $option = $select.find('[value="' + value + '"]');
if (selected) {
$option.prop('selected', 'selected');
} else {
$option.prop('selected', null);
}
$option.detach();
options.push($option);
});
while (options.length) {
$select.prepend(options.pop());
}
}
$ul.find('input[type=checkbox]').on('change', function() {
multisort_sync();
});
$ul.sortable({
handle: '.handle',
update: function(event, ui) {
var options = Array();
var select = $(element).find('select');
$ul.find('li').each(function(i, x) {
options.push($(element).find("option[value='" + $(x).data('value') + "']").attr('selected', 'selected').detach());
});
while (options.length) {
select.prepend(options.pop());
}
multisort_sync();
}
});
}

View File

@ -0,0 +1,26 @@
# combo - content management system
# Copyright (C) 2014-2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django.apps
from django.utils.translation import ugettext_lazy as _
class AppConfig(django.apps.AppConfig):
name = 'combo.profile'
verbose_name = _('Profile')
default_app_config = 'combo.profile.AppConfig'

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-08-10 13:52
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('data', '0035_page_related_cells'),
('profile', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ProfileCell',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('placeholder', models.CharField(max_length=20)),
('order', models.PositiveIntegerField()),
('slug', models.SlugField(blank=True, verbose_name='Slug')),
('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
('public', models.BooleanField(default=True, verbose_name='Public')),
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
('last_update_timestamp', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
],
options={
'verbose_name': 'Profile',
},
),
]

View File

@ -14,10 +14,47 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from collections import OrderedDict
import copy
from django.conf import settings
from django.db import models
from django.utils.dateparse import parse_date
from django.utils.translation import ugettext_lazy as _
from combo.data.models import JsonCellBase
from combo.data.library import register_cell_class
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL)
initial_login_view_timestamp = models.DateTimeField(null=True)
@register_cell_class
class ProfileCell(JsonCellBase):
template_name = 'combo/profile.html'
first_data_key = 'profile'
class Meta:
verbose_name = _('Profile')
@property
def url(self):
idp = settings.KNOWN_SERVICES.get('authentic').values()[0]
return '{%% load combo %%}%sapi/users/{{ concerned_user|name_id }}/' % idp.get('url')
def get_cell_extra_context(self, context):
extra_context = super(ProfileCell, self).get_cell_extra_context(context)
extra_context['profile_fields'] = OrderedDict()
if extra_context.get('profile') is not None:
for attribute in settings.USER_PROFILE_CONFIG.get('fields'):
extra_context['profile_fields'][attribute['name']] = copy.copy(attribute)
value = extra_context['profile'].get(attribute['name'])
if value:
if attribute['kind'] in ('birthdate', 'date'):
value = parse_date(value)
extra_context['profile_fields'][attribute['name']]['value'] = value
else:
extra_context['error'] = 'unknown user'
return extra_context

View File

@ -0,0 +1,14 @@
{% load i18n %}
{% block cell-content %}
<div class="profile">
<h2>{% trans "Profile" %}</h2>
{% for key, details in profile_fields.items %}
{% if details.value and details.user_visible %}
<p><span class="label">{{ details.label }}</span> <span class="value">{{ details.value }}</span></p>
{% endif %}
{% endfor %}
{% if error == 'unknown user' %}
<p>{% trans 'Unknown User' %}</p>
{% endif %}
</div>
{% endblock %}

View File

@ -19,8 +19,10 @@ from __future__ import absolute_import
import datetime
from django import template
from django.conf import settings
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.template import VariableDoesNotExist
from django.template.base import TOKEN_BLOCK, TOKEN_VAR
from django.template.defaultfilters import stringfilter
from django.utils import dateparse
@ -30,6 +32,9 @@ from combo.public.menu import get_menu_context
from combo.utils import NothingInCacheException, flatten_context
from combo.apps.dashboard.models import DashboardCell, Tile
if 'mellon' in settings.INSTALLED_APPS:
from mellon.models import UserSAMLIdentifier
register = template.Library()
def skeleton_text(context, placeholder_name, content=''):
@ -229,3 +234,12 @@ def as_list(obj):
@register.filter
def signed(obj):
return signing.dumps(obj)
@register.filter
def name_id(user):
saml_id = UserSAMLIdentifier.objects.filter(user=user).last()
if saml_id:
return saml_id.name_id
# it is important to raise this so get_templated_url is aborted and no call
# is tried with a missing user argument.
raise VariableDoesNotExist('name_id')

View File

@ -491,7 +491,7 @@ def menu_badges(request):
def api_search(request):
for cell in SearchCell.objects.filter(_search_service='_text'):
for cell in SearchCell.get_cells_by_search_service('_text'):
if not cell.is_visible(request.user):
continue
break

69
combo/utils/forms.py Normal file
View File

@ -0,0 +1,69 @@
# combo - content management system
# Copyright (C) 2014-2018 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import django
from django import forms
from django.utils.datastructures import MultiValueDict
from django.utils.safestring import mark_safe
class MultiSortWidget(forms.SelectMultiple):
def __init__(self, *args, **kwargs):
if 'with_checkboxes' in kwargs:
self.with_checkboxes = kwargs.pop('with_checkboxes')
else:
self.with_checkboxes = False
super(MultiSortWidget, self).__init__(*args, **kwargs)
def render(self, name, value, attrs=None, choices=()):
# reorder choices to get them in the current value order
self_choices = self.choices[:]
choices_dict = dict(self_choices)
if value:
for option in reversed(value.get('data')):
if option not in choices_dict:
continue
option_tuple = (option, choices_dict[option])
self.choices.remove(option_tuple)
self.choices.insert(0, option_tuple)
# render the <select multiple>
if django.VERSION < (1, 11, 0):
rendered = super(MultiSortWidget, self).render(name, value,
attrs=attrs, choices=choices)
else:
rendered = super(MultiSortWidget, self).render(name, value,
attrs=attrs)
# include it in a <div> that will be turned into an appropriate widget
# in javascript
id_ = 'wid-%s' % name
if self.with_checkboxes:
attrs = 'data-checkboxes="true"'
else:
attrs = ''
return mark_safe('''<div class="multisort" %s id="%s">%s</div>
<script type="text/javascript">multisort($("#%s"));</script>
''' % (attrs, id_, rendered, id_))
def render_options(self, choices, value):
value = value.get('data') or []
return super(MultiSortWidget, self).render_options(choices, value)
def value_from_datadict(self, data, files, name):
if isinstance(data, MultiValueDict):
return {'data': data.getlist(name)}
return data.get(name, None)

View File

@ -184,6 +184,13 @@ div.cell {
}
}
div.profile {
span.value {
display: block;
margin-left: 1rem;
}
}
@media screen and (min-width: 1586px) {
div#page-content div.cubesbarchart {
width: 49.5%;

View File

@ -62,3 +62,20 @@ FAMILY_SERVICE = {'root': '/'}
BOOKING_CALENDAR_CELL_ENABLED = True
NEWSLETTERS_CELL_ENABLED = True
USER_PROFILE_CONFIG = {
'fields': [
{
'name': 'first_name',
'kind': 'string',
'label': 'First Name',
'user_visible': True,
},
{
'name': 'birthdate',
'kind': 'birthdate',
'label': 'Birth Date',
'user_visible': True,
}
]
}

51
tests/test_profile.py Normal file
View File

@ -0,0 +1,51 @@
import datetime
import json
import mock
import pytest
from django.test import override_settings
from combo.data.models import Page
from combo.profile.models import ProfileCell
pytestmark = pytest.mark.django_db
@override_settings(
KNOWN_SERVICES={'authentic': {'idp': {'title': 'IdP', 'url': 'http://example.org/'}}})
@mock.patch('combo.public.templatetags.combo.UserSAMLIdentifier')
@mock.patch('combo.utils.requests.get')
def test_profile_cell(requests_get, user_saml, app, admin_user):
page = Page()
page.save()
cell = ProfileCell(page=page, order=0)
cell.save()
data = {'first_name': 'Foo', 'birthdate': '2018-08-10'}
requests_get.return_value = mock.Mock(
content=json.dumps(data),
json=lambda: data,
status_code=200)
def filter_mock(user=None):
assert user is admin_user
return mock.Mock(last=lambda: mock.Mock(name_id='123456'))
mocked_objects = mock.Mock()
mocked_objects.filter = mock.Mock(side_effect=filter_mock)
user_saml.objects = mocked_objects
context = cell.get_cell_extra_context({'synchronous': True, 'selected_user': admin_user})
assert context['profile_fields']['first_name']['value'] == 'Foo'
assert context['profile_fields']['birthdate']['value'] == datetime.date(2018, 8, 10)
assert requests_get.call_args[0][0] == 'http://example.org/api/users/123456/'
def filter_mock_missing(user=None):
return mock.Mock(last=lambda: None)
mocked_objects.filter = mock.Mock(side_effect=filter_mock_missing)
context = cell.get_cell_extra_context({'synchronous': True, 'selected_user': admin_user})
assert context['error'] == 'unknown user'
assert requests_get.call_count == 1 # no new call was made

View File

@ -1,6 +1,7 @@
import json
import os
import pytest
import re
import mock
from django.conf import settings
@ -15,6 +16,9 @@ from combo.apps.search.models import SearchCell
from combo.data.models import Page, JsonCell, TextCell, MenuCell, LinkCell
from combo.data.search_indexes import PageIndex
from .test_manager import admin_user, login
pytestmark = pytest.mark.django_db
@ -40,7 +44,7 @@ class SearchServices(object):
settings.COMBO_SEARCH_SERVICES = self.search_services
def __exit__(self, *args, **kwargs):
delattr(settings, 'COMBO_SEARCH_SERVICES')
settings.COMBO_SEARCH_SERVICES = {}
def test_search_cell(app):
with SearchServices(SEARCH_SERVICES):
@ -48,7 +52,7 @@ def test_search_cell(app):
page.save()
cell = SearchCell(page=page, placeholder='content', order=0)
cell._search_service = 'search1'
cell._search_services = {'data': ['search1']}
cell.save()
resp = cell.render({})
@ -66,35 +70,35 @@ def test_search_cell(app):
mock_json = mock.Mock()
mock_json.json.return_value = response
requests_get.return_value = mock_json
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
assert requests_get.call_args[0][0] == 'http://www.example.net/search/?q=foo'
assert '<li>' not in resp.text
resp = app.get('/ajax/search/%s/?q=foo%%23bar' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search1/?q=foo%%23bar' % cell.pk, status=200)
assert requests_get.call_args[0][0] == 'http://www.example.net/search/?q=foo%23bar'
assert '<li>' not in resp.text
response['data'] = [{'url': 'http://test', 'text': 'barbarbar'}]
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
assert resp.text.count('<li>') == 1
assert '<li><a href="http://test">barbarbar</a>' in resp.text
response['data'] = [{'url': 'http://test', 'text': 'barbarbar',
'description': 'this is <b>html</b>'}]
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search1/?q=foo' % cell.pk, status=200)
assert resp.text.count('<li>') == 1
assert '<li><a href="http://test">barbarbar</a>' in resp.text
assert 'this is <b>html</b>' in resp.text
with override_settings(TEMPLATE_VARS=TEMPLATE_VARS):
cell._search_service = 'search_tmpl'
cell._search_services = {'data': ['search_tmpl']}
cell.save()
with mock.patch('combo.apps.search.models.requests.get') as requests_get:
response = {'err': 0, 'data': []}
mock_json = mock.Mock()
mock_json.json.return_value = response
requests_get.return_value = mock_json
resp = app.get('/ajax/search/%s/?q=foo' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search_tmpl/?q=foo' % cell.pk, status=200)
assert requests_get.call_args[0][0] == 'http://search.example.net/?q=foo'
# TEMPLATE_VARS are accessible in template
@ -103,7 +107,7 @@ def test_search_cell(app):
templates_settings = [settings.TEMPLATES[0].copy()]
templates_settings[0]['DIRS'] = ['%s/templates-1' % os.path.abspath(os.path.dirname(__file__))]
with override_settings(TEMPLATES=templates_settings):
resp = app.get('/ajax/search/%s/?q=bar' % cell.pk, status=200)
resp = app.get('/ajax/search/%s/search_tmpl/?q=bar' % cell.pk, status=200)
assert requests_get.call_args[0][0] == 'http://search.example.net/?q=bar'
assert 'searchfoo results.data=[]' in resp.text
assert 'search_url=http://search.example.net/' in resp.text
@ -113,7 +117,7 @@ def test_search_global_context(app):
page = Page(title='Search', slug='search_page', template_name='standard')
page.save()
cell = SearchCell(page=page, placeholder='content', order=0)
cell._search_service = 'search1'
cell._search_services = {'data': ['search1']}
cell.save()
assert cell.varname == ''
@ -143,7 +147,7 @@ def test_search_cell_visibility(app):
cell = SearchCell(page=page, order=0)
assert not cell.is_visible()
cell._search_service = '_text'
cell._search_services = {'data': ['_text']}
assert cell.is_visible()
def test_search_contents():
@ -218,7 +222,7 @@ def test_search_api(app):
resp = app.get('/api/search/?q=foobar', status=404)
cell = SearchCell(page=page, _search_service='_text', order=0)
cell = SearchCell(page=page, _search_services={'data': ['_text']}, order=0)
cell.save()
resp = app.get('/api/search/?q=foobar', status=200)
@ -242,7 +246,7 @@ def test_update_index_command(app):
page = Page(title='example page', slug='example-page')
page.save()
cell = SearchCell(page=page, _search_service='_text', order=0)
cell = SearchCell(page=page, _search_services={'data': ['_text']}, order=0)
cell.save()
call_command('update_index')
@ -276,3 +280,42 @@ def test_update_index_command(app):
resp = app.get('/api/search/?q=bar', status=200)
assert len(resp.json['data']) == 1
assert resp.json['data'][0]['url'] == 'http://example.net'
def test_manager_search_cell(app, admin_user):
Page.objects.all().delete()
page = Page(title='One', slug='one', template_name='standard')
page.save()
app = login(app)
resp = app.get('/manage/pages/%s/' % page.id)
resp = app.get(resp.html.find('option',
**{'data-add-url': re.compile('search_searchcell')})['data-add-url'])
cells = Page.objects.get(id=page.id).get_cells()
assert len(cells) == 1
assert isinstance(cells[0], SearchCell)
resp = app.get('/manage/pages/%s/' % page.id)
assert ('data-cell-reference="%s"' % cells[0].get_reference()) in resp.text
assert len(resp.form['c%s-_search_services' % cells[0].get_reference()].options) == 2
with SearchServices(SEARCH_SERVICES):
resp = app.get('/manage/pages/%s/' % page.id)
assert len(resp.form['c%s-_search_services' % cells[0].get_reference()].options) == 4
# simulate reordering of options
resp.form['c%s-_search_services' % cells[0].get_reference()].options = [
(u'search_tmpl', False, u'Search with template'),
(u'_text', False, u'Page Contents'),
(u'search1', False, u'Search 1')]
resp.form['c%s-_search_services' % cells[0].get_reference()].value = ['search_tmpl', '_text']
resp = resp.form.submit()
assert resp.status_int == 302
# check selected engines are selected and the first items of the list
resp = app.get('/manage/pages/%s/' % page.id)
assert set(resp.form['c%s-_search_services' % cells[0].get_reference()].value) == set(['search_tmpl', '_text'])
assert resp.form['c%s-_search_services' % cells[0].get_reference()].options[0][0] == 'search_tmpl'
assert resp.form['c%s-_search_services' % cells[0].get_reference()].options[1][0] == '_text'
# check there's no crash if search engines are removed from config
resp = app.get('/manage/pages/%s/' % page.id)
assert resp.form['c%s-_search_services' % cells[0].get_reference()].value == ['_text']

View File

@ -20,7 +20,7 @@ from django.utils.six.moves.urllib import parse as urlparse
from combo.data.models import Page
from combo.apps.wcs.models import (WcsFormCell, WcsCurrentFormsCell,
WcsFormsOfCategoryCell, WcsCurrentDraftsCell, WcsCategoryCell,
TrackingCodeInputCell)
TrackingCodeInputCell, BackofficeSubmissionCell)
from combo.utils import NothingInCacheException
@ -143,9 +143,15 @@ formdef = FormDef()
formdef.name = 'a private form'
formdef.category_id = cats[2].id
formdef.roles = [role.id]
formdef.backoffice_submission_roles = [role.id]
formdef.fields = []
formdef.store()
user2 = get_publisher().user_class() # agent
user2.name = 'foo2 bar2'
user2.email = 'foo2@example.net'
user2.roles = [role.id]
user2.store()
""",
}
@ -643,3 +649,43 @@ def test_cell_assets(app, admin_user):
resp = app.get('/manage/assets/')
assert u'>Logo — Test 9<' in resp.text
assert u'>Picture — form title<' in resp.text
@wcsctl_present
def test_tracking_code_search(app):
assert len(app.get('/api/search/tracking-code/').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=123').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=BBCCDFF').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=BBCCDDFF').json.get('data')) == 0
assert len(app.get('/api/search/tracking-code/?q=CNPHNTFB').json.get('data')) == 1
assert len(app.get('/api/search/tracking-code/?q=BBCCDDFFG').json.get('data')) == 0
@wcsctl_present
def test_backoffice_submission_cell_render(context):
page = Page(title='xxx', slug='test_backoffice_submission_cell_render', template_name='standard')
page.save()
cell = BackofficeSubmissionCell(page=page, placeholder='content', order=0)
cell.wcs_site = 'default'
cell.save()
context['synchronous'] = True # to get fresh content
result = cell.render(context)
assert '/backoffice/submission/a-private-form/' not in result
class MockUser(object):
email = 'foo@example.net'
def is_authenticated(self):
return True
context['request'].user = MockUser()
result = cell.render(context)
assert '/backoffice/submission/a-private-form/' not in result
class MockUser(object):
email = 'foo2@example.net'
def is_authenticated(self):
return True
context['request'].user = MockUser()
result = cell.render(context)
assert '/backoffice/submission/a-private-form/' in result