diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py index cd25121..e9447e9 100644 --- a/apiserver/apiserver/api/serializers.py +++ b/apiserver/apiserver/api/serializers.py @@ -3,6 +3,7 @@ logger = logging.getLogger(__name__) from django.contrib.auth.models import User, Group from django.shortcuts import get_object_or_404 +from django.utils.timezone import now from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.validators import UniqueValidator @@ -10,7 +11,7 @@ from rest_auth.registration.serializers import RegisterSerializer from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, LoginSerializer from rest_auth.serializers import UserDetailsSerializer import re -import time +import datetime, time from . import models, fields, utils, utils_ldap, utils_auth, utils_stats from .. import settings, secrets @@ -229,6 +230,20 @@ class MemberSerializer(serializers.ModelSerializer): logger.info(msg) raise ValidationError(dict(discourse_username='Invalid Discourse username.')) + if validated_data.get('allow_last_scanned', None) == True: + changed = validated_data['allow_last_scanned'] != instance.allow_last_scanned + ONE_WEEK = now() - datetime.timedelta(days=7) + if changed and models.HistoryChange.objects.filter( + field='allow_last_scanned', + index__history_user__member__id=instance.id, + index__owner_id=instance.id, + index__history_date__gte=ONE_WEEK, + ).count() >= 6: + msg = 'Member allow_last_scanned rate limit exceeded by: ' + instance.first_name + ' ' + instance.last_name + utils.alert_tanner(msg) + logger.info(msg) + raise ValidationError(dict(allow_last_scanned='You\'re doing that too often.')) + return super().update(instance, validated_data) # admin viewing member details @@ -384,6 +399,7 @@ class CardSerializer(serializers.ModelSerializer): read_only_fields = [ 'id', 'last_seen', + 'last_seen_at', 'user', ] @@ -536,7 +552,7 @@ class MyRegisterSerializer(RegisterSerializer): if re.search(r'[^a-z.]', username): raise ValidationError('Invalid characters.') if '..' in username: - raise ValidationError('Can\'t have double periods.') + raise ValidationError('Can\'t have double periods. Remove spaces.') if username.startswith('.') or username.endswith('.'): raise ValidationError('Can\'t start or end with periods.') return super().validate_username(username) diff --git a/apiserver/apiserver/api/signals.py b/apiserver/apiserver/api/signals.py index 9acc61a..ae54cac 100644 --- a/apiserver/apiserver/api/signals.py +++ b/apiserver/apiserver/api/signals.py @@ -69,7 +69,7 @@ def post_create_historical_record_callback( is_admin=is_admin_director(history_user), ) - for change in changes: + for num, change in enumerate(changes): change_old = str(change.old) change_new = str(change.new) @@ -84,6 +84,18 @@ def post_create_historical_record_callback( old=change_old, new=change_new, ) + + logger.info('History - {} changed {}\'s {} {}/{}: {} "{}" --> "{}"'.format( + history_user or 'System', + owner[0], + object_name, + num+1, + len(changes), + change.field, + change_old, + change_new, + )) + except BaseException as e: logger.error('History Signal - {} - {}'.format(e.__class__.__name__, e)) logger.info(str(sender)) diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py new file mode 100644 index 0000000..ade5c33 --- /dev/null +++ b/apiserver/apiserver/api/throttles.py @@ -0,0 +1,35 @@ +import logging +logger = logging.getLogger(__name__) + +from rest_framework import throttling + +class LoggingThrottle(throttling.BaseThrottle): + def allow_request(self, request, view): + if request.user.id: + user = '{} ({})'.format(request.user, request.user.member.id) + else: + user = None + + method = request._request.method + path = request._request.path + + if method == 'OPTIONS': + return True + + if path.startswith('/lockout/'): + return True + elif path == '/stats/sign/': + pass + elif path.startswith('/stats/'): + return True + + if request.data: + data = request.data.dict() + for key in ['password', 'password1', 'password2', 'old_password', 'new_password1', 'new_password2']: + if key in data: + data[key] = '[CENSORED]' + else: + data = None + + logging.info('%s %s | User: %s | Data: %s', method, path, user, data) + return True diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py index 96c2796..5e6c2c4 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( @@ -162,6 +164,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 @@ -317,9 +321,9 @@ def create_new_member(data, user): models.Member.objects.create( user=user, - first_name=data['first_name'].title(), - last_name=data['last_name'].title(), - preferred_name=data['first_name'].title(), + first_name=data['first_name'].title().strip(), + last_name=data['last_name'].title().strip(), + preferred_name=data['first_name'].title().strip(), ) def register_user(data, user): diff --git a/apiserver/apiserver/api/utils_stats.py b/apiserver/apiserver/api/utils_stats.py index 54fc990..199869f 100644 --- a/apiserver/apiserver/api/utils_stats.py +++ b/apiserver/apiserver/api/utils_stats.py @@ -26,6 +26,7 @@ DEFAULTS = { 'card_scans': 0, 'track': {}, 'alarm': {}, + 'sign': '', } if secrets.MUMBLE: @@ -158,7 +159,7 @@ def get_progress(request_id): return cache.get('request-progress-' + request_id, []) def set_progress(request_id, data): - logger.info('Request %s progress: %s', request_id, data) + logger.info('Progress - ID: %s | Status: %s', request_id, data) progress = get_progress(request_id) progress.append(data) cache.set('request-progress-' + request_id, progress) diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py index c566615..338d5f8 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): @@ -427,16 +432,13 @@ class PingView(views.APIView): permission_classes = [AllowMetadata | IsAuthenticated] def post(self, request): - d = request.data.dict() - if d: - logger.info(str(d)) return Response(200) class DoorViewSet(viewsets.ViewSet, List): def list(self, request): auth_token = request.META.get('HTTP_AUTHORIZATION', '') - if auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: + if secrets.DOOR_API_TOKEN and auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: raise exceptions.PermissionDenied() cards = models.Card.objects.filter(active_status='card_active') @@ -462,7 +464,7 @@ class DoorViewSet(viewsets.ViewSet, List): member = card.user.member t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p') - logger.info('Time: {} - Name: {} {} ({})'.format(t, member.first_name, member.last_name, member.id)) + logger.info('Scan - Time: {} | Name: {} {} ({})'.format(t, member.first_name, member.last_name, member.id)) utils_stats.calc_card_scans() @@ -472,7 +474,7 @@ class DoorViewSet(viewsets.ViewSet, List): class LockoutViewSet(viewsets.ViewSet, List): def list(self, request): auth_token = request.META.get('HTTP_AUTHORIZATION', '') - if auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: + if secrets.DOOR_API_TOKEN and auth_token != 'Bearer ' + secrets.DOOR_API_TOKEN: raise exceptions.PermissionDenied() cards = models.Card.objects.filter(active_status='card_active') @@ -553,6 +555,24 @@ class StatsViewSet(viewsets.ViewSet, List): except KeyError: raise exceptions.ValidationError(dict(data='This field is required.')) + @action(detail=False, methods=['post']) + def sign(self, request): + try: + sign = request.data['sign'] + cache.set('sign', sign) + + if secrets.SIGN_TOKEN: + try: + post_data = dict(access_token=secrets.SIGN_TOKEN, args=sign) + r = requests.post('https://api.particle.io/v1/devices/200042000647343232363230/text/', data=post_data, timeout=5) + r.raise_for_status() + except: + raise exceptions.ValidationError(dict(sign='Something went wrong :(')) + + return Response(200) + except KeyError: + raise exceptions.ValidationError(dict(sign='This field is required.')) + @action(detail=False, methods=['post']) def alarm(self, request): try: @@ -635,7 +655,7 @@ class BackupView(views.APIView): backup_user = secrets.BACKUP_TOKENS.get(auth_token, None) if backup_user: - logger.info('Backup user: ' + backup_user['name']) + logger.info('Backup - User: ' + backup_user['name']) backup_path = cache.get(backup_user['cache_key'], None) if not backup_path: @@ -720,15 +740,6 @@ class VettingViewSet(Base, List): class RegistrationView(RegisterView): serializer_class = serializers.MyRegisterSerializer - def post(self, request): - data = request.data.copy() - data.pop('password1', None) - data.pop('password2', None) - logger.info(dict(data)) - - return super().post(request) - - class PasswordChangeView(PasswordChangeView): permission_classes = [AllowMetadata | IsAuthenticated] serializer_class = serializers.MyPasswordChangeSerializer diff --git a/apiserver/apiserver/secrets.py.example b/apiserver/apiserver/secrets.py.example index a6dd896..b92dd74 100644 --- a/apiserver/apiserver/secrets.py.example +++ b/apiserver/apiserver/secrets.py.example @@ -1,4 +1,6 @@ # Spaceport secrets file, don't commit to version control! +# +# Note: all values are optional, features are excluded if left blank # /admin/ route obfuscation # Set this to random characters @@ -59,6 +61,7 @@ DOOR_CODE = '' WIFI_PASS = '' MINECRAFT = '' MUMBLE = '' +SIGN_TOKEN = '' # Portal Email Credentials # For sending password resets, etc. diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py index aa8def4..0a6949b 100644 --- a/apiserver/apiserver/settings.py +++ b/apiserver/apiserver/settings.py @@ -25,7 +25,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = secrets.DJANGO_SECRET_KEY +SECRET_KEY = secrets.DJANGO_SECRET_KEY or 'OaOBN2E+brpoRyDMlTD9eTE5PgBtkkl+L7Bzt6pQ5Qr3GS82SH' # SECURITY WARNING: don't run with debug turned on in production! DEBUG_ENV = os.environ.get('DEBUG', False) @@ -55,7 +55,6 @@ SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_REFERRER_POLICY = 'same-origin' - # Application definition INSTALLED_APPS = [ @@ -209,6 +208,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 300, 'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES, 'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES, + 'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'], } #DEFAULT_LOGGING = None @@ -239,7 +239,7 @@ LOGGING = { 'loggers': { 'gunicorn': { 'handlers': ['console'], - 'level': 'DEBUG', + 'level': 'DEBUG' if DEBUG else 'INFO', 'propagate': False, }, '': { diff --git a/apiserver/apiserver/urls.py b/apiserver/apiserver/urls.py index d6165b7..9e5ca02 100644 --- a/apiserver/apiserver/urls.py +++ b/apiserver/apiserver/urls.py @@ -7,9 +7,6 @@ from rest_auth.views import LoginView, LogoutView from .api import views from . import secrets, settings -IPN_ROUTE = r'^ipn/{}/'.format(secrets.IPN_RANDOM) -ADMIN_ROUTE = '{}/admin/'.format(secrets.ADMIN_RANDOM) - router = routers.DefaultRouter() router.register(r'door', views.DoorViewSet, basename='door') router.register(r'lockout', views.LockoutViewSet, basename='lockout') @@ -31,7 +28,6 @@ router.register(r'charts/spaceactivity', views.SpaceActivityViewSet, basename='s urlpatterns = [ path('', include(router.urls)), - path(ADMIN_ROUTE, admin.site.urls), url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'), url(r'^spaceport-auth/login/$', views.SpaceportAuthView.as_view(), name='spaceport_auth'), url(r'^rest-auth/logout/$', LogoutView.as_view(), name='rest_logout'), @@ -44,9 +40,16 @@ urlpatterns = [ url(r'^ping/', views.PingView.as_view(), name='ping'), url(r'^paste/', views.PasteView.as_view(), name='paste'), url(r'^backup/', views.BackupView.as_view(), name='backup'), - url(IPN_ROUTE, views.IpnView.as_view(), name='ipn'), ] +if secrets.IPN_RANDOM: + IPN_ROUTE = r'^ipn/{}/'.format(secrets.IPN_RANDOM) + urlpatterns.append(url(IPN_ROUTE, views.IpnView.as_view(), name='ipn')) + +if secrets.ADMIN_RANDOM: + ADMIN_ROUTE = '{}/admin/'.format(secrets.ADMIN_RANDOM) + urlpatterns.append(path(ADMIN_ROUTE, admin.site.urls)) + if settings.DEBUG: urlpatterns += [ path('api-auth/', include('rest_framework.urls')), diff --git a/apiserver/requirements.txt b/apiserver/requirements.txt index b2839bf..b8b5b69 100644 --- a/apiserver/requirements.txt +++ b/apiserver/requirements.txt @@ -1,11 +1,11 @@ alabaster==0.7.12 argon2-cffi==19.2.0 -asgiref==3.2.3 Babel==2.9.1 bleach==3.3.0 certifi==2019.11.28 cffi==1.13.2 chardet==3.0.4 +commonmark==0.9.1 defusedxml==0.6.0 Django==3.1.13 django-allauth==0.41.0 @@ -18,6 +18,7 @@ gunicorn==20.0.4 idna==2.8 imagesize==1.2.0 Jinja2==2.11.3 +logging-tree==1.8.1 MarkupSafe==1.1.1 oauthlib==3.1.0 packaging==20.0 @@ -31,6 +32,7 @@ python-Levenshtein==0.12.0 python-memcached==1.59 python3-openid==3.1.0 pytz==2019.3 +recommonmark==0.7.1 reportlab==3.5.34 requests==2.22.0 requests-oauthlib==1.3.0 @@ -46,5 +48,5 @@ sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.2 sphinxcontrib-serializinghtml==1.1.3 sqlparse==0.3.0 -urllib3==1.26.5 +urllib3==1.25.11 webencodings==0.5.1 diff --git a/webclient/src/Account.js b/webclient/src/Account.js index c32f9f5..a5b226f 100644 --- a/webclient/src/Account.js +++ b/webclient/src/Account.js @@ -266,11 +266,16 @@ export function AccountForm(props) { - diff --git a/webclient/src/App.js b/webclient/src/App.js index b869c8a..c850192 100644 --- a/webclient/src/App.js +++ b/webclient/src/App.js @@ -16,6 +16,7 @@ import { Training } from './Training.js'; import { AdminTransactions } from './AdminTransactions.js'; import { Admin } from './Admin.js'; import { Paste } from './Paste.js'; +import { Sign } from './Sign.js'; import { Courses, CourseDetail } from './Courses.js'; import { Classes, ClassDetail } from './Classes.js'; import { Members, MemberDetail } from './Members.js'; @@ -167,9 +168,9 @@ function App() { to='/classes' /> - + + + + + diff --git a/webclient/src/Home.js b/webclient/src/Home.js index 7c0fd2f..cc6609d 100644 --- a/webclient/src/Home.js +++ b/webclient/src/Home.js @@ -7,6 +7,7 @@ import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Me import { statusColor, BasicTable, siteUrl, staticUrl, requester, isAdmin } from './utils.js'; import { LoginForm, SignupForm } from './LoginSignup.js'; import { AccountForm } from './Account.js'; +import { SignForm } from './Sign.js'; import { PayPalSubscribeDeal } from './PayPal.js'; function MemberInfo(props) { @@ -202,9 +203,6 @@ export function Home(props) { -
Home
-

