Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates to class-based view for filterable lists #4173

Merged
merged 9 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/core/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,8 +649,23 @@ def __init__(self, *args, **kwargs):
),
)

elif facet['type'] == 'integer':
self.fields[facet_key] = forms.IntegerField(
required=False,
)

elif facet['type'] == 'search':
self.fields[facet_key] = forms.CharField(
required=False,
widget=forms.TextInput(
attrs={'type': 'search'}
),
)

elif facet['type'] == 'boolean':
pass
self.fields[facet_key] = forms.BooleanField(
required=False,
)

self.fields[facet_key].label = facet['field_label']

Expand Down
23 changes: 23 additions & 0 deletions src/core/model_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from django import forms
from django.apps import apps
from django.contrib import admin
from django.contrib.postgres.lookups import SearchLookup as PGSearchLookup
from django.contrib.postgres.search import (
SearchVector as DjangoSearchVector,
Expand Down Expand Up @@ -572,6 +573,24 @@ def set_source_expressions(self, _):
template = '%(expressions)s'


def search_model_admin(request, model, q=None, queryset=None):
"""
A simple search using the admin search functionality,
for use in class-based views where our methods for
article search do not suit.
:param request: A Django request object
:param model: Any model that has search_fields specified in its admin
:param q: the search term
:param queryset: a pre-existing queryset to filter by the search term
"""
if not q:
q = request.POST['q'] if request.POST else request.GET['q']
if not queryset:
queryset = model.objects.all()
registered_admin = admin.site._registry[model]
return registered_admin.get_search_results(request, queryset, q)


class JanewayBleachField(BleachField):
""" An override of BleachField to avoid casting SafeString from db
Bleachfield automatically casts the default return type (string) into
Expand Down Expand Up @@ -656,3 +675,7 @@ class DateTimePickerModelField(models.DateTimeField):
def formfield(self, **kwargs):
kwargs['form_class'] = DateTimePickerFormField
return super().formfield(**kwargs)

@property
def NotImplementedField(self):
raise NotImplementedError
28 changes: 27 additions & 1 deletion src/core/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import IntegrityError
from django.http import HttpRequest, QueryDict
from django.forms import Form
from django.test import TestCase
from django.utils import timezone
from freezegun import freeze_time

from core import forms, models
from core.model_utils import merge_models, SVGImageFieldForm
from core.model_utils import (
merge_models,
SVGImageFieldForm,
search_model_admin,
)
from journal import models as journal_models
from utils.testing import helpers
from submission import models as submission_models
Expand Down Expand Up @@ -371,3 +376,24 @@ def test_last_modified_model_recursive_doubly_linked(self):

# Test
self.assertEqual(self.article.best_last_modified_date(), file_date)


class TestModelUtils(TestCase):

@classmethod
def setUpTestData(cls):
cls.account = helpers.create_user(
'[email protected]',
)

def test_search_model_admin(self):
request = HttpRequest()
request.GET = QueryDict('q=Ab6CrWPPxQ7FoLj5dgdH')
results, _duplicates = search_model_admin(
request,
models.Account,
)
self.assertIn(
self.account,
results,
)
63 changes: 34 additions & 29 deletions src/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from django.views import generic

from core import models, forms, logic, workflow, models as core_models
from core.model_utils import NotImplementedField, search_model_admin
from security.decorators import (
editor_user_required, article_author_required, has_journal,
any_editor_user_required, role_can_access,
Expand Down Expand Up @@ -1198,9 +1199,10 @@ def add_user(request):
if registration_form.is_valid():
new_user = registration_form.save()
# Every new user is given the author role
new_user.add_account_role('author', request.journal)
if request.journal:
new_user.add_account_role('author', request.journal)

if role:
if role and request.journal:
new_user.add_account_role(role, request.journal)

form = forms.EditAccountForm(
Expand Down Expand Up @@ -2405,19 +2407,20 @@ def manage_access_requests(request):
)


class FilteredArticlesListView(generic.ListView):
joemull marked this conversation as resolved.
Show resolved Hide resolved
class GenericFacetedListView(generic.ListView):
"""
This is a base class for article list views.
It does not have access controls applied because some public views use it.
For staff views, be sure to filter to published articles in get_queryset.
Do not use this view directly.
This is a generic base class for creating filterable list views
with Janeway models.
"""
model = NotImplementedField
template_name = NotImplementedField

model = submission_models.Article
template_name = 'core/manager/article_list.html'
paginate_by = '25'
facets = {}

# These fields will receive a single initial value, not a list
single_value_fields = {'date_time', 'date', 'integer', 'search', 'boolean'}

# None or integer
action_queryset_chunk_size = None

Expand All @@ -2437,12 +2440,11 @@ def get_context_data(self, **kwargs):
context['paginate_by'] = params_querydict.get('paginate_by', self.paginate_by)
facets = self.get_facets()

# Most initial values are in list form
# The exception is date_time facets
initial = dict(params_querydict.lists())

for keyword, value in initial.items():
if keyword in facets:
if facets[keyword]['type'] in ['date_time', 'date']:
if facets[keyword]['type'] in self.single_value_fields:
initial[keyword] = value[0]

context['facet_form'] = forms.CBVFacetForm(
Expand Down Expand Up @@ -2486,26 +2488,31 @@ def get_queryset(self, params_querydict=None):
# The following line prevents the user from passing any parameters
# other than those specified in the facets.
if keyword in facets and value_list:
if value_list[0]:
predicates = [(keyword, value) for value in value_list]
elif facets[keyword]['type'] not in ['date_time', 'date']:
if value_list[0] == '':
predicates = [(keyword, '')]
if facets[keyword]['type'] == 'search' and value_list[0]:
self.queryset, _duplicates = search_model_admin(
self.request,
self.model,
q=value_list[0],
queryset=self.queryset,
)
predicates = []
elif facets[keyword]['type'] == 'boolean':
if value_list[0]:
predicates = [(keyword, True)]
else:
predicates = [(keyword+'__isnull', True)]
predicates = [(keyword, False)]
elif value_list[0]:
predicates = [(keyword, value) for value in value_list]
else:
predicates = []
query = Q()
for predicate in predicates:
query |= Q(predicate)
q_stack.append(query)
return self.order_queryset(
self.filter_queryset_if_journal(
self.queryset.filter(*q_stack)
)
).exclude(
stage=submission_models.STAGE_UNSUBMITTED,
self.queryset = self.filter_queryset_if_journal(
self.queryset.filter(*q_stack)
)
return self.order_queryset(self.queryset)

def order_queryset(self, queryset):
order_by = self.get_order_by()
Expand Down Expand Up @@ -2534,13 +2541,11 @@ def get_facets(self):
def get_facet_queryset(self):
# The default behavior is for the facets to stay the same
# when a filter is chosen.
# To make them change dynamically, return None
# To make them change dynamically, return None
# instead of a separate facet.
# return None
queryset = self.filter_queryset_if_journal(
super().get_queryset()
).exclude(
stage=submission_models.STAGE_UNSUBMITTED
)
facets = self.get_facets()
for facet in facets.values():
Expand All @@ -2567,7 +2572,7 @@ def post(self, request, *args, **kwargs):

if request.journal:
querysets.extend(self.split_up_queryset_if_needed(queryset))
else:
elif hasattr(self.model, 'journal'):
for journal in journal_models.Journal.objects.all():
journal_queryset = queryset.filter(journal=journal)
if journal_queryset:
Expand Down Expand Up @@ -2598,7 +2603,7 @@ def split_up_queryset_if_needed(self, queryset):
return [queryset]

def filter_queryset_if_journal(self, queryset):
if self.request.journal:
if self.request.journal and hasattr(self.model, 'journal'):
return queryset.filter(journal=self.request.journal)
else:
return queryset
Expand Down
5 changes: 2 additions & 3 deletions src/identifiers/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@

from identifiers import models, forms
from submission import models as submission_models
from core import views as core_views
from journal import models as journal_models
from journal import models as journal_models, views as journal_views

from security.decorators import production_user_or_editor_required, editor_user_required
from identifiers import logic
Expand Down Expand Up @@ -321,7 +320,7 @@ def delete_identifier(request, article_id, identifier_id):


@method_decorator(editor_user_required, name='dispatch')
class IdentifierManager(core_views.FilteredArticlesListView):
class IdentifierManager(journal_views.FacetedArticlesListView):
template_name = 'core/manager/identifier_manager.html'

# None or integer
Expand Down
26 changes: 25 additions & 1 deletion src/journal/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2650,9 +2650,33 @@ def manage_languages(request):
)


class FacetedArticlesListView(core_views.GenericFacetedListView):
"""
This is a base class for article list views.
It does not have access controls applied because some public views use it.
For staff views, be sure to filter to published articles in get_queryset.
Do not use this view directly.
This view can also be subclassed and modified for use with other models.
"""
model = submission_models.Article
template_name = 'core/manager/article_list.html'

def get_queryset(self, params_querydict=None):
self.queryset = super().get_queryset(params_querydict=params_querydict)
return self.queryset.exclude(
stage=submission_models.STAGE_UNSUBMITTED
)

def get_facet_queryset(self, **kwargs):
queryset = super().get_facet_queryset(**kwargs)
return queryset.exclude(
stage=submission_models.STAGE_UNSUBMITTED
)


@method_decorator(has_journal, name='dispatch')
@method_decorator(decorators.frontend_enabled, name='dispatch')
class PublishedArticlesListView(core_views.FilteredArticlesListView):
class PublishedArticlesListView(FacetedArticlesListView):

"""
A list of published articles that can be searched,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ <h3>{% trans 'Filter' %}</h3>
<form method="GET" id={{ facet_form.id }}>
{{ facet_form|foundation }}
<button action="" class="button" type="submit">{% trans 'Apply' %}</button>
<a href="{{ request.path }}" class="button warning">{% trans 'Clear all' %}</a>
</form>
</section>
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ <h3>{% trans 'Filter' %}</h3>
<form method="GET" id={{ facet_form.id }}>
{% bootstrap_form facet_form %}
<button action="" class="btn btn-primary" type="submit">{% trans 'Apply' %}</button>
<a href="{{ request.path }}" class="btn btn-secondary">{% trans 'Clear all' %}</a>
</form>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ <h3>Filter</h3>
{{ facet_form|materializecss }}
<div class="col s12">
<button action="" class="btn" type="submit">Apply</button>
<a href="{{ request.path }}" class="btn">{% trans 'Clear all' %}</a>
</div>
</form>
</div>
Expand Down