search preference settings

This commit is contained in:
smilerz
2021-06-04 13:33:02 -05:00
parent f0e56863c5
commit 7c1b5b2d85
8 changed files with 304 additions and 126 deletions

View File

@@ -10,7 +10,8 @@ from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
UserPreference, SupermarketCategory, MealType, Space)
UserPreference, SupermarketCategory, MealType, Space,
SearchPreference)
class SelectWidget(widgets.Select):
@@ -471,3 +472,38 @@ class UserCreateForm(forms.Form):
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
import annoying.fields
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
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.utils import translation
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):
@@ -17,6 +17,7 @@ def set_default_search_vector(apps, schema_editor):
language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled():
# 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(
name_search_vector=SearchVector('name__unaccent', weight='A', 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):
dependencies = [
('cookbook', '0121_auto_20210518_1638'),
('auth', '0012_alter_user_first_name_max_length'),
('cookbook', '0123_invitelink_email'),
]
operations = [
migrations.AddField(
@@ -80,6 +82,28 @@ class Migration(migrations.Migration):
model_name='viewlog',
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(
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

@@ -111,7 +111,8 @@ class UserPreference(models.Model, PermissionModelMixin):
COLORS = (
(PRIMARY, 'Primary'),
(SECONDARY, 'Secondary'),
(SUCCESS, 'Success'), (INFO, 'Info'),
(SUCCESS, 'Success'),
(INFO, 'Info'),
(WARNING, 'Warning'),
(DANGER, 'Danger'),
(LIGHT, 'Light'),
@@ -720,18 +721,48 @@ class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models
space = models.ForeignKey(Space, on_delete=models.CASCADE)
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
file = models.FileField(upload_to='files/')
file_size_kb = models.IntegerField(default=0, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, 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)
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
def __str__(self):
return _(self.name)
def save(self, *args, **kwargs):
if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile):
self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix
self.file_size_kb = round(self.file.size / 1000)
super(UserFile, self).save(*args, **kwargs)
@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

@@ -32,6 +32,10 @@
<a class="nav-link" id="api-tab" data-toggle="tab" href="#api" role="tab" aria-controls="api"
aria-selected="false">{% 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>
</li>
</ul>
@@ -141,6 +145,16 @@
</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>
<script type="application/javascript">

View File

@@ -25,8 +25,8 @@ from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, User,
UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm, AllAuthSignupForm)
from cookbook.helper.ingredient_parser import parse
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
SearchPreferenceForm)
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
@@ -56,9 +56,6 @@ def index(request):
return HttpResponseRedirect(reverse('view_search'))
# faceting
# unaccent / likely will perform full table scan
# create tests
def search(request):
if has_group_permission(request.user, ('guest',)):
if request.user.userpreference.search_style == UserPreference.NEW:
@@ -307,6 +304,7 @@ def user_settings(request):
return redirect('index')
up = request.user.userpreference
sp = request.user.searchpreference
user_name_form = UserNameForm(instance=request.user)
@@ -335,18 +333,43 @@ def user_settings(request):
up.save()
if 'user_name_form' in request.POST:
elif 'user_name_form' in request.POST:
user_name_form = UserNameForm(request.POST, prefix='name')
if user_name_form.is_valid():
request.user.first_name = user_name_form.cleaned_data['first_name']
request.user.last_name = user_name_form.cleaned_data['last_name']
request.user.save()
elif 'password_form' in request.POST:
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
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:
preference_form = UserPreferenceForm(instance=up)
else:
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:
api_token = Token.objects.create(user=request.user)
@@ -354,6 +377,7 @@ def user_settings(request):
'preference_form': preference_form,
'user_name_form': user_name_form,
'api_token': api_token,
'search_form': search_form
})