Welcome to the Protospace member portal! Here you can view member info, join classes, and manage your membership.

-
Quick Links

Main Website

Protospace Wiki[register]

@@ -279,10 +277,26 @@ export function Home(props) { } trigger={[more]} />

+

+ Precix availability: {getTrackStat('CNC-PRECIX')} +

+ Last use:
+ {getTrackLast('CNC-PRECIX')}
+ {getTrackAgo('CNC-PRECIX')}
+ by {getTrackName('CNC-PRECIX')} +

+ + } trigger={[more]} /> +

+ {user &&

Alarm status: {alarmStat()}{doorOpenStat()}

} + +
+
diff --git a/webclient/src/LoginSignup.js b/webclient/src/LoginSignup.js index a67a9d0..798b351 100644 --- a/webclient/src/LoginSignup.js +++ b/webclient/src/LoginSignup.js @@ -77,12 +77,19 @@ export function SignupForm(props) { const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value }); const handleChange = (e) => handleValues(e, e.currentTarget); - const genUsername = () => ( - input.first_name && input.last_name ? - (input.first_name + '.' + input.last_name).toLowerCase().replace(/ /g, '.') - : - '' - ); + const genUsername = () => { + if (input.first_name && input.last_name) { + let first_name = input.first_name.trim().toLowerCase(); + let last_name = input.last_name.trim().toLowerCase(); + first_name = first_name.replace(/[^a-z- ]+/g, ''); + last_name = last_name.replace(/[^a-z- ]+/g, ''); + first_name = first_name.replace(/[ -]/g, '.'); + last_name = last_name.replace(/[ -]/g, '.'); + return first_name + '.' + last_name; + } else { + return ''; + } + }; const handleSubmit = (e) => { if (loading) return; 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 ? -