Merge branch 'load_more'

This commit is contained in:
Tanner Collin 2021-12-08 22:09:06 +00:00
commit bf5c300c6c
17 changed files with 461 additions and 138 deletions

View File

@ -3,6 +3,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 from django.shortcuts import get_object_or_404
from django.utils.timezone import now
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.validators import UniqueValidator 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 PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, LoginSerializer
from rest_auth.serializers import UserDetailsSerializer from rest_auth.serializers import UserDetailsSerializer
import re import re
import time import datetime, time
from . import models, fields, utils, utils_ldap, utils_auth, utils_stats from . import models, fields, utils, utils_ldap, utils_auth, utils_stats
from .. import settings, secrets from .. import settings, secrets
@ -229,6 +230,20 @@ class MemberSerializer(serializers.ModelSerializer):
logger.info(msg) logger.info(msg)
raise ValidationError(dict(discourse_username='Invalid Discourse username.')) 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) return super().update(instance, validated_data)
# admin viewing member details # admin viewing member details
@ -384,6 +399,7 @@ class CardSerializer(serializers.ModelSerializer):
read_only_fields = [ read_only_fields = [
'id', 'id',
'last_seen', 'last_seen',
'last_seen_at',
'user', 'user',
] ]
@ -536,7 +552,7 @@ class MyRegisterSerializer(RegisterSerializer):
if re.search(r'[^a-z.]', username): if re.search(r'[^a-z.]', username):
raise ValidationError('Invalid characters.') raise ValidationError('Invalid characters.')
if '..' in username: 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('.'): if username.startswith('.') or username.endswith('.'):
raise ValidationError('Can\'t start or end with periods.') raise ValidationError('Can\'t start or end with periods.')
return super().validate_username(username) return super().validate_username(username)

View File

