Load more search results, maintain scroll position

This commit is contained in:
Tanner Collin 2021-11-17 04:50:39 +00:00
parent 42ad1ac327
commit 4af72a43e5
3 changed files with 148 additions and 91 deletions

View File

@ -146,6 +146,8 @@ def gen_search_strings():
''' '''
Generate a cache dict of names to member ids for rapid string matching Generate a cache dict of names to member ids for rapid string matching
''' '''
start = time.time()
search_strings = {} search_strings = {}
for m in models.Member.objects.order_by('-expire_date'): for m in models.Member.objects.order_by('-expire_date'):
string = '{} {} | {} {}'.format( string = '{} {} | {} {}'.format(
@ -165,6 +167,8 @@ def gen_search_strings():
search_strings[string] = m.id search_strings[string] = m.id
cache.set('search_strings', search_strings) cache.set('search_strings', search_strings)
logger.info('Generated search strings in %s s.', time.time() - start)
LARGE_SIZE = 1080 LARGE_SIZE = 1080
MEDIUM_SIZE = 220 MEDIUM_SIZE = 220

View File

@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.db import transaction 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.db.utils import OperationalError
from django.http import HttpResponse, Http404, FileResponse from django.http import HttpResponse, Http404, FileResponse
from django.core.files.base import File from django.core.files.base import File
@ -43,7 +43,7 @@ Create = mixins.CreateModelMixin
Update = mixins.UpdateModelMixin Update = mixins.UpdateModelMixin
Destroy = mixins.DestroyModelMixin Destroy = mixins.DestroyModelMixin
NUM_SEARCH_RESULTS = 20 NUM_RESULTS = 100
class SearchViewSet(Base, Retrieve): class SearchViewSet(Base, Retrieve):
@ -79,54 +79,57 @@ class SearchViewSet(Base, Retrieve):
if len(results) == 0 and len(search) >= 3 and '@' not in search: if len(results) == 0 and len(search) >= 3 and '@' not in search:
# then get fuzzy matches, but not for emails # 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] results += [x[0] for x in fuzzy_results]
# remove dupes, truncate list # 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_ids = [search_strings[x] for x in results]
result_objects = [queryset.get(id=x) for x in result_ids] result_objects = [queryset.get(id=x) for x in result_ids]
queryset = result_objects queryset = result_objects
logging.info('Search for: {}, results: {}'.format(search, len(queryset))) logging.info('Search for: {}, results: {}'.format(search, len(queryset)))
elif self.action == 'create' and sort == 'recently_vetted': elif self.action == 'create':
utils.gen_search_strings() # update cache if sort == 'recently_vetted':
queryset = queryset.order_by('-vetted_date') queryset = queryset.filter(vetted_date__isnull=False)
elif self.action == 'create' and sort == 'newest_active': queryset = queryset.order_by('-vetted_date')
queryset = queryset.filter(paused_date__isnull=True) elif sort == 'newest_active':
queryset = queryset.order_by('-application_date') queryset = queryset.filter(paused_date__isnull=True)
elif self.action == 'create' and sort == 'newest_overall': queryset = queryset.order_by('-application_date')
queryset = queryset.order_by('-application_date') elif sort == 'newest_overall':
elif self.action == 'create' and sort == 'oldest_active': queryset = queryset.order_by('-application_date')
queryset = queryset.filter(paused_date__isnull=True) elif sort == 'oldest_active':
queryset = queryset.order_by('application_date') queryset = queryset.filter(paused_date__isnull=True)
elif self.action == 'create' and sort == 'oldest_overall': queryset = queryset.order_by('application_date')
queryset = queryset.filter(application_date__isnull=False) elif sort == 'oldest_overall':
queryset = queryset.order_by('application_date') queryset = queryset.filter(application_date__isnull=False)
elif self.action == 'create' and sort == 'recently_inactive': queryset = queryset.order_by('application_date')
queryset = queryset.filter(paused_date__isnull=False) elif sort == 'recently_inactive':
queryset = queryset.order_by('-paused_date') queryset = queryset.filter(paused_date__isnull=False)
elif self.action == 'create' and sort == 'is_director': queryset = queryset.order_by('-paused_date')
queryset = queryset.filter(is_director=True) elif sort == 'is_director':
queryset = queryset.order_by('application_date') queryset = queryset.filter(is_director=True)
elif self.action == 'create' and sort == 'is_instructor': queryset = queryset.order_by('application_date')
queryset = queryset.filter(is_instructor=True) elif sort == 'is_instructor':
queryset = queryset.order_by('application_date') queryset = queryset.filter(is_instructor=True)
elif self.action == 'create' and sort == 'due': queryset = queryset.order_by('application_date')
queryset = queryset.filter(status='Due') elif sort == 'due':
queryset = queryset.order_by('expire_date') queryset = queryset.filter(status='Due')
elif self.action == 'create' and sort == 'overdue': queryset = queryset.order_by('expire_date')
queryset = queryset.filter(status='Overdue') elif sort == 'overdue':
queryset = queryset.order_by('expire_date') queryset = queryset.filter(status='Overdue')
elif self.action == 'create' and sort == 'last_scanned': queryset = queryset.order_by('expire_date')
if self.request.user.member.allow_last_scanned: elif sort == 'last_scanned':
queryset = queryset.filter(allow_last_scanned=True) if self.request.user.member.allow_last_scanned:
queryset = queryset.order_by('-user__cards__last_seen') queryset = queryset.filter(allow_last_scanned=True)
else: 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 = [] queryset = []
elif self.action == 'create' and sort == 'best_looking':
queryset = []
return queryset return queryset
@ -139,19 +142,19 @@ class SearchViewSet(Base, Retrieve):
seq = 0 seq = 0
search = self.request.data.get('q', '').lower() search = self.request.data.get('q', '').lower()
if search: page = self.request.data.get('page', 0)
num_results = NUM_SEARCH_RESULTS queryset = self.get_queryset()
else: total = len(queryset)
num_results = 100
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: if self.request.user.member.vetted_date:
serializer = serializers.VettedSearchSerializer(queryset, many=True) serializer = serializers.VettedSearchSerializer(queryset, many=True)
else: else:
serializer = serializers.SearchSerializer(queryset, many=True) 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): class MemberViewSet(Base, Retrieve, Update):
@ -168,11 +171,13 @@ class MemberViewSet(Base, Retrieve, Update):
member = serializer.save() member = serializer.save()
utils.tally_membership_months(member) utils.tally_membership_months(member)
utils.gen_member_forms(member) utils.gen_member_forms(member)
utils.gen_search_strings()
def perform_update(self, serializer): def perform_update(self, serializer):
member = serializer.save() member = serializer.save()
utils.tally_membership_months(member) utils.tally_membership_months(member)
utils.gen_member_forms(member) utils.gen_member_forms(member)
utils.gen_search_strings()
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def pause(self, request, pk=None): def pause(self, request, pk=None):

View File

@ -16,11 +16,12 @@ const memberSorts = {
//newest_overall: 'Newest Overall', //newest_overall: 'Newest Overall',
oldest_active: 'Oldest', oldest_active: 'Oldest',
//oldest_overall: 'Oldest Overall', //oldest_overall: 'Oldest Overall',
recently_inactive: 'Inactive', recently_inactive: 'Recently Inactive',
is_director: 'Directors', is_director: 'Directors',
is_instructor: 'Instructors', is_instructor: 'Instructors',
due: 'Due', due: 'Due',
overdue: 'Overdue', overdue: 'Overdue',
everyone: 'Everyone',
}; };
export function MembersDropdown(props) { 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) { export function Members(props) {
const history = useHistory(); const [response, setResponse] = useState(responseCache);
const qs = useLocation().search; const [loading, setLoading] = useState(false);
const params = new URLSearchParams(qs); const [page, setPage] = useState(pageCache);
const sort = params.get('sort') || 'recently_vetted'; const [sort, setSort] = useState(sortCache);
const search = params.get('q') || ''; const [search, setSearch] = useState(searchCache);
const [response, setResponse] = useState(false);
const [numShow, setNumShow] = useState(numShowCache);
const [controller, setController] = useState(false); const [controller, setController] = useState(false);
const { token, user } = props; const { token, user } = props;
const doSearch = (q) => { const makeRequest = ({loadPage, q, sort_key}) => {
console.log('doing search', q); let pageNum = 0;
if (q.length) { if (loadPage) {
const qs = queryString.stringify({ 'q': q }); pageNum = page + 1;
history.replace('/members?' + qs); setPage(pageNum);
pageCache = pageNum;
} else { } else {
setResponse(false); 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(() => { useEffect(() => {
if (controller) { if (!responseCache) {
controller.abort(); 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 ( return (
<Container> <Container>
@ -144,7 +195,7 @@ export function Members(props) {
Sort by{' '} Sort by{' '}
{Object.entries(memberSorts).map((x, i) => {Object.entries(memberSorts).map((x, i) =>
<> <>
<Link to={'/members?sort='+x[0]} replace>{x[1]}</Link> <a href='javascript:void(0)' onClick={() => doSort(x[0])}>{x[1]}</a>
{i < Object.keys(memberSorts).length - 1 && ', '} {i < Object.keys(memberSorts).length - 1 && ', '}
</> </>
)}. )}.
@ -164,9 +215,11 @@ export function Members(props) {
{response ? {response ?
<> <>
<p>{response.total} results:</p>
<Item.Group unstackable divided> <Item.Group unstackable divided>
{response.results.length ? {!!response.results.length &&
response.results.slice(0, numShow).map((x, i) => response.results.map((x, i) =>
<Item key={x.member.id} as={Link} to={'/members/'+x.member.id}> <Item key={x.member.id} as={Link} to={'/members/'+x.member.id}>
<div className='list-num'>{i+1}</div> <div className='list-num'>{i+1}</div>
<Item.Image size='tiny' src={x.member.photo_small ? staticUrl + '/' + x.member.photo_small : '/nophoto.png'} /> <Item.Image size='tiny' src={x.member.photo_small ? staticUrl + '/' + x.member.photo_small : '/nophoto.png'} />
@ -181,16 +234,11 @@ export function Members(props) {
</Item.Content> </Item.Content>
</Item> </Item>
) )
:
<p>No Results</p>
} }
</Item.Group> </Item.Group>
{response.results.length > 20 && numShow !== 100 ? {!search && response.total !== response.results.length &&
<Button <Button content={loading ? 'Reticulating splines...' : loadMoreStrings[page]} onClick={loadMore} disabled={loading} />
content='Load More'
onClick={() => {setNumShow(100); numShowCache = 100;}}
/> : ''
} }
</> </>
: :