diff --git a/postgres-hstore/create_extension.sh b/postgres-hstore/create_extension.sh index 60576f8..41df007 100644 --- a/postgres-hstore/create_extension.sh +++ b/postgres-hstore/create_extension.sh @@ -9,6 +9,7 @@ set -e psql --dbname template1 -U postgres < -1){ + return; + } + + var $ = django.jQuery; + + // processing inlines + if(hstore_field_name.indexOf('inline') > -1){ + var inlineClass = $('#id_'+hstore_field_name).parents('.inline-related, .grp-group').attr('class'); + // if using TabularInlines stop here + // TabularInlines not supported + if (inlineClass.indexOf('tabular') > -1) { + return; + } + } + + // reusable function that retrieves a template even if ID is not correct + // (written to support inlines) + var retrieveTemplate = function(template_name, field_name){ + var specific_template = $('#'+template_name+'-'+field_name); + // if found specific template return that + if(specific_template.length){ + return specific_template.html(); + } + else{ + // get fallback template + var html = $('.'+template_name+'-inline').html(); + // replace all occurrences of __prefix__ with field_name + // and return + html = html.replace(/__prefix__/g, inline_prefix); + return html; + } + } + + // reusable function that compiles the UI + var compileUI = function(params){ + var hstore_field_id = 'id_'+hstore_field_name, + original_textarea = $('#'+hstore_field_id), + original_value = original_textarea.val(), + original_container = original_textarea.parents('.form-row, .grp-row').eq(0), + errorHtml = original_container.find('.errorlist').html(), + json_data = {}; + + if(original_value !== ''){ + // manage case in which textarea is blank + try{ + json_data = JSON.parse(original_value); + } + catch(e){ + alert('invalid JSON:\n'+e); + return false; + } + } + + var hstore_field_data = { + "id": hstore_field_id, + "label": original_container.find('label').text(), + "name": hstore_field_name, + "value": original_textarea.val(), + "help": original_container.find('.grp-help, .help').text(), + "errors": errorHtml, + "data": json_data + }, + // compile template + ui_html = retrieveTemplate('hstore-ui-template', hstore_field_name), + compiled_ui_html = _.template(ui_html, hstore_field_data); + + // this is just to DRY up a bit + if(params && params.replace_original === true){ + // remove original textarea to avoid having two textareas with same ID + original_textarea.remove(); + // inject compiled template and hide original + original_container.after(compiled_ui_html).hide(); + } + + return compiled_ui_html; + }; + + + + // generate UI + compileUI({ replace_original: true }); + + // cache other objects that we'll reuse + var row_html = retrieveTemplate('hstore-row-template', hstore_field_name), + empty_row = _.template(row_html, { 'key': '', 'value': '' }), + $hstore = $('#id_'+hstore_field_name).parents('.hstore'); + + // reusable function that updates the textarea value + var updateTextarea = function(container) { + // init empty json object + var new_value = {}, + raw_textarea = container.find('textarea'), + rows = container.find('.form-row, .grp-row'); + + // loop over each object and populate json + rows.each(function() { + var inputs = $(this).find('input'), + key = $(this).find('.hs-key').val(), + value = $(this).find('.hs-val').val(); + new_value[key] = value; + }); + + // update textarea value + $(raw_textarea).val(JSON.stringify(new_value, null, 4)); + }; + + // remove row link + $hstore.delegate('a.remove-row', 'click', function(e) { + e.preventDefault(); + // cache container jquery object before $(this) gets removed + $(this).parents('.form-row, .grp-row').eq(0).remove(); + updateTextarea($hstore); + }); + + // add row link + $hstore.delegate('a.hs-add-row, .hs-add-row a', 'click', function(e) { + e.preventDefault(); + $hstore.find('.hstore-rows').append(empty_row); + xD('.django-select2').djangoSelect2() + xD('select').on( 'select2:close', function () { + $(this).focus(); + }); + }); + + // toggle textarea link + $hstore.delegate('.hstore-toggle-txtarea', 'click', function(e) { + e.preventDefault(); + + var raw_textarea = $hstore.find('.hstore-textarea'), + hstore_rows = $hstore.find('.hstore-rows'), + add_row = $hstore.find('.hs-add-row'); + + if(raw_textarea.is(':visible')) { + + var compiled_ui = compileUI(); + + // in case of JSON error + if(compiled_ui === false){ + return; + } + + // jquery < 1.8 + try{ + var $ui = $(compiled_ui); + } + // jquery >= 1.8 + catch(e){ + var $ui = $($.parseHTML(compiled_ui)); + } + + // update rows with only relevant content + hstore_rows.html($ui.find('.hstore-rows').html()); + + raw_textarea.hide(); + hstore_rows.show(); + add_row.show(); + + xD('.django-select2').djangoSelect2() + } + else{ + raw_textarea.show(); + hstore_rows.hide(); + add_row.hide(); + } + }); + + // update textarea whenever a field changes + $hstore.delegate('.hs-val', 'keyup', function() { + updateTextarea($hstore); + }); + + $hstore.delegate('.hs-key', 'change', function() { + updateTextarea($hstore); + }); +}; + +django.jQuery(window).load(function() { + // support inlines + // bind only once + if(django.hstoreWidgetBoundInlines === undefined){ + var $ = django.jQuery; + $('.grp-group .grp-add-handler, .inline-group .hs-add-row a, .inline-group .add-row').click(function(e){ + var hstore_original_textareas = $(this).parents('.grp-group, .inline-group').eq(0).find('.hstore-original-textarea'); + // if module contains .hstore-original-textarea + if(hstore_original_textareas.length > 0){ + // loop over each inline + $(this).parents('.grp-group, .inline-group').find('.grp-items div.grp-dynamic-form, .inline-related').each(function(e, i){ + var prefix = i; + // loop each textarea + $(this).find('.hstore-original-textarea').each(function(e, i){ + // cache field name + var field_name = $(this).attr('name'); + // ignore templates + // if name attribute contains __prefix__ + if(field_name.indexOf('prefix') > -1){ + // skip to next + return; + } + initDjangoHStoreWidget(field_name, prefix); + }); + }); + } + }); + django.hstoreWidgetBoundInlines = true; + } +}); diff --git a/storage/admin.py b/storage/admin.py index 84014c3..09ae407 100644 --- a/storage/admin.py +++ b/storage/admin.py @@ -1,21 +1,11 @@ from django import forms from django.contrib import admin -from .models import Item, ItemImage, Category, Label + from django_select2.forms import ModelSelect2Widget, Select2MultipleWidget +from .models import Item, ItemImage, Category, Label +from .widgets import ItemSelectWidget, PropsSelectWidget -class ItemSelectWidget(ModelSelect2Widget): - search_fields = [ - 'name__icontains', - 'description__icontains' - ] - - def __init__(self, *args, **kwargs): - kwargs['data_view'] = 'item-complete' - super(ItemSelectWidget, self).__init__(*args, **kwargs) - - def label_from_instance(self, obj): - return obj.name class ItemForm(forms.ModelForm): name = forms.CharField(widget=forms.TextInput()) @@ -25,16 +15,20 @@ class ItemForm(forms.ModelForm): exclude = [] widgets = { 'parent': ItemSelectWidget, - 'categories': Select2MultipleWidget + 'categories': Select2MultipleWidget, + 'props': PropsSelectWidget } + class ItemImageInline(admin.TabularInline): model = ItemImage extra = 1 + class LabelInline(admin.TabularInline): model = Label + class ItemAdmin(admin.ModelAdmin): list_display = ('_name',) list_filter = ('categories',) @@ -75,5 +69,6 @@ class ItemAdmin(admin.ModelAdmin): with Item.disabled_tree_trigger(): return super(ItemAdmin, self).response_action(request, queryset) + admin.site.register(Item, ItemAdmin) admin.site.register(Category) diff --git a/storage/urls.py b/storage/urls.py index d9f9582..fce08cb 100644 --- a/storage/urls.py +++ b/storage/urls.py @@ -1,10 +1,13 @@ from django.conf.urls import include, url -from storage.views import index, search, item_display, label_lookup, ItemSelectView +from storage.views import ( + index, search, item_display, label_lookup, ItemSelectView, PropSelectView +) urlpatterns = [ url(r'^$', index), url(r'^search$', search), url(r'^item/(?P.*)$', item_display, name='item-display'), url(r'^autocomplete.json$', ItemSelectView.as_view(), name='item-complete'), + url(r'^autocomplete_prop.json$', PropSelectView.as_view(), name='prop-complete'), url(r'^(?P[^/]*)$', label_lookup, name='label-lookup'), ] diff --git a/storage/views.py b/storage/views.py index 5c93eb6..d11d515 100644 --- a/storage/views.py +++ b/storage/views.py @@ -1,10 +1,12 @@ import shlex from django.shortcuts import render, get_object_or_404, redirect -from django.contrib.postgres.search import SearchVector +from django.contrib.postgres.search import SearchVector, TrigramSimilarity from django.http import Http404, JsonResponse from django.contrib.admin.models import LogEntry from django_select2.views import AutoResponseView +from django.db.models import Q +from django.db import connection from storage.models import Item, Label @@ -19,30 +21,37 @@ def apply_smart_search(query, objects): general_term.append(prop) else: key, value = prop.split(':', 1) - if hasattr(Item, key): + if key in ['owner', 'taken_by']: + filters[key + '__username'] = value + elif hasattr(Item, key): filters[key + '__search'] = value elif key == 'ancestor': objects = Item.objects.get(pk=value).get_children() elif key == 'prop' or value: if key == 'prop': - key, value = value.split(':', 1) - - if 'props__contains' not in filters: - filters['props__contains'] = {} - filters['props__contains'] = {key: value} - + key, _, value = value.partition(':') + if not value: + filters['props__isnull'] = {key: False} + else: + filters['props__contains'] = {key: value} else: # "Whatever:" general_term.append(prop) - if general_term: - objects = objects.annotate( - search=SearchVector('name', 'description', 'props', config='simple'), - ) - filters['search'] = ' '.join(general_term) - objects = objects.filter(**filters) + if not general_term: + return objects + objects = objects.annotate( + search=SearchVector('name', 'description', 'props', config='simple'), + ) + general_term = ' '.join(general_term) + + objects = objects.annotate( + similarity=TrigramSimilarity('name', general_term) + ).filter( + similarity__gte=0.15 + ).order_by('-similarity') return objects @@ -53,14 +62,14 @@ def index(request): def search(request): query = request.GET.get('q', '') - results = apply_smart_search(query, Item.objects) + results = apply_smart_search(query, Item.objects).all() - if results.count() == 1: - return redirect(results.all()[0]) + if results and len(results) == 1 or getattr(results[0], 'similarity', 0) == 1: + return redirect(results[0]) return render(request, 'results.html', { 'query': query, - 'results': results.all(), + 'results': results, }) @@ -105,3 +114,23 @@ class ItemSelectView(AutoResponseView): ], 'more': context['page_obj'].has_next() }) + + +class PropSelectView(AutoResponseView): + def get(self, request, *args, **kwargs): + # self.widget = self.get_widget_or_404() + self.term = kwargs.get('term', request.GET.get('term', '')) + # context = self.get_context_data() + with connection.cursor() as c: + c.execute("select e from (select skeys(props) as e, count(skeys(props)) as e_count from storage_item group by e order by e_count desc) as xD where e like %s limit 10;", ['%' + self.term + '%']) + props = c.fetchall() + + return JsonResponse({ + 'results': [ + { + 'text': p, + 'id': p, + } + for p in props + ], + }) diff --git a/storage/widgets.py b/storage/widgets.py new file mode 100644 index 0000000..b887056 --- /dev/null +++ b/storage/widgets.py @@ -0,0 +1,59 @@ +from pkg_resources import parse_version + +from django_select2.forms import ModelSelect2Widget, HeavySelect2Widget +from django_hstore.forms import DictionaryFieldWidget + +from django import get_version +from django.urls import reverse +from django.conf import settings +from django.utils.safestring import mark_safe +from django.template import Context +from django.template.loader import get_template +from django.contrib.admin.widgets import AdminTextareaWidget + + +class ItemSelectWidget(ModelSelect2Widget): + search_fields = [ + 'name__icontains', + 'description__icontains' + ] + + def __init__(self, *args, **kwargs): + kwargs['data_view'] = 'item-complete' + super(ItemSelectWidget, self).__init__(*args, **kwargs) + + def label_from_instance(self, obj): + return obj.name + + +class PropsSelectWidget(DictionaryFieldWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def render(self, name, value, attrs=None): + if attrs is None: + attrs = {} + # it's called "original" because it will be replaced by a copy + attrs['class'] = 'hstore-original-textarea' + w = HeavySelect2Widget(data_view='prop-complete', attrs={'data-tags': 'true', 'class': 'hs-key'}) + + # get default HTML from AdminTextareaWidget + html = AdminTextareaWidget.render(self, name, value, attrs) + # prepare template context + template_context = Context({ + 'field_name': name, + 'STATIC_URL': settings.STATIC_URL, + 'use_svg': parse_version(get_version()) >= parse_version('1.9'), # use svg icons if django >= 1.9 + 'ajax_url': reverse('prop-complete'), + 'w': w.build_attrs() + }) + # get template object + template = get_template('hstore_%s_widget.html' % self.admin_style) + # render additional html + additional_html = template.render(template_context) + + # append additional HTML and mark as safe + html = html + additional_html + html = mark_safe(html) + + return html diff --git a/templates/hstore_default_widget.html b/templates/hstore_default_widget.html new file mode 100644 index 0000000..29774ff --- /dev/null +++ b/templates/hstore_default_widget.html @@ -0,0 +1,75 @@ +{% load i18n %} + + + + +