search preference settings

This commit is contained in:
smilerz 2021-06-04 13:33:02 -05:00
parent a956868eaf
commit 591591e3dc
8 changed files with 723 additions and 617 deletions

View File

@ -10,7 +10,8 @@ from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe, from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User, RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
UserPreference, SupermarketCategory, MealType, Space) UserPreference, SupermarketCategory, MealType, Space,
SearchPreference)
class SelectWidget(widgets.Select): class SelectWidget(widgets.Select):
@ -476,3 +477,38 @@ class UserCreateForm(forms.Form):
attrs={'autocomplete': 'new-password', 'type': 'password'} attrs={'autocomplete': 'new-password', 'type': 'password'}
) )
) )
class SearchPreferenceForm(forms.ModelForm):
prefix = 'search'
class Meta:
model = SearchPreference
fields = ('search', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext')
help_texts = {
'search': _('Select type method of search. Click here 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')"),
'trigram': _("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
'fulltext': _("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields.")
}
labels = {
'search': _('Search Method'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts Wtih"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
}
widgets = {
'search': SelectWidget,
'unaccent': MultiSelectWidget,
'icontains': MultiSelectWidget,
'istartswith': MultiSelectWidget,
'trigram': MultiSelectWidget,
'fulltext': MultiSelectWidget,
}

View File

@ -1,14 +1,14 @@
# Generated by Django 3.1.7 on 2021-04-07 20:00 # Generated by Django 3.1.7 on 2021-04-07 20:00
import annoying.fields
from django.conf import settings from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField, SearchVector from django.contrib.postgres.search import SearchVectorField, SearchVector
from django.db import migrations from django.db import migrations, models
from django.db.models import deletion
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from django.utils import translation from django.utils import translation
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Index from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
def set_default_search_vector(apps, schema_editor): def set_default_search_vector(apps, schema_editor):
@ -17,6 +17,7 @@ def set_default_search_vector(apps, schema_editor):
language = DICTIONARY.get(translation.get_language(), 'simple') language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled(): with scopes_disabled():
# TODO this approach doesn't work terribly well if multiple languages are in use # TODO this approach doesn't work terribly well if multiple languages are in use
# I'm also uncertain about forcing unaccent here
Recipe.objects.all().update( Recipe.objects.all().update(
name_search_vector=SearchVector('name__unaccent', weight='A', config=language), name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language) desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
@ -26,7 +27,8 @@ def set_default_search_vector(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('cookbook', '0121_auto_20210518_1638'), ('auth', '0012_alter_user_first_name_max_length'),
('cookbook', '0123_invitelink_email'),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
@ -80,6 +82,28 @@ class Migration(migrations.Migration):
model_name='viewlog', model_name='viewlog',
index=Index(fields=['recipe', '-created_at'], name='cookbook_vi_recipe__5cd178_idx'), index=Index(fields=['recipe', '-created_at'], name='cookbook_vi_recipe__5cd178_idx'),
), ),
migrations.CreateModel(
name='SearchFields',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=32, unique=True)),
('field', models.CharField(max_length=64, unique=True)),
],
bases=(models.Model, PermissionModelMixin),
),
migrations.CreateModel(
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)),
('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')),
('trigram', models.ManyToManyField(blank=True, related_name='trigram_fields', to='cookbook.SearchFields')),
('unaccent', models.ManyToManyField(blank=True, default=allSearchFields, related_name='unaccent_fields', to='cookbook.SearchFields')),
],
bases=(models.Model, PermissionModelMixin),
),
migrations.RunPython( migrations.RunPython(
set_default_search_vector set_default_search_vector
), ),

View File

@ -0,0 +1,23 @@
from cookbook.models import SearchFields
from django.db import migrations
def create_searchfields(apps, schema_editor):
SearchFields.objects.create(name='Name', field='name')
SearchFields.objects.create(name='Description', field='description')
SearchFields.objects.create(name='Instructions', field='steps__instruction')
SearchFields.objects.create(name='Ingredients', field='steps__ingredients__food__name')
SearchFields.objects.create(name='Keywords', field='keywords__name')
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0124_build_full_text_index'),
]
operations = [
migrations.RunPython(
create_searchfields
),
]

View File

