search preference settings
This commit is contained in:
parent
a956868eaf
commit
591591e3dc
@ -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,
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
),
|
),
|
23
cookbook/migrations/0125_create_searchfields.py.stop
Normal file
23
cookbook/migrations/0125_create_searchfields.py.stop
Normal 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
|
||||||
|
),
|
||||||
|
]
|
@ -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)
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"}}}
|
1148
vue/yarn.lock
1148
vue/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user