Load more search results, maintain scroll position
This commit is contained in:
		| @@ -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 | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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;}} |  | ||||||
| 						/> : '' |  | ||||||
| 					} | 					} | ||||||
| 				</> | 				</> | ||||||
| 			: | 			: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user