@ -105,7 +105,8 @@ class UserPreference(models.Model, PermissionModelMixin):
COLORS = ( COLORS = (
(PRIMARY, 'Primary'), (PRIMARY, 'Primary'),
(SECONDARY, 'Secondary'), (SECONDARY, 'Secondary'),
(SUCCESS, 'Success'), (INFO, 'Info'), (SUCCESS, 'Success'),
(INFO, 'Info'),
(WARNING, 'Warning'), (WARNING, 'Warning'),
(DANGER, 'Danger'), (DANGER, 'Danger'),
(LIGHT, 'Light'), (LIGHT, 'Light'),
@ -721,3 +722,50 @@ class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
# field names used to configure search behavior - all data populated during data migration
# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
class SearchFields(models.Model, PermissionModelMixin):
name = models.CharField(max_length=32, unique=True)
field = models.CharField(max_length=64, unique=True)
def __str__(self):
return _(self.name)
@staticmethod
def get_name(self):
return _(self.name)
def allSearchFields():
return SearchFields.objects.values_list('id')
def nameSearchField():
return [SearchFields.objects.get(name='Name').id]
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'
SEARCH_STYLE = (
(PLAIN, _('Plain')),
(PHRASE, _('Phrase')),
(WEB, _('Web')),
(RAW, _('Raw'))
)
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
search = models.CharField(choices=SEARCH_STYLE, max_length=32, default=SIMPLE)
unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True, default=allSearchFields)
icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True, default=nameSearchField)
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True)
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)

View File

@ -30,6 +30,10 @@
<a class="nav-link" id="api-tab" data-toggle="tab" href="#api" role="tab" aria-controls="api" <a class="nav-link" id="api-tab" data-toggle="tab" href="#api" role="tab" aria-controls="api"
aria-selected="false">{% trans 'API-Settings' %}</a> aria-selected="false">{% trans 'API-Settings' %}</a>
</li> </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>
</li>
</ul> </ul>
@ -150,6 +154,16 @@
</div> </div>
<div class="tab-pane" id="search" role="tabpanel" aria-labelledby="search-tab">
<h4>{% trans 'Search Settings' %}</h4>
<form action="." method="post">
{% csrf_token %}
{{ search_form|crispy }}
<button class="btn btn-success" type="submit" name="search_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
</div>
</div> </div>
<script type="application/javascript"> <script type="application/javascript">

View File

@ -24,7 +24,8 @@ from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, User, from cookbook.forms import (CommentForm, Recipe, User,
UserCreateForm, UserNameForm, UserPreference, UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm) UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
SearchPreferenceForm)
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan, from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit, RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
@ -54,9 +55,6 @@ def index(request):
return HttpResponseRedirect(reverse('view_search')) return HttpResponseRedirect(reverse('view_search'))
# faceting
# unaccent / likely will perform full table scan
# create tests
def search(request): def search(request):
if has_group_permission(request.user, ('guest',)): if has_group_permission(request.user, ('guest',)):
if request.user.userpreference.search_style == UserPreference.NEW: if request.user.userpreference.search_style == UserPreference.NEW:
@ -291,6 +289,7 @@ def user_settings(request):
return redirect('index') return redirect('index')
up = request.user.userpreference up = request.user.userpreference
sp = request.user.searchpreference
user_name_form = UserNameForm(instance=request.user) user_name_form = UserNameForm(instance=request.user)
password_form = PasswordChangeForm(request.user) password_form = PasswordChangeForm(request.user)
@ -321,24 +320,43 @@ def user_settings(request):
up.save() up.save()
if 'user_name_form' in request.POST: elif 'user_name_form' in request.POST:
user_name_form = UserNameForm(request.POST, prefix='name') user_name_form = UserNameForm(request.POST, prefix='name')
if user_name_form.is_valid(): if user_name_form.is_valid():
request.user.first_name = user_name_form.cleaned_data['first_name'] request.user.first_name = user_name_form.cleaned_data['first_name']
request.user.last_name = user_name_form.cleaned_data['last_name'] request.user.last_name = user_name_form.cleaned_data['last_name']
request.user.save() request.user.save()
if 'password_form' in request.POST: elif 'password_form' in request.POST:
password_form = PasswordChangeForm(request.user, request.POST) password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid(): if password_form.is_valid():
user = password_form.save() user = password_form.save()
update_session_auth_hash(request, user) update_session_auth_hash(request, user)
elif 'search_form' in request.POST:
search_form = SearchPreferenceForm(request.POST, prefix='search')
if form.is_valid():
if not sp:
sp = search_form(user=request.user)
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()
if up: if up:
preference_form = UserPreferenceForm(instance=up) preference_form = UserPreferenceForm(instance=up)
else: else:
preference_form = UserPreferenceForm() preference_form = UserPreferenceForm()
if sp:
preference_form = SearchPreferenceForm(instance=sp)
else:
preference_form = SearchPreferenceForm()
if (api_token := Token.objects.filter(user=request.user).first()) is None: if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user) api_token = Token.objects.create(user=request.user)
@ -347,6 +365,7 @@ def user_settings(request):
'user_name_form': user_name_form, 'user_name_form': user_name_form,
'password_form': password_form, 'password_form': password_form,
'api_token': api_token, 'api_token': api_token,
'search_form': search_form
}) })

View File

@ -1 +1 @@
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"..\\..\\templates\\sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css\\chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js\\chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js\\import_response_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js\\offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js\\recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js\\recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js\\supermarket_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}} {"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"../../templates/sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css/chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js/chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js/import_response_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js/offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js/recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js/recipe_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}

File diff suppressed because it is too large Load Diff