@ -69,7 +69,7 @@ def post_create_historical_record_callback(
is_admin=is_admin_director(history_user), is_admin=is_admin_director(history_user),
) )
for change in changes: for num, change in enumerate(changes):
change_old = str(change.old) change_old = str(change.old)
change_new = str(change.new) change_new = str(change.new)
@ -84,6 +84,18 @@ def post_create_historical_record_callback(
old=change_old, old=change_old,
new=change_new, 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: except BaseException as e:
logger.error('History Signal - {} - {}'.format(e.__class__.__name__, e)) logger.error('History Signal - {} - {}'.format(e.__class__.__name__, e))
logger.info(str(sender)) logger.info(str(sender))

View File

@ -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

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(
@ -162,6 +164,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
@ -317,9 +321,9 @@ def create_new_member(data, user):
models.Member.objects.create( models.Member.objects.create(
user=user, user=user,
first_name=data['first_name'].title(), first_name=data['first_name'].title().strip(),
last_name=data['last_name'].title(), last_name=data['last_name'].title().strip(),
preferred_name=data['first_name'].title(), preferred_name=data['first_name'].title().strip(),
) )
def register_user(data, user): def register_user(data, user):

View File

@ -26,6 +26,7 @@ DEFAULTS = {
'card_scans': 0, 'card_scans': 0,
'track': {}, 'track': {},
'alarm': {}, 'alarm': {},
'sign': '',
} }
if secrets.MUMBLE: if secrets.MUMBLE:
@ -158,7 +159,7 @@ def get_progress(request_id):
return cache.get('request-progress-' + request_id, []) return cache.get('request-progress-' + request_id, [])
def set_progress(request_id, data): 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 = get_progress(request_id)
progress.append(data) progress.append(data)
cache.set('request-progress-' + request_id, progress) cache.set('request-progress-' + request_id, progress)

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,53 +79,56 @@ 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.filter(vetted_date__isnull=False)
queryset = queryset.order_by('-vetted_date') queryset = queryset.order_by('-vetted_date')
elif self.action == 'create' and sort == 'newest_active': elif sort == 'newest_active':
queryset = queryset.filter(paused_date__isnull=True) queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('-application_date') queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'newest_overall': elif sort == 'newest_overall':
queryset = queryset.order_by('-application_date') queryset = queryset.order_by('-application_date')
elif self.action == 'create' and sort == 'oldest_active': elif sort == 'oldest_active':
queryset = queryset.filter(paused_date__isnull=True) queryset = queryset.filter(paused_date__isnull=True)
queryset = queryset.order_by('application_date') queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'oldest_overall': elif sort == 'oldest_overall':
queryset = queryset.filter(application_date__isnull=False) queryset = queryset.filter(application_date__isnull=False)
queryset = queryset.order_by('application_date') queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'recently_inactive': elif sort == 'recently_inactive':
queryset = queryset.filter(paused_date__isnull=False) queryset = queryset.filter(paused_date__isnull=False)
queryset = queryset.order_by('-paused_date') queryset = queryset.order_by('-paused_date')
elif self.action == 'create' and sort == 'is_director': elif sort == 'is_director':
queryset = queryset.filter(is_director=True) queryset = queryset.filter(is_director=True)
queryset = queryset.order_by('application_date') queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'is_instructor': elif sort == 'is_instructor':
queryset = queryset.filter(is_instructor=True) queryset = queryset.filter(is_instructor=True)
queryset = queryset.order_by('application_date') queryset = queryset.order_by('application_date')
elif self.action == 'create' and sort == 'due': elif sort == 'due':
queryset = queryset.filter(status='Due') queryset = queryset.filter(status='Due')
queryset = queryset.order_by('expire_date') queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'overdue': elif sort == 'overdue':
queryset = queryset.filter(status='Overdue') queryset = queryset.filter(status='Overdue')
queryset = queryset.order_by('expire_date') queryset = queryset.order_by('expire_date')
elif self.action == 'create' and sort == 'last_scanned': elif sort == 'last_scanned':
if self.request.user.member.allow_last_scanned: if self.request.user.member.allow_last_scanned:
queryset = queryset.filter(allow_last_scanned=True) queryset = queryset.filter(allow_last_scanned=True)
queryset = queryset.order_by('-user__cards__last_seen') queryset = queryset.order_by('-user__cards__last_seen')
else: else:
queryset = [] queryset = []
elif self.action == 'create' and sort == 'best_looking': elif sort == 'everyone':
queryset = queryset.annotate(Count('user__transactions')).order_by('-user__transactions__count')
elif sort == 'best_looking':
queryset = [] 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):
@ -427,16 +432,13 @@ class PingView(views.APIView):
permission_classes = [AllowMetadata | IsAuthenticated] permission_classes = [AllowMetadata | IsAuthenticated]
def post(self, request): def post(self, request):
d = request.data.dict()
if d:
logger.info(str(d))
return Response(200) return Response(200)
class DoorViewSet(viewsets.ViewSet, List): class DoorViewSet(viewsets.ViewSet, List):
def list(self, request): def list(self, request):
auth_token = request.META.get('HTTP_AUTHORIZATION', '') 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() raise exceptions.PermissionDenied()
cards = models.Card.objects.filter(active_status='card_active') cards = models.Card.objects.filter(active_status='card_active')
@ -462,7 +464,7 @@ class DoorViewSet(viewsets.ViewSet, List):
member = card.user.member member = card.user.member
t = utils.now_alberta_tz().strftime('%Y-%m-%d %H:%M:%S, %a %I:%M %p') 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() utils_stats.calc_card_scans()
@ -472,7 +474,7 @@ class DoorViewSet(viewsets.ViewSet, List):
class LockoutViewSet(viewsets.ViewSet, List): class LockoutViewSet(viewsets.ViewSet, List):
def list(self, request): def list(self, request):
auth_token = request.META.get('HTTP_AUTHORIZATION', '') 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() raise exceptions.PermissionDenied()
cards = models.Card.objects.filter(active_status='card_active') cards = models.Card.objects.filter(active_status='card_active')
@ -553,6 +555,24 @@ class StatsViewSet(viewsets.ViewSet, List):
except KeyError: except KeyError:
raise exceptions.ValidationError(dict(data='This field is required.')) 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']) @action(detail=False, methods=['post'])
def alarm(self, request): def alarm(self, request):
try: try:
@ -635,7 +655,7 @@ class BackupView(views.APIView):
backup_user = secrets.BACKUP_TOKENS.get(auth_token, None) backup_user = secrets.BACKUP_TOKENS.get(auth_token, None)
if backup_user: 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) backup_path = cache.get(backup_user['cache_key'], None)
if not backup_path: if not backup_path:
@ -720,15 +740,6 @@ class VettingViewSet(Base, List):
class RegistrationView(RegisterView): class RegistrationView(RegisterView):
serializer_class = serializers.MyRegisterSerializer 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): class PasswordChangeView(PasswordChangeView):
permission_classes = [AllowMetadata | IsAuthenticated] permission_classes = [AllowMetadata | IsAuthenticated]
serializer_class = serializers.MyPasswordChangeSerializer serializer_class = serializers.MyPasswordChangeSerializer

View File

