From 4af72a43e5d2b96a821d1dfb0e77c51a2b9fc823 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Wed, 17 Nov 2021 04:50:39 +0000 Subject: [PATCH] Load more search results, maintain scroll position --- apiserver/apiserver/api/utils.py | 4 + apiserver/apiserver/api/views.py | 97 +++++++++++----------- webclient/src/Members.js | 138 +++++++++++++++++++++---------- 3 files changed, 148 insertions(+), 91 deletions(-) diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py index 55b3cce..d802e20 100644 --- a/apiserver/apiserver/api/utils.py +++ b/apiserver/apiserver/api/utils.py @@ -146,6 +146,8 @@ def gen_search_strings(): ''' Generate a cache dict of names to member ids for rapid string matching ''' + start = time.time() + search_strings = {} for m in models.Member.objects.order_by('-expire_date'): string = '{} {} | {} {}'.format( @@ -165,6 +167,8 @@ def gen_search_strings(): search_strings[string] = m.id cache.set('search_strings', search_strings) + logger.info('Generated search strings in %s s.', time.time() - start) + LARGE_SIZE = 1080 MEDIUM_SIZE = 220 diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index 13c0d79..c0ddd0b 100644 --- a/apiserver/apiserver/api/views.py +++ b/apiserver/apiserver/api/views.py @@ -4,7 +4,7 @@ logger = logging.getLogger(__name__) from django.contrib.auth.models import User, Group from django.shortcuts import get_object_or_404, redirect from django.db import transaction -from django.db.models import Max, F +from django.db.models import Max, F, Count from django.db.utils import OperationalError from django.http import HttpResponse, Http404, FileResponse from django.core.files.base import File @@ -43,7 +43,7 @@ Create = mixins.CreateModelMixin Update = mixins.UpdateModelMixin Destroy = mixins.DestroyModelMixin -NUM_SEARCH_RESULTS = 20 +NUM_RESULTS = 100 class SearchViewSet(Base, Retrieve): @@ -79,54 +79,57 @@ class SearchViewSet(Base, Retrieve): if len(results) == 0 and len(search) >= 3 and '@' not in search: # then get fuzzy matches, but not for emails - fuzzy_results = process.extract(search, choices, limit=NUM_SEARCH_RESULTS, scorer=fuzz.token_set_ratio) + fuzzy_results = process.extract(search, choices, limit=20, scorer=fuzz.token_set_ratio) results += [x[0] for x in fuzzy_results] # remove dupes, truncate list - results = list(OrderedDict.fromkeys(results))[:NUM_SEARCH_RESULTS] + results = list(OrderedDict.fromkeys(results))[:20] result_ids = [search_strings[x] for x in results] result_objects = [queryset.get(id=x) for x in result_ids] queryset = result_objects logging.info('Search for: {}, results: {}'.format(search, len(queryset))) - elif self.action == 'create' and sort == 'recently_vetted': - utils.gen_search_strings() # update cache - queryset = queryset.order_by('-vetted_date') - elif self.action == 'create' and sort == 'newest_active': - queryset = queryset.filter(paused_date__isnull=True) - queryset = queryset.order_by('-application_date') - elif self.action == 'create' and sort == 'newest_overall': - queryset = queryset.order_by('-application_date') - elif self.action == 'create' and sort == 'oldest_active': - queryset = queryset.filter(paused_date__isnull=True) - queryset = queryset.order_by('application_date') - elif self.action == 'create' and sort == 'oldest_overall': - queryset = queryset.filter(application_date__isnull=False) - queryset = queryset.order_by('application_date') - elif self.action == 'create' and sort == 'recently_inactive': - queryset = queryset.filter(paused_date__isnull=False) - queryset = queryset.order_by('-paused_date') - elif self.action == 'create' and sort == 'is_director': - queryset = queryset.filter(is_director=True) - queryset = queryset.order_by('application_date') - elif self.action == 'create' and sort == 'is_instructor': - queryset = queryset.filter(is_instructor=True) - queryset = queryset.order_by('application_date') - elif self.action == 'create' and sort == 'due': - queryset = queryset.filter(status='Due') - queryset = queryset.order_by('expire_date') - elif self.action == 'create' and sort == 'overdue': - queryset = queryset.filter(status='Overdue') - queryset = queryset.order_by('expire_date') - elif self.action == 'create' and sort == 'last_scanned': - if self.request.user.member.allow_last_scanned: - queryset = queryset.filter(allow_last_scanned=True) - queryset = queryset.order_by('-user__cards__last_seen') - else: + elif self.action == 'create': + if sort == 'recently_vetted': + queryset = queryset.filter(vetted_date__isnull=False) + queryset = queryset.order_by('-vetted_date') + elif sort == 'newest_active': + queryset = queryset.filter(paused_date__isnull=True) + queryset = queryset.order_by('-application_date') + elif sort == 'newest_overall': + queryset = queryset.order_by('-application_date') + elif sort == 'oldest_active': + queryset = queryset.filter(paused_date__isnull=True) + queryset = queryset.order_by('application_date') + elif sort == 'oldest_overall': + queryset = queryset.filter(application_date__isnull=False) + queryset = queryset.order_by('application_date') + elif sort == 'recently_inactive': + queryset = queryset.filter(paused_date__isnull=False) + queryset = queryset.order_by('-paused_date') + elif sort == 'is_director': + queryset = queryset.filter(is_director=True) + queryset = queryset.order_by('application_date') + elif sort == 'is_instructor': + queryset = queryset.filter(is_instructor=True) + queryset = queryset.order_by('application_date') + elif sort == 'due': + queryset = queryset.filter(status='Due') + queryset = queryset.order_by('expire_date') + elif sort == 'overdue': + queryset = queryset.filter(status='Overdue') + queryset = queryset.order_by('expire_date') + elif sort == 'last_scanned': + if self.request.user.member.allow_last_scanned: + queryset = queryset.filter(allow_last_scanned=True) + queryset = queryset.order_by('-user__cards__last_seen') + else: + queryset = [] + elif sort == 'everyone': + queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count') + elif sort == 'best_looking': queryset = [] - elif self.action == 'create' and sort == 'best_looking': - queryset = [] return queryset @@ -139,19 +142,19 @@ class SearchViewSet(Base, Retrieve): seq = 0 search = self.request.data.get('q', '').lower() - if search: - num_results = NUM_SEARCH_RESULTS - else: - num_results = 100 + page = self.request.data.get('page', 0) + queryset = self.get_queryset() + total = len(queryset) - queryset = self.get_queryset()[:num_results] + start = int(page) * NUM_RESULTS - 80 + queryset = queryset[max(start,0):start+NUM_RESULTS] if self.request.user.member.vetted_date: serializer = serializers.VettedSearchSerializer(queryset, many=True) else: serializer = serializers.SearchSerializer(queryset, many=True) - return Response({'seq': seq, 'results': serializer.data}) + return Response({'seq': seq, 'results': serializer.data, 'total': total}) class MemberViewSet(Base, Retrieve, Update): @@ -168,11 +171,13 @@ class MemberViewSet(Base, Retrieve, Update): member = serializer.save() utils.tally_membership_months(member) utils.gen_member_forms(member) + utils.gen_search_strings() def perform_update(self, serializer): member = serializer.save() utils.tally_membership_months(member) utils.gen_member_forms(member) + utils.gen_search_strings() @action(detail=True, methods=['post']) def pause(self, request, pk=None): diff --git a/webclient/src/Members.js b/webclient/src/Members.js index 02f4328..e4086aa 100644 --- a/webclient/src/Members.js +++ b/webclient/src/Members.js @@ -16,11 +16,12 @@ const memberSorts = { //newest_overall: 'Newest Overall', oldest_active: 'Oldest', //oldest_overall: 'Oldest Overall', - recently_inactive: 'Inactive', + recently_inactive: 'Recently Inactive', is_director: 'Directors', is_instructor: 'Instructors', due: 'Due', overdue: 'Overdue', + everyone: 'Everyone', }; export function MembersDropdown(props) { @@ -65,28 +66,94 @@ export function MembersDropdown(props) { ); }; -let numShowCache = 20; +let responseCache = false; +let pageCache = 0; +let sortCache = ''; +let searchCache = ''; + +const loadMoreStrings = [ + 'Load More', + 'Load EVEN More', + 'Load WAY More', + 'Why did you stop? LOAD MORE!', + 'GIVE ME MORE NAMES!!', + 'Shower me with names, baby', + 'I don\'t care about the poor server, MORE NAMES!', + 'Names make me hotter than two rats in a wool sock', + 'Holy shit, I can\'t get enough names', + 'I don\'t have anything better to do than LOAD NAMES!', + 'I need names because I love N̶a̸M̸E̵S̴ it\'s not to late to stop but I can\'t because it feels so good god help me', + 'The One who loads the names will liquify the NERVES of the sentient whilst I o̴̭̐b̴̙̾s̷̺͝ē̶̟r̷̦̓v̸͚̐ę̸̈́ ̷̞̒t̸͘ͅh̴͂͜e̵̜̕i̶̾͜r̷̃͜ ̵̹͊Ḷ̷͝Ȍ̸͚Ä̶̘́D̴̰́I̸̧̚N̵͖̎G̷̣͒', + 'The Song of Names will will e̶̟̤͋x̷̜̀͘͜t̴̳̀i̸̪͑̇n̷̘̍g̵̥̗̓ṳ̴̑̈́i̷͚̿s̸̨̪̓ḣ̶̡̓ ̷̲͊ṫ̴̫h̸̙͕͗ḛ̸̡̃̈́ ̷̘̫̉̏v̸̧̟͗̕o̴͕̾͜i̷̢͛̿ͅc̴͕̥̈́̂ȅ̵͕s̶̹͋̀ ̶̰́͜͠ǒ̷̰̯f̵̛̥̊ ̸̟̟̒͝m̸̯̀̂o̶̝͛̌͜r̸̞̀ṫ̴̥͗ä̶̢́l̶̯̄͘ ̵̫̈́m̷̦̑̂ą̶͕͝ṋ̴̎͝ from the sphere I can see it can you see it it is beautiful', + 'The final suffering of T̷̯̂͝H̴̰̏̉Ḛ̸̀̓ ̷̟̒ͅN̷̠̾Ą̵̟̈́M̶̡̾͝E̸̥̟̐͐S̸̖̍ are lies all is lost the pony he come h̷̲̺͂̾͒̔͝ḙ̶̻͒͠ ̷̙̘͈̬̰̽̽̈́̒͘c̵͎̺̞̰͝ơ̷͚̱̺̰̺͐̏͑͠m̴̖̰̓̈͝ĕ̷̜s̶̛̹̤̦͉̓͝ the í̵̠̞̙̦̱̠̅̊͒̌͊̓͠͠c̴̻̺̙͕̲͚͔̩̥͑ḩ̷̦̰̠̯̳̖̘́̉̾̾͠o̴͈̯̟̣̲͙̦̖̖͍̞̞̻̎͐̊͊̇͋̒͛̅͆̌͂̈̕r̷̡̝̲̜͇͉̣̹̖͕̻̐̑̉̋͋̉͒͋̍́̒͐͐͘ͅ ̵̳̖͕̩̝̮͈̻̣̤͎̟͓̜̄̿̓̈́p̴̰̝͓̣͍̫̞͓̑͌͊͑̓̂̽͑͝e̶̛̪̜̐̋́̆͊͌̋̄́͘r̶̫̬͈͌̔̽m̶̛̱̣͍͌̈́͋̾̈̀͑̽̋̏̊͋͝ę̶̋̀̈̃͠ą̵̡̣̫̮͙͈͚̞̰̠̥͇̣̽̿̉́̔̒͌̓͌̂̌̕͜͠t̷̯͚̭̮̠̐͋͆́͛̿́̏̆̚ě̶̢̨̩̞ş̸̢͍̱̻͕̪̗̻͖͇̱̳̽̈́̚͠ ̴͉̝̖̤͚̖̩̻̪̒ͅà̸̙̥̩̠̝̪̰͋́̊̓͌́͒̕͝ĺ̵̖̖͚̱͎̤̟̲̺͎͑͋̐̈́̓͂͆̅̈́̎̆̋̇l̸̢̧̟͉̞͇̱͉̙͇͊̏͐͠ͅ', +]; export function Members(props) { - const history = useHistory(); - const qs = useLocation().search; - const params = new URLSearchParams(qs); - const sort = params.get('sort') || 'recently_vetted'; - const search = params.get('q') || ''; - - const [response, setResponse] = useState(false); - const [numShow, setNumShow] = useState(numShowCache); + const [response, setResponse] = useState(responseCache); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(pageCache); + const [sort, setSort] = useState(sortCache); + const [search, setSearch] = useState(searchCache); const [controller, setController] = useState(false); const { token, user } = props; - const doSearch = (q) => { - console.log('doing search', q); - if (q.length) { - const qs = queryString.stringify({ 'q': q }); - history.replace('/members?' + qs); + const makeRequest = ({loadPage, q, sort_key}) => { + let pageNum = 0; + if (loadPage) { + pageNum = page + 1; + setPage(pageNum); + pageCache = pageNum; } else { setResponse(false); - history.replace('/members'); + setPage(0); + pageCache = 0; + } + + if (controller) { + controller.abort(); + } + const ctl = new AbortController(); + setController(ctl); + const signal = ctl.signal; + + const data = {page: pageNum}; + if (q) data.q = q; + if (sort_key) data.sort = sort_key; + + requester('/search/', 'POST', token, data, signal) + .then(res => { + const r = loadPage ? {...response, results: [...response.results, ...res.results]} : res; + setResponse(r); + responseCache = r; + setLoading(false); + }) + .catch(err => { + console.log('Aborted.'); + }); + } + + const loadMore = () => { + setLoading(true); + makeRequest({loadPage: true, q: search, sort_key: sort}); + }; + + const doSort = (sort_key) => { + setSort(sort_key); + sortCache = sort_key; + setSearch(''); + searchCache = ''; + makeRequest({loadPage: false, sort_key: sort_key}); + }; + + const doSearch = (q) => { + if (q) { + setSearch(q); + searchCache = q; + setSort(''); + sortCache = ''; + makeRequest({loadPage: false, q: q}); + } else { + doSort('recently_vetted'); } }; @@ -96,26 +163,10 @@ export function Members(props) { }; useEffect(() => { - if (controller) { - controller.abort(); + if (!responseCache) { + doSort('recently_vetted'); } - const ctl = new AbortController(); - setController(ctl); - const signal = ctl.signal; - - const data = {q: search, sort: sort}; - requester('/search/', 'POST', token, data, signal) - .then(res => { - setResponse(res); - }) - .catch(err => { - ; - }); - }, [search, sort]); - - useEffect(() => { - setResponse(false); - }, [sort]); + }, []); return ( @@ -144,7 +195,7 @@ export function Members(props) { Sort by{' '} {Object.entries(memberSorts).map((x, i) => <> - {x[1]} + doSort(x[0])}>{x[1]} {i < Object.keys(memberSorts).length - 1 && ', '} )}. @@ -164,9 +215,11 @@ export function Members(props) { {response ? <> +

{response.total} results:

+ - {response.results.length ? - response.results.slice(0, numShow).map((x, i) => + {!!response.results.length && + response.results.map((x, i) =>
{i+1}
@@ -181,16 +234,11 @@ export function Members(props) {
) - : -

No Results

}
- {response.results.length > 20 && numShow !== 100 ? -