search form and help doc

This commit is contained in:
smilerz 2021-06-05 17:43:48 -05:00
parent 7c1b5b2d85
commit 9b9ecec52f
9 changed files with 212 additions and 62 deletions

View File

@ -482,7 +482,7 @@ class SearchPreferenceForm(forms.ModelForm):
fields = ('search', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext')
help_texts = {
'search': _('Select type method of search. Click here for full desciption of choices.'),
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
'unaccent': _('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
'icontains': _("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
'istartswith': _("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
@ -507,3 +507,5 @@ class SearchPreferenceForm(forms.ModelForm):
'trigram': MultiSelectWidget,
'fulltext': MultiSelectWidget,
}

View File

@ -12,14 +12,7 @@ from cookbook.models import Food, Keyword, ViewLog
def search_recipes(request, queryset, params):
fields = {
'name': 'name',
'description': 'description',
'instructions': 'steps__instruction',
'foods': 'steps__ingredients__food__name',
'keywords': 'keywords__name'
}
search_prefs = request.user.searchpreference
search_string = params.get('query', '')
search_keywords = params.getlist('keywords', [])
search_foods = params.getlist('foods', [])
@ -46,35 +39,38 @@ def search_recipes(request, queryset, params):
created_at__gte=(datetime.now() - timedelta(days=7)), then=Value(100)),
default=Value(0), )).order_by('-new_recipe', 'name')
search_type = None
search_type = search_prefs.search or 'plain'
search_sort = None
if len(search_string) > 0:
# TODO move all of these to settings somewhere - probably user settings
unaccent_include = search_prefs.unaccent.values_list('field', flat=True)
unaccent_include = ['name', 'description', 'instructions', 'keywords', 'foods'] # can also contain: description, instructions, keywords, foods
# TODO when setting up settings length of arrays below must be >=1
icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.icontains.values_list('field', flat=True)]
istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.istartswith.values_list('field', flat=True)]
trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.trigram.values_list('field', flat=True)]
fulltext_include = search_prefs.fulltext.values_list('field', flat=True) # fulltext doesn't use field name directly
icontains_include = [] # can contain: name, description, instructions, keywords, foods
istartswith_include = ['name'] # can also contain: description, instructions, keywords, foods
trigram_include = ['name', 'description', 'instructions'] # only these choices - keywords and foods are really, really, really slow maybe add to subquery?
fulltext_include = ['name', 'description', 'instructions', 'foods', 'keywords']
# if no filters are configured use name__icontains as default
if len(icontains_include) + len(istartswith_include) + len(trigram_include) + len(fulltext_include) == 0:
filters = [Q(**{"name__icontains": search_string})]
else:
filters = []
# END OF SETTINGS SECTION
for f in unaccent_include:
fields[f] += '__unaccent'
filters = []
# dynamically build array of filters that will be applied
for f in icontains_include:
filters += [Q(**{"%s__icontains" % fields[f]: search_string})]
filters += [Q(**{"%s__icontains" % f: search_string})]
for f in istartswith_include:
filters += [Q(**{"%s__istartswith" % fields[f]: search_string})]
filters += [Q(**{"%s__istartswith" % f: search_string})]
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
language = DICTIONARY.get(translation.get_language(), 'simple')
# django full text search https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#searchquery
search_type = 'websearch' # other postgress options are phrase or plain or raw (websearch and trigrams are mutually exclusive)
search_trigram = False
# TODO can options install this extension to further enhance search query language https://github.com/caub/pg-tsquery
# trigram breaks full text search 'websearch' and 'raw' capabilities and will be ignored if those methods are chosen
if search_type in ['websearch', 'raw']:
search_trigram = False
else:
search_trigram = True
search_query = SearchQuery(
search_string,
search_type=search_type,
@ -86,10 +82,11 @@ def search_recipes(request, queryset, params):
trigram = None
for f in trigram_include:
if trigram:
trigram += TrigramSimilarity(fields[f], search_string)
trigram += TrigramSimilarity(f, search_string)
else:
trigram = TrigramSimilarity(fields[f], search_string)
trigram = TrigramSimilarity(f, search_string)
queryset.annotate(simularity=trigram)
# TODO allow user to play with trigram scores
filters += [Q(simularity__gt=0.5)]
if 'name' in fulltext_include:

View File

@ -95,7 +95,7 @@ class Migration(migrations.Migration):
name='SearchPreference',
fields=[
('user', annoying.fields.AutoOneToOneField(on_delete=deletion.CASCADE, primary_key=True, serialize=False, to='auth.user')),
('search', models.CharField(choices=[('PLAIN', 'Plain'), ('PHRASE', 'Phrase'), ('WEBSEARCH', 'Web'), ('RAW', 'Raw')], default='SIMPLE', max_length=32)),
('search', models.CharField(choices=[('plain', 'Plain'), ('phrase', 'Phrase'), ('websearch', 'Web'), ('raw', 'Raw')], default='plain', max_length=32)),
('fulltext', models.ManyToManyField(blank=True, related_name='fulltext_fields', to='cookbook.SearchFields')),
('icontains', models.ManyToManyField(blank=True, default=nameSearchField, related_name='icontains_fields', to='cookbook.SearchFields')),
('istartswith', models.ManyToManyField(blank=True, related_name='istartswith_fields', to='cookbook.SearchFields')),

View File

@ -746,13 +746,12 @@ def nameSearchField():
class SearchPreference(models.Model, PermissionModelMixin):
# Search Style (validation parsleyjs.org)
# phrase or plain or raw (websearch and trigrams are mutually exclusive)
SIMPLE = 'SIMPLE'
PLAIN = 'PLAIN'
PHRASE = 'PHRASE'
WEB = 'WEBSEARCH'
RAW = 'RAW'
SIMPLE = 'plain'
PHRASE = 'phrase'
WEB = 'websearch'
RAW = 'raw'
SEARCH_STYLE = (
(PLAIN, _('Plain')),
(SIMPLE, _('Simple')),
(PHRASE, _('Phrase')),
(WEB, _('Web')),
(RAW, _('Raw'))

View File

@ -0,0 +1,110 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Search Settings" %}{% endblock %}
{% block content %}
<h1>{% trans 'Search Settings' %}</h1>
{% blocktrans %}
Creating the best search experience is complicated and weighs heavily on your personal configuration.
Changing any of the search settings can have significant impact on the speed and quality of the results.
Search Methods, Trigrams and Full Text Search configurations are only available if you are using Postgres for your database.
{% endblocktrans %}
<br/>
<br/>
<h3>{% trans 'Search Methods' %}</h3>
<div class="card">
<div class="card-body">
<p> {% blocktrans %}
Full text searches attempt to normalize the words provided to match common variants. For example: 'forked', 'forking', 'forks' will all normalize to 'fork'.
There are several methods available, described below, that will control how the search behavior should react when multiple words are searched.
Full technical details on how these operate can be viewed on <a href=https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES>Postgresql's website.</a>
{% endblocktrans %}</p>
<h4>{% trans 'Simple' %}</h4>
<p> {% blocktrans %}
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
{% endblocktrans %}</p>
<h4>{% trans 'Phrase' %}</h4>
<p> {% blocktrans %}
Phrase searches ignore punctuation, but will search for all of the words in the exact order provided.
Searching for 'apple or flour' will only return a recipe that includes the exact phrase 'apple or flour' in any of the fields that have been selected for a full text search.
{% endblocktrans %}</p>
<h4>{% trans 'Web' %}</h4>
<p> {% blocktrans %}
Web searches simulate functionality found on many web search sites supporting special syntax.
Placing quotes around several words will convert those words into a phrase.
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
{% endblocktrans %}</p>
<h4>{% trans 'Raw' %}</h4>
<p> {% blocktrans %}
Raw search is similar to Web except will take puncuation operators such as '|', '&' and '()'
{% endblocktrans %}</p>
</div>
</div>
<br/>
<h4>fuzzy search</h4>
<div class="card">
<div class="card-body">
{% blocktrans %}
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
{% endblocktrans %}
</div>
</div>
<br/>
<h4>{% trans 'Search Fields' %}</h4>
<div class="card">
<div class="card-body">
{% blocktrans %}
Unaccent is a special case in that it enables searching a field 'unaccented' for each search style attempting to ignore accented values.
For example when you enable unaccent for 'Name' any search (starts with, contains, trigram) will attempt the search ignoring accented characters.
For the other options, you can enable search on any or all fields and they will be combined together with an assumed 'OR'.
For example enabling 'Name' for Starts With, 'Name' and 'Description' for Partial Match and 'Ingredients' and 'Keywords' for Full Search
and searching for 'apple' will generate a search that will return recipes that have:
- A recipe name that starts with 'apple'
- OR a recipe name that contains 'apple'
- OR a recipe description that contains 'apple'
- OR a recipe that will have a full text search match ('apple' or 'apples') in ingredients
- OR a recipe that will have a full text search match in Keywords
Combining too many fields in too many types of search can have a negative impact on performance, create duplicate results or return unexpected results.
For example, enabling fuzzy search or partial matches will interfere with web search methods.
Searching for 'apple -pie' with fuzzy search and full text search will return the recipe Apple Pie. Though it is not included in the full text results, it does match the trigram results.
{% endblocktrans %}
</div>
</div>
<br/>
<h4>{% trans 'Search Index' %}</h4>
<div class="card">
<div class="card-body">
{% blocktrans %}
Trigram search and Full Text Search both rely on database indexes to perform effectively.
You can rebuild the indexes on all fields in the Admin page for Recipes and selecting all recipes and running 'rebuild index for selected recipes'
You can also rebuild indexes at the command line by executing the management command 'python manage.py rebuildindex'
{% endblocktrans %}
</div>
</div>
<br>
<br>
<br>
{% endblock %}

View File

@ -21,27 +21,35 @@
<!-- Nav tabs -->
<ul class="nav nav-tabs" id="myTab" role="tablist" style="margin-bottom: 2vh">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="account-tab" data-toggle="tab" href="#account" role="tab"
aria-controls="account" aria-selected="true">{% trans 'Account' %}</a>
<a class="nav-link {% if active_tab == 'account' %} active {% endif %}" id="account-tab" data-toggle="tab" href="#account" role="tab"
aria-controls="account"
aria-selected="{% if active_tab == 'account' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Account' %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="preferences-tab" data-toggle="tab" href="#preferences" role="tab"
aria-controls="preferences" aria-selected="false">{% trans 'Preferences' %}</a>
<a class="nav-link {% if active_tab == 'prefernces' %} active {% endif %}" id="preferences-tab" data-toggle="tab" href="#preferences" role="tab"
aria-controls="preferences"
aria-selected="{% if active_tab == 'prefernces' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Preferences' %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="api-tab" data-toggle="tab" href="#api" role="tab" aria-controls="api"
aria-selected="false">{% trans 'API-Settings' %}</a>
<a class="nav-link {% if active_tab == 'api' %} active {% endif %}" id="api-tab" data-toggle="tab" href="#api" role="tab"
aria-controls="api"
aria-selected="{% if active_tab == 'api' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'API-Settings' %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="search-tab" data-toggle="tab" href="#search" role="tab" aria-controls="search"
aria-selected="false">{% trans 'Search-Settings' %}</a>
<a class="nav-link {% if active_tab == 'search' %} active {% endif %}" id="search-tab" data-toggle="tab" href="#search" role="tab"
aria-controls="search"
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Search-Settings' %}</a>
</li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div class="tab-pane active" id="account" role="tabpanel" aria-labelledby="account-tab">
<div class="tab-pane {% if active_tab == 'account' %} active {% endif %}" id="account" role="tabpanel" aria-labelledby="account-tab">
<h4>{% trans 'Name Settings' %}</h4>
<form action="." method="post">
{% csrf_token %}
@ -62,7 +70,7 @@
<br/>
</div>
<div class="tab-pane" id="preferences" role="tabpanel" aria-labelledby="preferences-tab">
<div class="tab-pane {% if active_tab == 'preferences' %} active {% endif %}" id="preferences" role="tabpanel" aria-labelledby="preferences-tab">
<div class="row">
@ -113,7 +121,7 @@
</div>
<div class="tab-pane" id="api" role="tabpanel" aria-labelledby="api-tab">
<div class="tab-pane {% if active_tab == 'api' %} active {% endif %}" id="api" role="tabpanel" aria-labelledby="api-tab">
<div class="row">
<div class="col col-md-12">
@ -145,7 +153,7 @@
</div>
<div class="tab-pane" id="search" role="tabpanel" aria-labelledby="search-tab">
<div class="tab-pane {% if active_tab == 'search' %} active {% endif %}" id="search" role="tabpanel" aria-labelledby="search-tab">
<h4>{% trans 'Search Settings' %}</h4>
<form action="." method="post">
{% csrf_token %}

View File

@ -118,6 +118,7 @@ urlpatterns = [
path('telegram/hook/<slug:token>/', telegram.hook, name='telegram_hook'),
path('docs/markdown/', views.markdown_info, name='docs_markdown'),
path('docs/search/', views.search_info, name='docs_search'),
path('docs/api/', views.api_info, name='docs_api'),
path('openapi/', get_schema_view(title="Django Recipes", version=VERSION_NUMBER, public=True,

View File

@ -34,6 +34,8 @@ from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
ViewLogTable, InviteLinkTable)
from cookbook.views.data import Object
from recipes import settings
from recipes.settings import DEMO
from recipes.version import BUILD_REF, VERSION_NUMBER
@ -305,11 +307,14 @@ def user_settings(request):
up = request.user.userpreference
sp = request.user.searchpreference
search_error = False
active_tab = 'account'
user_name_form = UserNameForm(instance=request.user)
if request.method == "POST":
if 'preference_form' in request.POST:
active_tab = 'preferences'
form = UserPreferenceForm(request.POST, prefix='preference')
if form.is_valid():
if not up:
@ -347,37 +352,61 @@ def user_settings(request):
update_session_auth_hash(request, user)
elif 'search_form' in request.POST:
active_tab = 'search'
search_form = SearchPreferenceForm(request.POST, prefix='search')
if form.is_valid():
if search_form.is_valid():
if not sp:
sp = search_form(user=request.user)
sp = SearchPreferenceForm(user=request.user)
fields_searched = (
len(search_form.cleaned_data['icontains'])
+ len(search_form.cleaned_data['istartswith'])
+ len(search_form.cleaned_data['trigram'])
+ len(search_form.cleaned_data['fulltext']))
# TODO add 'recommended' option
if fields_searched == 0:
search_form.add_error(None, _('You must select at least one field to search!'))
search_error = True
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['fulltext']) == 0:
search_form.add_error('search', _('To use this search method you must select at least one full text search field!'))
search_error = True
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['trigram']) > 0:
search_form.add_error(None, _('Fuzzy search is not compatible with this search method!'))
search_error = True
else:
sp.search = search_form.cleaned_data['search']
sp.unaccent.set(search_form.cleaned_data['unaccent'])
sp.icontains.set(search_form.cleaned_data['icontains'])
sp.istartswith.set(search_form.cleaned_data['istartswith'])
sp.trigram.set(search_form.cleaned_data['trigram'])
sp.fulltext.set(search_form.cleaned_data['fulltext'])
sp.search = search_form.cleaned_data['search']
sp.unaccent = search_form.cleaned_data['unaccent']
sp.icontains = search_form.cleaned_data['icontains']
sp.istartswith = search_form.cleaned_data['istartswith']
sp.trigram = search_form.cleaned_data['trigram']
sp.fulltext = search_form.cleaned_data['fulltext']
sp.save()
sp.save()
if up:
preference_form = UserPreferenceForm(instance=up)
else:
preference_form = UserPreferenceForm()
if sp:
preference_form = SearchPreferenceForm(instance=sp)
else:
preference_form = SearchPreferenceForm()
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(sp.fulltext.all())
if sp and not search_error and fields_searched > 0:
search_form = SearchPreferenceForm(instance=sp)
elif not search_error:
search_form = SearchPreferenceForm()
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
# these fields require postgress - just disable them if postgress isn't available
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
search_form.fields['search'].disabled = True
search_form.fields['trigram'].disabled = True
search_form.fields['fulltext'].disabled = True
return render(request, 'settings.html', {
'preference_form': preference_form,
'user_name_form': user_name_form,
'api_token': api_token,
'search_form': search_form
'search_form': search_form,
'active_tab': active_tab
})
@ -560,6 +589,10 @@ def markdown_info(request):
return render(request, 'markdown_info.html', {})
def search_info(request):
return render(request, 'search_info.html', {})
@group_required('guest')
def api_info(request):
return render(request, 'api_info.html', {})