@ -1,4 +1,6 @@
# Spaceport secrets file, don't commit to version control! # Spaceport secrets file, don't commit to version control!
#
# Note: all values are optional, features are excluded if left blank
# /admin/ route obfuscation # /admin/ route obfuscation
# Set this to random characters # Set this to random characters
@ -59,6 +61,7 @@ DOOR_CODE = ''
WIFI_PASS = '' WIFI_PASS = ''
MINECRAFT = '' MINECRAFT = ''
MUMBLE = '' MUMBLE = ''
SIGN_TOKEN = ''
# Portal Email Credentials # Portal Email Credentials
# For sending password resets, etc. # For sending password resets, etc.

View File

@ -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/ # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG_ENV = os.environ.get('DEBUG', False) DEBUG_ENV = os.environ.get('DEBUG', False)
@ -55,7 +55,6 @@ SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SECURE_REFERRER_POLICY = 'same-origin' SECURE_REFERRER_POLICY = 'same-origin'
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -209,6 +208,7 @@ REST_FRAMEWORK = {
'PAGE_SIZE': 300, 'PAGE_SIZE': 300,
'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES, 'DEFAULT_RENDERER_CLASSES': DEFAULT_RENDERER_CLASSES,
'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES, 'DEFAULT_AUTHENTICATION_CLASSES': DEFAULT_AUTHENTICATION_CLASSES,
'DEFAULT_THROTTLE_CLASSES': ['apiserver.api.throttles.LoggingThrottle'],
} }
#DEFAULT_LOGGING = None #DEFAULT_LOGGING = None
@ -239,7 +239,7 @@ LOGGING = {
'loggers': { 'loggers': {
'gunicorn': { 'gunicorn': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'DEBUG', 'level': 'DEBUG' if DEBUG else 'INFO',
'propagate': False, 'propagate': False,
}, },
'': { '': {

View File

@ -7,9 +7,6 @@ from rest_auth.views import LoginView, LogoutView
from .api import views from .api import views
from . import secrets, settings from . import secrets, settings
IPN_ROUTE = r'^ipn/{}/'.format(secrets.IPN_RANDOM)
ADMIN_ROUTE = '{}/admin/'.format(secrets.ADMIN_RANDOM)
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'door', views.DoorViewSet, basename='door') router.register(r'door', views.DoorViewSet, basename='door')
router.register(r'lockout', views.LockoutViewSet, basename='lockout') router.register(r'lockout', views.LockoutViewSet, basename='lockout')
@ -31,7 +28,6 @@ router.register(r'charts/spaceactivity', views.SpaceActivityViewSet, basename='s
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path(ADMIN_ROUTE, admin.site.urls),
url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'), 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'^spaceport-auth/login/$', views.SpaceportAuthView.as_view(), name='spaceport_auth'),
url(r'^rest-auth/logout/$', LogoutView.as_view(), name='rest_logout'), 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'^ping/', views.PingView.as_view(), name='ping'),
url(r'^paste/', views.PasteView.as_view(), name='paste'), url(r'^paste/', views.PasteView.as_view(), name='paste'),
url(r'^backup/', views.BackupView.as_view(), name='backup'), 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: if settings.DEBUG:
urlpatterns += [ urlpatterns += [
path('api-auth/', include('rest_framework.urls')), path('api-auth/', include('rest_framework.urls')),

View File

@ -1,11 +1,11 @@
alabaster==0.7.12 alabaster==0.7.12
argon2-cffi==19.2.0 argon2-cffi==19.2.0
asgiref==3.2.3
Babel==2.9.1 Babel==2.9.1
bleach==3.3.0 bleach==3.3.0
certifi==2019.11.28 certifi==2019.11.28
cffi==1.13.2 cffi==1.13.2
chardet==3.0.4 chardet==3.0.4
commonmark==0.9.1
defusedxml==0.6.0 defusedxml==0.6.0
Django==3.1.13 Django==3.1.13
django-allauth==0.41.0 django-allauth==0.41.0
@ -18,6 +18,7 @@ gunicorn==20.0.4
idna==2.8 idna==2.8
imagesize==1.2.0 imagesize==1.2.0
Jinja2==2.11.3 Jinja2==2.11.3
logging-tree==1.8.1
MarkupSafe==1.1.1 MarkupSafe==1.1.1
oauthlib==3.1.0 oauthlib==3.1.0
packaging==20.0 packaging==20.0
@ -31,6 +32,7 @@ python-Levenshtein==0.12.0
python-memcached==1.59 python-memcached==1.59
python3-openid==3.1.0 python3-openid==3.1.0
pytz==2019.3 pytz==2019.3
recommonmark==0.7.1
reportlab==3.5.34 reportlab==3.5.34
requests==2.22.0 requests==2.22.0
requests-oauthlib==1.3.0 requests-oauthlib==1.3.0
@ -46,5 +48,5 @@ sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.2 sphinxcontrib-qthelp==1.0.2
sphinxcontrib-serializinghtml==1.1.3 sphinxcontrib-serializinghtml==1.1.3
sqlparse==0.3.0 sqlparse==0.3.0
urllib3==1.26.5 urllib3==1.25.11
webencodings==0.5.1 webencodings==0.5.1

View File

@ -266,11 +266,16 @@ export function AccountForm(props) {
<Form.Field> <Form.Field>
<label>Participate in "Last Scanned" member list?</label> <label>Participate in "Last Scanned" member list?</label>
<Checkbox <Form.Checkbox
label='Yes, show me' label='Yes, show me'
name='allow_last_scanned' name='allow_last_scanned'
onChange={handleCheck} onChange={handleCheck}
checked={input.allow_last_scanned} checked={input.allow_last_scanned}
error={error.allow_last_scanned ?
{ content: error.allow_last_scanned, pointing: 'left' }
:
false
}
/> />
</Form.Field> </Form.Field>

View File

@ -16,6 +16,7 @@ import { Training } from './Training.js';
import { AdminTransactions } from './AdminTransactions.js'; import { AdminTransactions } from './AdminTransactions.js';
import { Admin } from './Admin.js'; import { Admin } from './Admin.js';
import { Paste } from './Paste.js'; import { Paste } from './Paste.js';
import { Sign } from './Sign.js';
import { Courses, CourseDetail } from './Courses.js'; import { Courses, CourseDetail } from './Courses.js';
import { Classes, ClassDetail } from './Classes.js'; import { Classes, ClassDetail } from './Classes.js';
import { Members, MemberDetail } from './Members.js'; import { Members, MemberDetail } from './Members.js';
@ -167,9 +168,9 @@ function App() {
to='/classes' to='/classes'
/> />
<Dropdown.Item <Dropdown.Item
content='Transporter' content='Utilities'
as={Link} as={Link}
to='/paste' to='/utils'
/> />
<Dropdown.Item <Dropdown.Item
content='Charts' content='Charts'
@ -215,10 +216,14 @@ function App() {
<PasswordReset /> <PasswordReset />
</Route> </Route>
<Route path='/paste'> <Route path='/utils'>
<Paste token={token} /> <Paste token={token} />
</Route> </Route>
<Route path='/sign'>
<Sign token={token} />
</Route>
<Route path='/charts'> <Route path='/charts'>
<Charts /> <Charts />
</Route> </Route>

View File

@ -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 { statusColor, BasicTable, siteUrl, staticUrl, requester, isAdmin } from './utils.js';
import { LoginForm, SignupForm } from './LoginSignup.js'; import { LoginForm, SignupForm } from './LoginSignup.js';
import { AccountForm } from './Account.js'; import { AccountForm } from './Account.js';
import { SignForm } from './Sign.js';
import { PayPalSubscribeDeal } from './PayPal.js'; import { PayPalSubscribeDeal } from './PayPal.js';
function MemberInfo(props) { function MemberInfo(props) {
@ -202,9 +203,6 @@ export function Home(props) {
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Segment> <Segment>
<Header size='medium'>Home</Header>
<p>Welcome to the Protospace member portal! Here you can view member info, join classes, and manage your membership.</p>
<Header size='medium'>Quick Links</Header> <Header size='medium'>Quick Links</Header>
<p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p> <p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p>
<p><a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>Protospace Wiki</a> <Link to='/auth/wiki'>[register]</Link></p> <p><a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>Protospace Wiki</a> <Link to='/auth/wiki'>[register]</Link></p>
@ -279,10 +277,26 @@ export function Home(props) {
} trigger={<a>[more]</a>} /> } trigger={<a>[more]</a>} />
</p> </p>
<p>
Precix availability: {getTrackStat('CNC-PRECIX')} <Popup content={
<React.Fragment>
<p>
Last use:<br />
{getTrackLast('CNC-PRECIX')}<br />
{getTrackAgo('CNC-PRECIX')}<br />
by {getTrackName('CNC-PRECIX')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
{user && <p>Alarm status: {alarmStat()}{doorOpenStat()}</p>} {user && <p>Alarm status: {alarmStat()}{doorOpenStat()}</p>}
</div> </div>
<SignForm token={token} />
</Segment> </Segment>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
</Container> </Container>

View File

@ -77,12 +77,19 @@ export function SignupForm(props) {
const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value }); const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value });
const handleChange = (e) => handleValues(e, e.currentTarget); const handleChange = (e) => handleValues(e, e.currentTarget);
const genUsername = () => ( const genUsername = () => {
input.first_name && input.last_name ? if (input.first_name && input.last_name) {
(input.first_name + '.' + input.last_name).toLowerCase().replace(/ /g, '.') 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) => { const handleSubmit = (e) => {
if (loading) return; if (loading) return;

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;}}
/> : ''
} }
</> </>
: :

View File

@ -41,7 +41,7 @@ function PasteForm(props) {
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Form.TextArea <Form.TextArea
maxLength={20000} maxLength={20000}
rows={20} rows={15}
{...makeProps('paste')} {...makeProps('paste')}
/> />
@ -53,6 +53,91 @@ function PasteForm(props) {
); );
}; };
function LabelForm(props) {
const [error, setError] = useState(false);
const [input, setInput] = useState({ id: '107', size: '2' });
const [label, setLabel] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value });
const handleChange = (e) => handleValues(e, e.currentTarget);
const handleSubmit = (e) => {
if (loading) return;
setLoading(true);
fetch('https://labels.protospace.ca/?' + new URLSearchParams(input))
.then(res => {
if (res.ok) {
return res.blob();
} else {
return res.text().then(text => {throw new Error(text)});
}
})
.then(res => {
setLoading(false);
setSuccess(true);
setError(false);
const imageObjectURL = URL.createObjectURL(res);
setLabel(imageObjectURL);
})
.catch(err => {
setLabel(false);
setLoading(false);
console.log(err);
setError(err);
});
};
const makeProps = (name) => ({
name: name,
onChange: handleChange,
value: input[name] || '',
});
const sizeOptions = [
{ key: '0', text: '1.0', value: '1' },
{ key: '1', text: '1.5', value: '1.5' },
{ key: '2', text: '2.0', value: '2' },
{ key: '3', text: '2.5', value: '2.5' },
{ key: '4', text: '3.0', value: '3' },
{ key: '5', text: '3.5', value: '3.5' },
{ key: '6', text: '4.0', value: '4' },
];
return (
<Form onSubmit={handleSubmit} error={!!error}>
<Form.Group widths='equal'>
<Form.Input
fluid
label='Wiki ID #'
{...makeProps('id')}
/>
<Form.Select
fluid
label='Size'
options={sizeOptions}
{...makeProps('size')}
onChange={handleValues}
/>
</Form.Group>
<Message
error
header='Label Error'
content={error.message}
/>
<Form.Button loading={loading}>
Submit
</Form.Button>
{label && <img src={label} />}
</Form>
);
};
let pasteCache = 'Loading...'; let pasteCache = 'Loading...';
export function Paste(props) { export function Paste(props) {
@ -73,6 +158,14 @@ export function Paste(props) {
return ( return (
<Container> <Container>
<Header size='large'>Label Generator</Header>
<p>Use this to generate QR code labels for tools at Protospace.</p>
<p>Choose a tool from here: <a href='https://wiki.protospace.ca/Category:Tools' target='_blank'>https://wiki.protospace.ca/Category:Tools</a></p>
<LabelForm />
<Header size='large'>Transporter</Header> <Header size='large'>Transporter</Header>
<p> <p>

64
webclient/src/Sign.js Normal file
View File

@ -0,0 +1,64 @@
import React, { useState, useEffect, useReducer, useContext } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom';
import { Button, Container, Checkbox, Dimmer, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
import { apiUrl, statusColor, BasicTable, staticUrl, requester } from './utils.js';
import { NotFound } from './Misc.js';
export function SignForm(props) {
const { token } = props;
const [error, setError] = useState({});
const [sign, setSign] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleValues = (e, v) => setSign(v.value);
const handleChange = (e) => handleValues(e, e.currentTarget);
const handleSubmit = (e) => {
if (loading) return;
setLoading(true);
const data = {sign: sign};
requester('/stats/sign/', 'POST', token, data)
.then(res => {
setLoading(false);
setSuccess(true);
setError({});
setSign('');
})
.catch(err => {
setLoading(false);
console.log(err);
setError(err.data);
});
};
return (
<Form onSubmit={handleSubmit}>
<p>Send a message to the sign:</p>
<Form.Input
name='sign'
onChange={handleChange}
value={sign}
error={error.sign}
/>
<Form.Button loading={loading} error={error.non_field_errors}>
Submit
</Form.Button>
{success && <div>Success!</div>}
</Form>
);
};
export function Sign(props) {
const { token } = props;
return (
<Container>
<Header size='large'>Protospace Sign</Header>
<SignForm token={token} />
</Container>
);
};