Merge branch 'master' of github.com:Protospace/spaceport into webgl-footer

This commit is contained in:
Elijah Lucian 2021-03-17 10:35:12 -06:00
commit 8f536b0242
58 changed files with 1650 additions and 214 deletions

View File

@ -22,4 +22,6 @@ That means you have the right to study, change, and distribute the software and
Thanks to the Protospace Portal Committee.
Thanks to Emrah for lockout certification code, Pat for LDAP code, and Murray for the blank member form PDF.
Thanks to all the devs behind Python, Django, DRF, Node, React, Quill, and Bleach.

View File

@ -0,0 +1,50 @@
from django.core.management.base import BaseCommand, CommandError
from django.utils.timezone import now
from apiserver import settings
from apiserver.api import models, utils, utils_stats
import time
import os
if settings.DEBUG:
STATIC_FOLDER = './data/static/'
else:
STATIC_FOLDER = '/opt/spaceport/apiserver/data/static/'
class Command(BaseCommand):
help = 'Delete unused static assets'
def delete_old_static(self):
members = models.Member.objects
good_files = []
for static_field in ['photo_large', 'photo_medium', 'photo_small', 'member_forms']:
good_files.extend(members.values_list(static_field, flat=True))
count = 0
for f in os.listdir(STATIC_FOLDER):
if len(f) != 40:
self.stdout.write('Skipping: ' + f)
continue
if f[-3:] not in ['jpg', 'pdf', 'png']:
self.stdout.write('Skipping: ' + f)
continue
if f not in good_files:
os.remove(STATIC_FOLDER + f)
count += 1
return count
def handle(self, *args, **options):
self.stdout.write('{} - Deleting unused static files'.format(str(now())))
start = time.time()
count = self.delete_old_static()
self.stdout.write('Deleted {} files'.format(count))
self.stdout.write('Completed deletion in {} s'.format(
str(time.time() - start)[:4]
))

View File

@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand, CommandError
from django.utils.timezone import now
from django.core.cache import cache
from django.db import transaction
from apiserver import secrets, settings
from apiserver.api import models
@ -25,6 +26,7 @@ backup_id_string = lambda x: '{}\t{}\t{}'.format(
class Command(BaseCommand):
help = 'Generate backups.'
@transaction.atomic
def generate_backups(self):
backup_users = secrets.BACKUP_TOKENS.values()

View File

@ -9,13 +9,18 @@ class Command(BaseCommand):
def generate_stats(self):
utils_stats.calc_next_events()
member_count, green_count = utils_stats.calc_member_counts()
member_count, green_count, six_month_plus_count, vetted_count = utils_stats.calc_member_counts()
signup_count = utils_stats.calc_signup_counts()
# do this hourly in case an admin causes a change
models.StatsMemberCount.objects.update_or_create(
date=utils.today_alberta_tz(),
defaults=dict(member_count=member_count, green_count=green_count),
defaults=dict(
member_count=member_count,
green_count=green_count,
six_month_plus_count=six_month_plus_count,
vetted_count=vetted_count,
),
)
models.StatsSignupCount.objects.update_or_create(

View File

@ -14,6 +14,8 @@ class Command(BaseCommand):
players = utils_stats.check_minecraft_server()
self.stdout.write('Found Minecraft players: ' + str(players))
users = utils_stats.check_mumble_server()
self.stdout.write('Found Mumble users: ' + str(users))
self.stdout.write('Completed tasks in {} s'.format(
str(time.time() - start)[:4]

View File

@ -21,7 +21,6 @@ class Member(models.Model):
photo_medium = models.CharField(max_length=64, blank=True, null=True)
photo_small = models.CharField(max_length=64, blank=True, null=True)
member_forms = models.CharField(max_length=64, blank=True, null=True)
card_photo = models.CharField(max_length=64, blank=True, null=True)
set_details = models.BooleanField(default=False)
first_name = models.CharField(max_length=32)
@ -53,6 +52,8 @@ class Member(models.Model):
wood_cert_date = models.DateField(blank=True, null=True, default=None)
wood2_cert_date = models.DateField(blank=True, null=True, default=None)
cnc_cert_date = models.DateField(blank=True, null=True, default=None)
rabbit_cert_date = models.DateField(blank=True, null=True, default=None)
trotec_cert_date = models.DateField(blank=True, null=True, default=None)
paused_date = models.DateField(blank=True, null=True)
monthly_fees = models.IntegerField(default=55, blank=True, null=True)
@ -141,6 +142,8 @@ class StatsMemberCount(models.Model):
date = models.DateField(default=today_alberta_tz)
member_count = models.IntegerField()
green_count = models.IntegerField()
six_month_plus_count = models.IntegerField()
vetted_count = models.IntegerField()
class StatsSignupCount(models.Model):
month = models.DateField()

View File

@ -7,11 +7,11 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.validators import UniqueValidator
from rest_auth.registration.serializers import RegisterSerializer
from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer
from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, LoginSerializer
from rest_auth.serializers import UserDetailsSerializer
import re
from . import models, fields, utils, utils_ldap
from . import models, fields, utils, utils_ldap, utils_auth
from .. import settings, secrets
class TransactionSerializer(serializers.ModelSerializer):
@ -24,7 +24,7 @@ class TransactionSerializer(serializers.ModelSerializer):
'Square Pmt',
'Member',
'Clearing',
'Cash'
'Cash',
])
info_source = serializers.ChoiceField([
'Web',
@ -38,7 +38,18 @@ class TransactionSerializer(serializers.ModelSerializer):
'IPN Trigger',
'Intranet Receipt',
'Automatic',
'Manual'
'Manual',
])
category = serializers.ChoiceField([
'Membership',
'OnAcct',
'Snacks',
'Donation',
'Consumables',
'Purchases',
'Garage Sale',
'Reimburse',
'Other',
])
member_id = serializers.IntegerField()
member_name = serializers.SerializerMethodField()
@ -95,6 +106,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
'last_name',
'status',
'current_start_date',
'application_date',
'photo_small',
'photo_large',
'public_bio',
@ -103,6 +115,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
def get_status(self, obj):
return 'Former Member' if obj.paused_date else obj.status
# member viewing his own details
class MemberSerializer(serializers.ModelSerializer):
status = serializers.SerializerMethodField()
@ -144,6 +157,8 @@ class MemberSerializer(serializers.ModelSerializer):
'wood_cert_date',
'wood2_cert_date',
'cnc_cert_date',
'rabbit_cert_date',
'trotec_cert_date',
]
def get_status(self, obj):
@ -163,7 +178,6 @@ class MemberSerializer(serializers.ModelSerializer):
instance.photo_small = small
instance.photo_medium = medium
instance.photo_large = large
instance.card_photo = utils.gen_card_photo(instance)
return super().update(instance, validated_data)
@ -173,6 +187,7 @@ class AdminMemberSerializer(MemberSerializer):
street_address = serializers.CharField(required=False)
city = serializers.CharField(required=False)
postal_code = serializers.CharField(required=False)
monthly_fees = serializers.ChoiceField([10, 30, 35, 50, 55])
class Meta:
model = models.Member
@ -193,6 +208,25 @@ class AdminMemberSerializer(MemberSerializer):
'is_staff',
]
def update(self, instance, validated_data):
if 'rabbit_cert_date' in validated_data:
changed = validated_data['rabbit_cert_date'] != instance.rabbit_cert_date
if changed:
if validated_data['rabbit_cert_date']:
utils_ldap.add_to_group(instance, 'Laser Users')
else:
utils_ldap.remove_from_group(instance, 'Laser Users')
if 'trotec_cert_date' in validated_data:
changed = validated_data['trotec_cert_date'] != instance.trotec_cert_date
if changed:
if validated_data['trotec_cert_date']:
utils_ldap.add_to_group(instance, 'Trotec Users')
else:
utils_ldap.remove_from_group(instance, 'Trotec Users')
return super().update(instance, validated_data)
# member viewing member list or search result
class SearchSerializer(serializers.Serializer):
@ -204,6 +238,24 @@ class SearchSerializer(serializers.Serializer):
serializer = OtherMemberSerializer(obj)
return serializer.data
# instructor viewing search result
class InstructorSearchSerializer(serializers.Serializer):
member = serializers.SerializerMethodField()
training = serializers.SerializerMethodField()
def get_member(self, obj):
serializer = OtherMemberSerializer(obj)
return serializer.data
def get_training(self, obj):
if obj.user:
queryset = obj.user.training
else:
queryset = models.Training.objects.filter(member_id=obj.id)
serializer = UserTrainingSerializer(data=queryset, many=True)
serializer.is_valid()
return serializer.data
# admin viewing search result
class AdminSearchSerializer(serializers.Serializer):
cards = serializers.SerializerMethodField()
@ -289,6 +341,7 @@ class TrainingSerializer(serializers.ModelSerializer):
session = serializers.PrimaryKeyRelatedField(queryset=models.Session.objects.all())
student_name = serializers.SerializerMethodField()
student_email = serializers.SerializerMethodField()
student_id = serializers.SerializerMethodField()
class Meta:
model = models.Training
@ -309,6 +362,12 @@ class TrainingSerializer(serializers.ModelSerializer):
member = models.Member.objects.get(id=obj.member_id)
return member.old_email
def get_student_id(self, obj):
if obj.user:
return obj.user.member.id
else:
return obj.member_id
class StudentTrainingSerializer(TrainingSerializer):
attendance_status = serializers.ChoiceField(['Waiting for payment', 'Withdrawn'])
@ -321,6 +380,8 @@ class SessionSerializer(serializers.ModelSerializer):
datetime = serializers.DateTimeField()
course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all())
students = TrainingSerializer(many=True, read_only=True)
max_students = serializers.IntegerField(min_value=1, max_value=50, allow_null=True)
cost = serializers.DecimalField(max_digits=None, decimal_places=2, min_value=0, max_value=200)
class Meta:
model = models.Session
@ -447,16 +508,38 @@ class MyPasswordChangeSerializer(PasswordChangeSerializer):
if utils_ldap.is_configured():
if utils_ldap.set_password(data) != 200:
raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.'))
msg = 'Problem connecting to LDAP server: set.'
utils.alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
data = dict(
username=self.user.username,
password=self.data['new_password1'],
)
if utils_auth.is_configured():
if utils_auth.set_password(data) != 200:
msg = 'Problem connecting to Auth server: set.'
utils.alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
super().save()
class MyPasswordResetSerializer(PasswordResetSerializer):
def validate_email(self, email):
if not User.objects.filter(email__iexact=email).exists():
logging.info('Email not found: ' + email)
raise ValidationError('Not found.')
return super().validate_email(email)
def save(self):
email = self.data['email']
member = User.objects.get(email__iexact=email).member
logging.info('Password reset requested for: {} - {} {} ({})'.format(email, member.first_name, member.last_name, member.id))
super().save()
class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
def save(self):
data = dict(
@ -466,7 +549,25 @@ class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
if utils_ldap.is_configured():
if utils_ldap.set_password(data) != 200:
raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.'))
msg = 'Problem connecting to LDAP server: set.'
utils.alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
data = dict(
username=self.user.username,
password=self.data['new_password1'],
)
if utils_auth.is_configured():
if utils_auth.set_password(data) != 200:
msg = 'Problem connecting to Auth server: set.'
utils.alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
member = self.user.member
logging.info('Password reset completed for: {} {} ({})'.format(member.first_name, member.last_name, member.id))
super().save()
@ -504,3 +605,13 @@ class HistorySerializer(serializers.ModelSerializer):
class Meta:
model = models.HistoryIndex
fields = '__all__'
class SpaceportAuthSerializer(LoginSerializer):
def authenticate(self, **kwargs):
result = super().authenticate(**kwargs)
if result:
data = self.context['request'].data
utils_auth.set_password(data)
return result

View File

@ -19,11 +19,6 @@ from django.core.cache import cache
from django.utils.timezone import now, pytz
from . import models, serializers, utils_ldap
try:
from . import old_models
except ImportError:
logger.info('Running without old portal data...')
old_models = None
STATIC_FOLDER = 'data/static/'
@ -234,28 +229,29 @@ def gen_card_photo(member):
# check font size
font_sizes = (60, 72)
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
size = draw.textsize(member.last_name, font=font)
size = draw.textsize(str(member.last_name), font=font)
if size[0] > CARD_TEXT_SIZE_LIMIT:
font_sizes = (36, 48)
font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0])
x = CARD_PHOTO_MARGIN_SIDE
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE
draw.text((x, y), member.first_name, (0,0,0), font=font)
draw.text((x, y), str(member.first_name), (0,0,0), font=font)
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE + font_sizes[1]
draw.text((x, y), member.last_name, (0,0,0), font=font)
draw.text((x, y), str(member.last_name), (0,0,0), font=font)
font = ImageFont.truetype('DejaVuSans.ttf', 36)
draw.text((x, 800), 'Joined: ' + str(member.application_date), (0,0,0), font=font)
draw.text((x, 800), 'Joined: ' + str(member.application_date or 'Unknown'), (0,0,0), font=font)
y = CARD_PHOTO_MARGIN_SIDE
draw.text((475, y), str(member.id), (0,0,0), font=font)
file_name = str(uuid4()) + '.jpg'
card_template.save(STATIC_FOLDER + file_name, quality=95)
bio = io.BytesIO()
card_template.save(bio, 'JPEG', quality=95)
bio.seek(0)
return file_name
return bio
ALLOWED_TAGS = [
@ -292,15 +288,11 @@ def link_old_member(data, user):
Since this runs AFTER registration, we need to delete the user on any
failures or else the username will be taken when they try again
'''
if not old_models:
msg = 'Unable to link, old DB wasn\'t imported.'
logger.info(msg)
raise ValidationError(dict(email=msg))
try:
member = models.Member.objects.get(old_email__iexact=data['email'])
except models.Member.DoesNotExist:
msg = 'Unable to find email in old portal.'
msg = 'Unable to find email in old portal. Maybe try your other email addresses?'
logger.info(msg)
raise ValidationError(dict(email=msg))
except models.Member.MultipleObjectsReturned:
@ -318,15 +310,18 @@ def link_old_member(data, user):
if result == 200:
if utils_ldap.set_password(data) != 200:
msg = 'Problem connecting to LDAP server: set.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
elif result == 404:
if utils_ldap.create_user(data) != 200:
msg = 'Problem connecting to LDAP server: create.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
else:
msg = 'Problem connecting to LDAP server: find.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
@ -342,12 +337,11 @@ def link_old_member(data, user):
models.Training.objects.filter(member_id=member.id).update(user=user)
def create_new_member(data, user):
if old_models:
old_members = old_models.Members.objects.using('old_portal')
if old_members.filter(email__iexact=data['email']).exists():
msg = 'Account was found in old portal.'
logger.info(msg)
raise ValidationError(dict(email=msg))
members = models.Member.objects
if members.filter(old_email__iexact=data['email']).exists():
msg = 'Account was found in old portal.'
logger.info(msg)
raise ValidationError(dict(email=msg))
if utils_ldap.is_configured():
result = utils_ldap.find_user(user.username)
@ -359,11 +353,13 @@ def create_new_member(data, user):
pass
else:
msg = 'Problem connecting to LDAP server.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
if utils_ldap.create_user(data) != 200:
msg = 'Problem connecting to LDAP server: create.'
alert_tanner(msg)
logger.info(msg)
raise ValidationError(dict(non_field_errors=msg))
@ -393,7 +389,6 @@ def gen_member_forms(member):
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
can.drawString(75, 775, '[ ] Paid [ ] Sponsored & Approved [ ] Vetted [ ] Got Card')
can.drawString(34, 683, data['first_name'])
can.drawString(218, 683, data['last_name'])
can.drawString(403, 683, data['preferred_name'])
@ -407,7 +402,7 @@ def gen_member_forms(member):
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
can.drawRightString(600, 775, '{} {} ({})'.format(
can.drawRightString(600, 770, '{} {} ({})'.format(
data['first_name'],
data['last_name'],
data['id'],

View File

@ -0,0 +1,28 @@
import logging
logger = logging.getLogger(__name__)
import requests
from apiserver import secrets
from apiserver.api import utils
def is_configured():
return bool(secrets.AUTH_API_URL and secrets.AUTH_API_KEY)
def auth_api(route, data):
try:
headers = {'Authorization': 'Token ' + secrets.AUTH_API_KEY}
url = secrets.AUTH_API_URL + route
r = requests.post(url, data=data, headers=headers, timeout=3)
return r.status_code
except BaseException as e:
logger.error('Auth {} - {} - {}'.format(url, e.__class__.__name__, str(e)))
return None
def set_password(data):
auth_data = dict(
username=data['username'],
password=data['password'],
)
return auth_api('set-password', auth_data)

View File

@ -4,6 +4,7 @@ logger = logging.getLogger(__name__)
import requests
from apiserver import secrets
from apiserver.api import utils
def is_configured():
return bool(secrets.LDAP_API_URL and secrets.LDAP_API_KEY)
@ -13,7 +14,7 @@ def ldap_api(route, data):
try:
headers = {'Authorization': 'Token ' + secrets.LDAP_API_KEY}
url = secrets.LDAP_API_URL + route
r = requests.post(url, data=data, headers=headers, timeout=3)
r = requests.post(url, data=data, headers=headers, timeout=5)
return r.status_code
except BaseException as e:
logger.error('LDAP {} - {} - {}'.format(url, e.__class__.__name__, str(e)))
@ -39,3 +40,37 @@ def set_password(data):
password=data['password1'],
)
return ldap_api('set-password', ldap_data)
def add_to_group(member, group):
try:
ldap_data = dict(group=group)
if member.user:
ldap_data['username'] = member.user.username
else:
ldap_data['email'] = member.old_email
if ldap_api('add-to-group', ldap_data) != 200: raise
except BaseException as e:
logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e)))
m = '{} {} ({})'.format(member.first_name, member.last_name, member.id)
msg = 'Problem adding {} to group {}!'.format(m, group)
utils.alert_tanner(msg)
logger.info(msg)
def remove_from_group(member, group):
try:
ldap_data = dict(group=group)
if member.user:
ldap_data['username'] = member.user.username
else:
ldap_data['email'] = member.old_email
if ldap_api('remove-from-group', ldap_data) != 200: raise
except BaseException as e:
logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e)))
m = '{} {} ({})'.format(member.first_name, member.last_name, member.id)
msg = 'Problem removing {} from group {}!'.format(m, group)
utils.alert_tanner(msg)
logger.info(msg)

View File

@ -2,7 +2,7 @@ import logging
logger = logging.getLogger(__name__)
import time
from datetime import date, datetime
from datetime import date, datetime, timedelta
import requests
from django.core.cache import cache
from django.utils.timezone import now, pytz
@ -22,8 +22,10 @@ DEFAULTS = {
'bay_108_temp': None,
'bay_110_temp': None,
'minecraft_players': [],
'mumble_users': [],
'card_scans': 0,
'track': {},
'alarm': {},
}
def changed_card():
@ -62,11 +64,16 @@ def calc_member_counts():
paused_count = members.count() - member_count
green_count = num_current + num_prepaid
six_months_ago = today_alberta_tz() - timedelta(days=183)
six_month_plus_count = not_paused.filter(application_date__lte=six_months_ago).count()
vetted_count = not_paused.filter(vetted_date__isnull=False).count()
cache.set('member_count', member_count)
cache.set('paused_count', paused_count)
cache.set('green_count', green_count)
return member_count, green_count
return member_count, green_count, six_month_plus_count, vetted_count
def calc_signup_counts():
month_beginning = today_alberta_tz().replace(day=1)
@ -114,6 +121,21 @@ def check_minecraft_server():
return []
def check_mumble_server():
if secrets.MUMBLE:
url = secrets.MUMBLE
try:
r = requests.get(url, timeout=5)
r.raise_for_status()
users = r.text.split()
cache.set('mumble_users', users)
return users
except BaseException as e:
logger.error('Problem checking Mumble: {} - {}'.format(e.__class__.__name__, str(e)))
return []
def calc_card_scans():
date = today_alberta_tz()
cards = models.Card.objects

View File

@ -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.models import Max
from django.http import HttpResponse, Http404
from django.http import HttpResponse, Http404, FileResponse
from django.core.files.base import File
from django.core.cache import cache
from django.utils.timezone import now
@ -12,7 +12,7 @@ from rest_framework import viewsets, views, mixins, generics, exceptions
from rest_framework.decorators import action, api_view
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView
from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView, LoginView
from rest_auth.registration.views import RegisterView
from fuzzywuzzy import fuzz, process
from collections import OrderedDict
@ -20,7 +20,7 @@ import datetime, time
import requests
from . import models, serializers, utils, utils_paypal, utils_stats
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap
from .permissions import (
is_admin_director,
AllowMetadata,
@ -50,6 +50,8 @@ class SearchViewSet(Base, Retrieve):
def get_serializer_class(self):
if is_admin_director(self.request.user) and self.action == 'retrieve':
return serializers.AdminSearchSerializer
elif self.request.user.member.is_instructor and self.action == 'retrieve':
return serializers.InstructorSearchSerializer
else:
return serializers.SearchSerializer
@ -82,6 +84,7 @@ class SearchViewSet(Base, Retrieve):
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':
utils.gen_search_strings() # update cache
queryset = queryset.order_by('-vetted_date')
@ -137,12 +140,24 @@ class MemberViewSet(Base, Retrieve, Update):
member = self.get_object()
member.current_start_date = utils.today_alberta_tz()
member.paused_date = None
if not member.monthly_fees:
member.monthly_fees = 55
member.save()
utils.tally_membership_months(member)
utils.gen_member_forms(member)
utils_stats.changed_card()
return Response(200)
@action(detail=True, methods=['get'])
def card_photo(self, request, pk=None):
if not is_admin_director(self.request.user):
raise exceptions.PermissionDenied()
member = self.get_object()
if not member.photo_large:
raise Http404
card_photo = utils.gen_card_photo(member)
return FileResponse(card_photo, filename='card.jpg')
class CardViewSet(Base, Create, Retrieve, Update, Destroy):
permission_classes = [AllowMetadata | IsAdmin]
@ -200,6 +215,38 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
else:
return serializers.StudentTrainingSerializer
def update_cert(self, session, member, status):
# always update cert date incase member is returning and gets recertified
if session.course.id == 249:
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 261:
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 401:
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 281:
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 283:
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 259:
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 247:
member.rabbit_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
if utils_ldap.is_configured():
if status == 'Attended':
utils_ldap.add_to_group(member, 'Laser Users')
else:
utils_ldap.remove_from_group(member, 'Laser Users')
elif session.course.id == 321:
member.trotec_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
if utils_ldap.is_configured():
if status == 'Attended':
utils_ldap.add_to_group(member, 'Trotec Users')
else:
utils_ldap.remove_from_group(member, 'Trotec Users')
member.save()
# TODO: turn these into @actions
# TODO: check if full, but not for instructors
# TODO: if already paid, skip to confirmed
@ -222,19 +269,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
if (user and training1.exists()) or training2.exists():
raise exceptions.ValidationError(dict(non_field_errors='Already registered.'))
if session.course.id == 249:
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 261:
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 401:
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 281:
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 283:
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 259:
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
member.save()
self.update_cert(session, member, status)
serializer.save(user=user, member_id=member.id, attendance_status=status)
else:
@ -261,19 +296,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
else:
member = models.Member.objects.get(id=training.member_id)
if session.course.id == 249:
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 261:
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 401:
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 281:
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 283:
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
elif session.course.id == 259:
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
member.save()
self.update_cert(session, member, status)
class TransactionViewSet(Base, List, Create, Retrieve, Update):
@ -440,6 +463,11 @@ class StatsViewSet(viewsets.ViewSet, List):
cached_stats = cache.get_many(stats_keys)
stats = utils_stats.DEFAULTS.copy()
stats.update(cached_stats)
user = self.request.user
if not user.is_authenticated or not user.member.vetted_date:
stats.pop('alarm', None)
return Response(stats)
@action(detail=False, methods=['post'])
@ -462,11 +490,27 @@ class StatsViewSet(viewsets.ViewSet, List):
except KeyError:
raise exceptions.ValidationError(dict(data='This field is required.'))
@action(detail=False, methods=['post'])
def alarm(self, request):
try:
alarm = dict(time=time.time(), data=int(request.data['data']))
cache.set('alarm', alarm)
return Response(200)
except ValueError:
raise exceptions.ValidationError(dict(data='Invalid integer.'))
except KeyError:
raise exceptions.ValidationError(dict(data='This field is required.'))
@action(detail=False, methods=['post'])
def track(self, request):
if 'name' in request.data:
track = cache.get('track', {})
track[request.data['name']] = time.time()
name = request.data['name']
username = request.data.get('username', '')
username = username.split('.')[0].title()
track[name] = dict(time=time.time(), username=username)
cache.set('track', track)
return Response(200)
else:
@ -500,14 +544,18 @@ class BackupView(views.APIView):
backup_user = secrets.BACKUP_TOKENS.get(auth_token, None)
if backup_user:
logger.info('Backup user: ' + backup_user['name'])
backup_path = cache.get(backup_user['cache_key'], None)
if not backup_path:
logger.error('Backup not found')
raise Http404
if str(now().date()) not in backup_path:
# sanity check - make sure it's actually today's backup
return Response('Today\'s backup not ready yet', status=400)
msg = 'Today\'s backup not ready yet'
logger.error(msg)
return Response(msg, status=503)
backup_url = 'https://static.{}/backups/{}'.format(
settings.PRODUCTION_HOST,
@ -598,6 +646,9 @@ class PasswordResetView(PasswordResetView):
class PasswordResetConfirmView(PasswordResetConfirmView):
serializer_class = serializers.MyPasswordResetConfirmSerializer
class SpaceportAuthView(LoginView):
serializer_class = serializers.SpaceportAuthSerializer
@api_view()
def null_view(request, *args, **kwargs):

View File

@ -8,3 +8,10 @@ class IgnoreStats(logging.Filter):
return False
else:
return True
class IgnoreLockout(logging.Filter):
def filter(self, record):
if 'GET /lockout/' in record.msg:
return False
else:
return True

View File

@ -40,6 +40,16 @@ LDAP_API_URL = ''
# spaceport/ldapserver/secrets.py
LDAP_API_KEY = ''
# Auth API url
# should contain the IP and port of the script and machine connected over VPN
# with trailing slash
AUTH_API_URL = ''
# Auth API key
# should be equal to the auth token value set in
# spaceport/authserver/secrets.py
AUTH_API_KEY = ''
# Door cards API token
# Set this to random characters
# For example, use the output of this:
@ -50,6 +60,7 @@ DOOR_API_TOKEN = ''
DOOR_CODE = ''
WIFI_PASS = ''
MINECRAFT = ''
MUMBLE = ''
# Portal Email Credentials
# For sending password resets, etc.

View File

@ -116,6 +116,9 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'data/db.sqlite3'),
'OPTIONS': {
'timeout': 20, # increased because generate_backups.py blocks
},
},
'old_portal': {
'ENGINE': 'django.db.backends.sqlite3',
@ -221,11 +224,14 @@ LOGGING = {
'ignore_stats': {
'()': 'apiserver.filters.IgnoreStats',
},
'ignore_lockout': {
'()': 'apiserver.filters.IgnoreLockout',
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'filters': ['ignore_stats'],
'filters': ['ignore_stats', 'ignore_lockout'],
'class': 'logging.StreamHandler',
'formatter': 'medium'
},

View File

@ -33,6 +33,7 @@ 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'),
url(r'^password/reset/$', views.PasswordResetView.as_view(), name='rest_password_reset'),
url(r'^password/reset/confirm/$', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),

40
apiserver/delete_addresses.py Executable file
View File

@ -0,0 +1,40 @@
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
from apiserver.api import models
print('Deleting member object addresses...')
result = models.Member.objects.update(
street_address='',
postal_code='',
city='',
)
print(result, 'rows affected')
print()
print('Scrubbing history...')
result = models.Member.history.update(
street_address='',
postal_code='',
city='',
)
print(result, 'rows affected')
print()
print('Deleting historical changes...')
address_fields = ['street_address', 'postal_code', 'city']
result = models.HistoryChange.objects.filter(field__in=address_fields).update(
old='',
new='',
)
print(result, 'rows affected')
print()
print('Done.')

View File

@ -13,7 +13,7 @@ Install dependencies:
$ sudo apt install memcached
# Python:
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv
$ sudo apt install build-essential python3 python3-dev python3-pip python3-virtualenv
# Yarn / nodejs:
# from https://yarnpkg.com/lang/en/docs/install/#debian-stable
@ -111,7 +111,7 @@ Point a domain to the server and reverse proxy requests according to subdomain.
Domains: `portal.example.com`, `api.portal.example.com`, `static.portal.example.com`, `docs.portal.example.com` should all be reverse proxied.
Configure nginx:
Configure nginx (`/etc/nginx/sites-available/default`):
.. sourcecode:: text
@ -185,7 +185,7 @@ Install certbot and run it:
.. sourcecode:: bash
$ sudo apt install certbot python-certbot-nginx
$ sudo apt install certbot python3-certbot-nginx
$ sudo certbot --nginx
Answer the prompts, enable redirect.

View File

@ -10,7 +10,7 @@ Install dependencies:
.. sourcecode:: bash
$ sudo apt update
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor libsasl2-dev libldap2-dev libssl-dev
Clone the repo:

0
apiserver/gen_card_photos.py Normal file → Executable file
View File

View File

@ -0,0 +1,59 @@
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
import datetime
import json
import re
from apiserver.api import models, utils
def clean(name):
return re.sub(r'[^a-z]', '', name.lower())
with open('ad-rabbit.json', 'r') as f:
ad_dirty = json.load(f)
with open('ad-dump.json', 'r') as f:
ad_dump = json.load(f)
ad = {}
for sam in ad_dirty:
try:
ad[clean(sam)] = ad_dump[sam]['mail']
except KeyError:
continue
members = models.Member.objects.all()
portal = {}
for m in members:
name = m.first_name + m.last_name
portal[clean(name)] = m
good_members = {}
for ad_name, email in ad.items():
if ad_name in portal:
good_members[ad_name] = portal[ad_name]
print('found ad name match', ad_name)
else:
print('cant find ad name', ad_name)
print('searching for email...')
for m in members:
if m.old_email and m.old_email.lower() == email.lower():
good_members[ad_name] = m
print(' found email', email)
break
else:
print(' cant link email', email)
print()
print()
for m in good_members.values():
if not m.rabbit_cert_date:
m.rabbit_cert_date = utils.today_alberta_tz()
print('certified', m.first_name, m.last_name)
m.save()
else:
print('skipping', m.first_name, m.last_name)

View File

@ -0,0 +1,21 @@
# Expects a old_counts.csv of the historical counts in format:
# date,six_month_plus_count
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
import csv
from apiserver.api import models
with open('old_counts.csv', newline='') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
print('Adding', row['date'], row['six_month_plus_count'])
models.StatsMemberCount.objects.update_or_create(
date=row['date'],
defaults=dict(six_month_plus_count=row['six_month_plus_count']),
)
print('Done.')

View File

@ -0,0 +1,59 @@
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
import datetime
import json
import re
from apiserver.api import models, utils
def clean(name):
return re.sub(r'[^a-z]', '', name.lower())
with open('ad-trotec.json', 'r') as f:
ad_dirty = json.load(f)
with open('ad-dump.json', 'r') as f:
ad_dump = json.load(f)
ad = {}
for sam in ad_dirty:
try:
ad[clean(sam)] = ad_dump[sam]['mail']
except KeyError:
continue
members = models.Member.objects.all()
portal = {}
for m in members:
name = m.first_name + m.last_name
portal[clean(name)] = m
good_members = {}
for ad_name, email in ad.items():
if ad_name in portal:
good_members[ad_name] = portal[ad_name]
print('found ad name match', ad_name)
else:
print('cant find ad name', ad_name)
print('searching for email...')
for m in members:
if m.old_email and m.old_email.lower() == email.lower():
good_members[ad_name] = m
print(' found email', email)
break
else:
print(' cant link email', email)
print()
print()
for m in good_members.values():
if not m.trotec_cert_date:
m.trotec_cert_date = utils.today_alberta_tz()
print('certified', m.first_name, m.last_name)
m.save()
else:
print('skipping', m.first_name, m.last_name)

View File

@ -0,0 +1,21 @@
# Expects a old_counts.csv of the historical counts in format:
# date,vetted_count
import django, sys, os
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
django.setup()
import csv
from apiserver.api import models
with open('old_counts.csv', newline='') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
print('Adding', row['date'], row['vetted_count'])
models.StatsMemberCount.objects.update_or_create(
date=row['date'],
defaults=dict(vetted_count=row['vetted_count']),
)
print('Done.')

0
apiserver/lockout_auth_update.py Normal file → Executable file
View File

Binary file not shown.

Binary file not shown.

105
authserver/.gitignore vendored Normal file
View File

@ -0,0 +1,105 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Editor
*.swp
*.swo
secrets.py

17
authserver/README.md Normal file
View File

@ -0,0 +1,17 @@
# Auth Server
Runs on Protospace's webhost and passes credentials around.
Exposes a REST API to Spaceport that allows setting wiki, etc passwords.
## Setup
Basically the exact same as:
https://docs.my.protospace.ca/ldap.html
## License
This program is free and open-source software licensed under the MIT License. Please see the `LICENSE` file for details.
That means you have the right to study, change, and distribute the software and source code to anyone and for any purpose. You deserve these rights.

View File

@ -0,0 +1,39 @@
from log import logger
import time
import secrets
import subprocess
from flask import abort
HTTP_NOTFOUND = 404
def set_wiki_password(username, password):
# sets a user's wiki password
# creates the account if it doesn't exist
if not username:
logger.error('Empty username, aborting')
abort(400)
logger.info('Setting wiki password for: ' + username)
if not password:
logger.error('Empty password, aborting')
abort(400)
script = secrets.WIKI_MAINTENANCE + '/createAndPromote.php'
result = subprocess.run(['php', script, '--force', username, password],
shell=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output = result.stdout or result.stderr
output = output.strip()
logger.info('Output: ' + output)
if result.stderr:
abort(400)
if __name__ == '__main__':
set_wiki_password('tanner.collin', 'protospace1')
pass

22
authserver/log.py Normal file
View File

@ -0,0 +1,22 @@
import logging
import logging.config
logging.config.dictConfig({
'version': 1,
'formatters': {'default': {
'format': '[%(asctime)s] [%(process)d] [%(levelname)7s] %(message)s',
}},
'handlers': {'wsgi': {
'class': 'logging.StreamHandler',
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
}},
'root': {
'level': 'INFO',
'handlers': ['wsgi']
}
})
logger = logging.getLogger(__name__)
logger.info('Logging enabled.')

View File

@ -0,0 +1,6 @@
click==7.1.2
Flask==1.1.2
itsdangerous==1.1.0
Jinja2==2.11.2
MarkupSafe==1.1.1
Werkzeug==1.0.1

View File

@ -0,0 +1,12 @@
# Auth server secrets file, don't commit to version control!
# Auth token, used by Spaceport to authenticate
# Set this to random characters
# For example, use the first output of this:
# head /dev/urandom | sha1sum
AUTH_TOKEN = ''
# Absolute path of Mediawiki maintenance directory
# Probably:
# /var/www/wiki/maintenance
WIKI_MAINTENANCE = ''

29
authserver/server.py Normal file
View File

@ -0,0 +1,29 @@
from flask import Flask, abort, request
app = Flask(__name__)
import auth_functions
import secrets
HTTP_UNAUTHORIZED = 401
def check_auth():
auth_header = request.headers.get('Authorization', '')
if auth_header != 'Token ' + secrets.AUTH_TOKEN:
abort(HTTP_UNAUTHORIZED)
@app.route('/')
def index():
return '<i>LIFE IS BUT A DREAM...</i>'
@app.route('/set-password', methods=['POST'])
def set_password():
check_auth()
username = request.form['username']
password = request.form['password']
auth_functions.set_wiki_password(username, password)
return ''
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

View File

@ -0,0 +1,10 @@
# Gunicorn config file
#
# By default, a file named gunicorn.conf.py will be read from the same directory where gunicorn is being run.
# Reference: https://docs.gunicorn.org/en/latest/settings.html
import log
logconfig_dict = log.LOG_DICT
workers = 1
bind = ['0.0.0.0:5000']

View File

@ -1,3 +1,4 @@
from log import logger
import time
import ldap
import ldap.modlist as modlist
@ -7,14 +8,12 @@ import base64
from flask import abort
HTTP_NOTFOUND = 404
BASE_MEMBERS = 'OU=MembersOU,DC=ps,DC=protospace,DC=ca' # prod
BASE_GROUPS = 'OU=GroupsOU,DC=ps,DC=protospace,DC=ca' # prod
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, './ProtospaceAD.cer')
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, secrets.LDAP_CERTFILE)
def init_ldap():
ldap_conn = ldap.initialize('ldaps://ldap.ps.protospace.ca:636')
ldap_conn = ldap.initialize(secrets.LDAP_URL)
ldap_conn.set_option(ldap.OPT_REFERRALS, 0)
ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
ldap_conn.set_option(ldap.OPT_X_TLS,ldap.OPT_X_TLS_DEMAND)
@ -23,15 +22,34 @@ def init_ldap():
return ldap_conn
def find_user(username):
def convert(data):
if isinstance(data, dict):
return {convert(key): convert(value) for key, value in data.items()}
elif isinstance(data, (list, tuple)):
if len(data) == 1:
return convert(data[0])
else:
return [convert(element) for element in data]
elif isinstance(data, (bytes, bytearray)):
try:
return data.decode()
except UnicodeDecodeError:
return data.hex()
else:
return data
def find_user(query):
'''
Search for a user by sAMAccountname
Search for a user by sAMAccountname or email
'''
ldap_conn = init_ldap()
try:
logger.info('Looking up user ' + query)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username)
results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
criteria = '(&(objectClass=user)(|(mail={})(sAMAccountName={}))(!(objectClass=computer)))'.format(query, query)
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'])
logger.info(' Results: ' + str(results))
if len(results) != 1:
abort(HTTP_NOTFOUND)
@ -40,6 +58,23 @@ def find_user(username):
finally:
ldap_conn.unbind()
def find_dn(dn):
'''
Search for a user by dn
'''
ldap_conn = init_ldap()
try:
logger.info('Finding user for dn: ' + dn)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(!(objectClass=computer)))'
results = ldap_conn.search_s(dn, ldap.SCOPE_SUBTREE, criteria, ['sAMAccountName'])
logger.info(' Results: ' + str(results))
return results[0][1]['sAMAccountName'][0].decode()
finally:
ldap_conn.unbind()
def create_user(first, last, username, email, password):
'''
Create a User; required data is first, last, email, username, password
@ -47,8 +82,9 @@ def create_user(first, last, username, email, password):
'''
ldap_conn = init_ldap()
try:
logger.info('Creating user: ' + username)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
dn = 'CN={} {},{}'.format(first, last, BASE_MEMBERS)
dn = 'CN={} {},{}'.format(first, last, secrets.BASE_MEMBERS)
full_name = '{} {}'.format(first, last)
ldif = [
@ -64,7 +100,9 @@ def create_user(first, last, username, email, password):
('company', [b'Spaceport']),
]
ldap_conn.add_s(dn, ldif)
result = ldap_conn.add_s(dn, ldif)
logger.info(' Result: ' + str(result))
# set password
pass_quotes = '"{}"'.format(password)
@ -72,58 +110,69 @@ def create_user(first, last, username, email, password):
change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
result = ldap_conn.modify_s(dn, change_des)
logger.info(' Result: ' + str(result))
# 512 will set user account to enabled
mod_acct = [(ldap.MOD_REPLACE, 'userAccountControl', b'512')]
result = ldap_conn.modify_s(dn, mod_acct)
logger.info(' Result: ' + str(result))
finally:
ldap_conn.unbind()
def set_password(username, password):
ldap_conn = init_ldap()
try:
logger.info('Setting password for: ' + username)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username)
results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
if len(results) != 1:
abort(HTTP_NOTFOUND)
dn = results[0][0]
logger.info(' Dn found: ' + dn)
# set password
pass_quotes = '"{}"'.format(password)
pass_uni = pass_quotes.encode('utf-16-le')
change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
result = ldap_conn.modify_s(dn, change_des)
logger.info(' Set password result: ' + str(result))
finally:
ldap_conn.unbind()
def find_group(groupname):
'''
Search for a group by name or sAMAccountname. Retrun the DN
Search for a group by name or sAMAccountname
'''
ldap_conn = init_ldap()
try:
logger.info('Looking up group ' + groupname)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] )
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] )
logger.info(' Results: ' + str(results))
if len(results) != 1:
abort(HTTP_NOTFOUND)
return results[0][0]
finally:
ldap_conn.unbind()
def create_group(groupname,description):
def create_group(groupname, description):
'''
Create a Group; required data is sAMAccountName, Description
'''
ldap_conn = init_ldap()
try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
dn = 'CN={},{}'.format(groupname, BASE_GROUPS)
dn = 'CN={},{}'.format(groupname, secrets.BASE_GROUPS)
ldif = [
('objectClass', [b'top', b'group']),
@ -134,6 +183,51 @@ def create_group(groupname,description):
]
rcode = ldap_conn.add_s(dn, ldif)
return rcode
finally:
ldap_conn.unbind()
def add_to_group(groupname, username):
'''
Add a user to a Group; required data is GroupName, Username
'''
ldap_conn = init_ldap()
try:
logger.info('Adding ' + username + ' to group: ' + groupname)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
user_dn = find_user(username)
group_dn = find_group(groupname)
if not is_member(groupname, username):
mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())]
ldap_conn.modify_s(group_dn, mod_acct)
logger.info(' Added.')
return True
else:
logger.info(' Already a member, skipping.')
return False
finally:
ldap_conn.unbind()
def remove_from_group(groupname, username):
'''
Remove a user from a Group; required data is GroupName, Username
'''
ldap_conn = init_ldap()
try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
user_dn = find_user(username)
group_dn = find_group(groupname)
if is_member(groupname, username):
mod_acct = [(ldap.MOD_DELETE, 'member', user_dn.encode())]
ldap_conn.modify_s(group_dn, mod_acct)
return True
else:
logger.info('Not a member, skipping')
return False
finally:
ldap_conn.unbind()
@ -142,68 +236,95 @@ def list_group(groupname):
'''
List users in a Group; required data is GroupName
'''
members = []
ldap_conn = init_ldap()
try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
group_dn = find_group(groupname)
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] )
members_tmp = results[0][1]['member']
for m in members_tmp:
members.append(m)
# print("m = {}".format(m)) #Debug
return(members)
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'])
members_tmp = results[0][1]
members = members_tmp.get('member', [])
return [find_dn(dn.decode()) for dn in members]
finally:
ldap_conn.unbind()
def add_to_group(groupname,username):
def is_member(groupname, username):
'''
Add a user to a Group; required data is GroupName, Username
Checks to see if a user is a member of a group
'''
print("== Enter add_to_group ==")
ldap_conn = init_ldap()
try:
print(' --- Enter add_to_group with {0}, {1}---'.format(groupname,username))
logger.info('Checking if ' + username + ' is in group: ' + groupname)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
# get DN of the groupname
group_dn = find_group(groupname)
#get DN of the username
user_dn = find_user(username)
# -- TODO: Check to see if user is already a member, skip if not needed
mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())]
result = ldap_conn.modify_s(group_dn, mod_acct)
group_dn = find_group(groupname)
user_dn = find_user(username).encode()
memflag = False
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] )
members_tmp = results[0][1]
members = members_tmp.get('member', [])
result = user_dn in members
logger.info(' Result: ' + str(result))
return result
finally:
ldap_conn.unbind()
def dump_users():
'''
Dump all AD users
'''
ldap_conn = init_ldap()
try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(objectGUID=*))'
attributes = ['cn', 'sAMAccountName', 'mail', 'displayName', 'givenName', 'name', 'sn', 'logonCount', 'objectGUID']
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, attributes)
results = convert(results)
output = {}
for r in results:
tmp = r[1]
tmp['dn'] = r[0]
output[r[1]['sAMAccountName']] = tmp
import json
return json.dumps(output, indent=4)
finally:
ldap_conn.unbind()
# ===========================================================================
#guid = '\\b4\\51\\1adce6709c449bd21a812c423e82'
#guid = ''.join(['\\%s' % guid[i:i+2] for i in range(0, len(guid), 2)])
#print(guid)
#criteria = '(&(objectClass=user)(objectGUID={}))'.format(guid)
if __name__ == '__main__':
pass
#print(create_user('Elon', 'Tusk', 'elon.tusk', 'elont@example.com', 'protospace*&^g87g6'))
#print(find_user('tanner.collin'))
#print(set_password('dsaftanner.collin', 'Supersecret@@'))
# create a new group
create_group("testgroup")
print(find_group("testgroup")
# List Group members
print("-- Members of {}".format("Laser Trainers"))
group_members = list_group("Laser Trainers")
for member in group_members:
print('{}'.format(member))
# add users to test group
add_to_group("testgroup","pat.spencer")
add_to_group("testgroup","Tanner.Collin")
# List Group members
print("-- Members of {}".format("testgroup"))
group_members = list_group("testgroup")
for member in group_members:
print('{}'.format(member))
#print(set_password('tanner.collin', 'Supersecret@@'))
#print(find_dn('CN=Tanner Collin,OU=MembersOU,DC=ps,DC=protospace,DC=ca'))
#print("============================================================")
#print(create_group("newgroup", "new group"))
#print(" ============== ")
#print(list_group("Laser Users"))
#print(" ============== ")
#print(is_member('newgroup','tanner.collin'))
#print(" ============== ")
#print(add_to_group('newgroup','tanner.collin'))
#print(" ============== ")
#print(list_group("newgroup"))
#print(" ============== ")
#print(remove_from_group('newgroup','tanner.collin'))
#print(" ============== ")
print(list_group('Trotec Users'))
#print(dump_users())
#users = list_group('Laser Users')
#import json
#print(json.dumps(users, indent=4))

59
ldapserver/log.py Normal file
View File

@ -0,0 +1,59 @@
import logging
import logging.config
class IgnorePing(logging.Filter):
def filter(self, record):
return 'GET /ping' not in record.getMessage()
LOG_DICT = {
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s] [%(process)d] [%(levelname)7s] %(message)s',
},
},
'filters': {
'ignore_ping': {
'()': 'log.IgnorePing',
},
},
'handlers': {
'wsgi': {
'class': 'logging.StreamHandler',
'filters': ['ignore_ping'],
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
},
'console': {
'level': 'DEBUG',
'filters': ['ignore_ping'],
'class': 'logging.StreamHandler',
'formatter': 'default'
},
'null': {
'level': 'DEBUG',
'filters': ['ignore_ping'],
'class': 'logging.NullHandler',
'formatter': 'default'
},
},
'loggers': {
'gunicorn': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
},
},
'root': {
'level': 'INFO',
'handlers': ['wsgi']
}
}
logging.config.dictConfig(LOG_DICT)
logger = logging.getLogger(__name__)
logger.info('Logging enabled.')
from logging_tree import printout
printout()

View File

@ -3,6 +3,7 @@ Flask==1.1.1
gunicorn==20.0.4
itsdangerous==1.1.0
Jinja2==2.11.1
logging-tree==1.8.1
MarkupSafe==1.1.1
pyasn1==0.4.8
pyasn1-modules==0.2.8

View File

@ -8,3 +8,9 @@ AUTH_TOKEN = ''
LDAP_USERNAME = ''
LDAP_PASSWORD = ''
LDAP_CERTFILE = ''
LDAP_URL = ''
BASE_MEMBERS = ''
BASE_GROUPS = ''

View File

@ -1,3 +1,5 @@
from log import logger
from flask import Flask, abort, request
app = Flask(__name__)
@ -13,8 +15,14 @@ def check_auth():
@app.route('/')
def index():
logger.info('Index page requested')
return '<i>SEE YOU SPACE SAMURAI...</i>'
@app.route('/ping')
def ping():
return 'pong'
@app.route('/find-user', methods=['POST'])
def find_user():
check_auth()
@ -46,5 +54,25 @@ def set_password():
ldap_functions.set_password(username, password)
return ''
@app.route('/add-to-group', methods=['POST'])
def add_to_group():
check_auth()
groupname = request.form['group']
username = request.form.get('username', None) or request.form.get('email', None)
ldap_functions.add_to_group(groupname, username)
return ''
@app.route('/remove-from-group', methods=['POST'])
def remove_from_group():
check_auth()
groupname = request.form['group']
username = request.form.get('username', None) or request.form.get('email', None)
ldap_functions.remove_from_group(groupname, username)
return ''
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

View File

@ -21,7 +21,8 @@
"react-to-print": "~2.5.1",
"recharts": "~1.8.5",
"semantic-ui-react": "~0.88.2",
"three": "^0.119.1"
"three": "^0.119.1",
"serialize-javascript": "^3.1.0"
},
"scripts": {
"start": "react-scripts start",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -123,7 +123,7 @@ export function AdminMemberCards(props) {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [viewCard, setViewCard] = useState(false);
const [cardPhoto, setCardPhoto] = useState(false);
const { id } = useParams();
useEffect(() => {
@ -155,6 +155,18 @@ export function AdminMemberCards(props) {
});
};
const getCardPhoto = (e) => {
e.preventDefault();
requester('/members/' + id + '/card_photo/', 'GET', token)
.then(res => res.blob())
.then(res => {
setCardPhoto(URL.createObjectURL(res));
})
.catch(err => {
console.log(err);
});
};
const makeProps = (name) => ({
name: name,
onChange: handleChange,
@ -176,17 +188,17 @@ export function AdminMemberCards(props) {
<Form onSubmit={handleSubmit}>
<Header size='small'>Add a Card</Header>
{result.member.card_photo ?
{result.member.photo_large ?
<p>
<Button onClick={() => setViewCard(true)}>View card image</Button>
<Button onClick={(e) => getCardPhoto(e)}>View card image</Button>
</p>
:
<p>No card image, member photo missing!</p>
}
{viewCard && <>
{cardPhoto && <>
<p>
<Image rounded size='medium' src={staticUrl + '/' + result.member.card_photo} />
<Image rounded size='medium' src={cardPhoto} />
</p>
<Header size='small'>How to Print a Card</Header>
@ -221,7 +233,15 @@ export function AdminMemberCards(props) {
/>
</Form.Group>
<Form.Button loading={loading} error={error.non_field_errors}>
<Form.Checkbox
label='Confirmed that the member has been given a tour and knows the alarm code'
required
{...makeProps('given_tour')}
onChange={handleCheck}
checked={input.given_tour}
/>
<Form.Button disabled={!input.given_tour} loading={loading} error={error.non_field_errors}>
Submit
</Form.Button>
{success && <div>Success!</div>}
@ -498,6 +518,12 @@ export function AdminMemberInfo(props) {
<Table.Cell>Emergency Contact Phone:</Table.Cell>
<Table.Cell>{member.emergency_contact_phone || 'None'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>On Spaceport:</Table.Cell>
<Table.Cell>{member.user ? 'Yes' : 'No'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Public Bio:</Table.Cell>
</Table.Row>
@ -541,6 +567,7 @@ export function AdminCert(props) {
const handleCert = (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
let data = Object();
data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD');
@ -555,6 +582,7 @@ export function AdminCert(props) {
const handleUncert = (e) => {
e.preventDefault();
if (loading) return;
setLoading(true);
let data = Object();
data[field] = null;
@ -646,6 +674,18 @@ export function AdminMemberCertifications(props) {
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
<Table.Cell><AdminCert name='CNC' field='cnc_cert_date' {...props} /></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Rabbit Laser</Table.Cell>
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
<Table.Cell><AdminCert name='Rabbit' field='rabbit_cert_date' {...props} /></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Trotec Laser</Table.Cell>
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
<Table.Cell><AdminCert name='Trotec' field='trotec_cert_date' {...props} /></Table.Cell>
</Table.Row>
</Table.Body>
</Table>

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useReducer, useContext } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom';
import './semantic-ui/semantic.min.css';
import './light.css';
import './dark.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
import Darkmode from 'darkmode-js';
import { isAdmin, requester } from './utils.js';
@ -19,6 +20,7 @@ import { Courses, CourseDetail } from './Courses.js';
import { Classes, ClassDetail } from './Classes.js';
import { Members, MemberDetail } from './Members.js';
import { Charts } from './Charts.js';
import { Auth } from './Auth.js';
import { PasswordReset, ConfirmReset } from './PasswordReset.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { Footer } from './Footer.js';
@ -107,6 +109,10 @@ function App() {
<img src='/logo-long.svg' className='logo-long' />
</Link>
</div>
{window.location.hostname !== 'my.protospace.ca' &&
<p style={{ background: 'yellow' }}>~~~~~ Development site ~~~~~</p>
}
</Container>
<Menu>
@ -216,6 +222,10 @@ function App() {
<Charts />
</Route>
<Route path='/auth'>
<Auth user={user} />
</Route>
{user && user.member.set_details ?
<Switch>
<Route path='/account'>

132
webclient/src/Auth.js Normal file
View File

@ -0,0 +1,132 @@
import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom';
import moment from 'moment-timezone';
import './light.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react';
import { statusColor, BasicTable, staticUrl, requester, isAdmin } from './utils.js';
export function AuthForm(props) {
const { user } = props;
const username = user ? user.username : '';
const [input, setInput] = useState({ username: username });
const [error, setError] = useState({});
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const handleValues = (e, v) => setInput({ ...input, [v.name]: v.value });
const handleChange = (e) => handleValues(e, e.currentTarget);
const handleSubmit = (e) => {
if (input.username.includes('@')) {
setError({ username: 'Username, not email.' });
} else {
if (loading) return;
setLoading(true);
const data = { ...input, username: input.username.toLowerCase() };
requester('/spaceport-auth/login/', 'POST', '', data)
.then(res => {
setSuccess(true);
setError({});
})
.catch(err => {
setLoading(false);
console.log(err);
setError(err.data);
});
}
};
return (
success ?
props.children
:
<Form
onSubmit={handleSubmit}
warning={error.non_field_errors && error.non_field_errors[0] === 'Unable to log in with provided credentials.'}
>
<Header size='medium'>Log In to Spaceport</Header>
{user ?
<><Form.Input
label='Spaceport Username'
name='username'
value={user.username}
onChange={handleChange}
error={error.username}
/>
<Form.Input
label='Spaceport Password'
name='password'
type='password'
onChange={handleChange}
error={error.password}
autoFocus
/></>
:
<><Form.Input
label='Spaceport Username'
name='username'
placeholder='first.last'
onChange={handleChange}
error={error.username}
autoFocus
/>
<Form.Input
label='Spaceport Password'
name='password'
type='password'
onChange={handleChange}
error={error.password}
/></>
}
<Form.Button loading={loading} error={error.non_field_errors}>
Authorize
</Form.Button>
<Message warning>
<Message.Header>Forgot your password?</Message.Header>
<p><Link to='/password/reset/'>Click here</Link> to reset it.</p>
</Message>
</Form>
);
};
export function AuthWiki(props) {
const { user } = props;
return (
<Segment compact padded>
<Header size='medium'>
<Image src={'/wikilogo.png'} />
Protospace Wiki
</Header>
<p>would like to request Spaceport authentication.</p>
<p>URL: <a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>wiki.protospace.ca</a></p>
<AuthForm user={user}>
<Header size='small'>Success!</Header>
<p>You can now log into the wiki:</p>
<p><a href='http://wiki.protospace.ca/index.php?title=Special:UserLogin&returnto=Welcome+to+Protospace' rel='noopener noreferrer'>Protospace Wiki</a></p>
</AuthForm>
</Segment>
);
}
export function Auth(props) {
const { user } = props;
return (
<Container>
<Header size='large'>Spaceport Auth</Header>
<p>Use this page to link different applications to your Spaceport account.</p>
<Route path='/auth/wiki'>
<AuthWiki user={user} />
</Route>
</Container>
);
}

View File

@ -13,6 +13,7 @@ export function Charts(props) {
const [memberCount, setMemberCount] = useState(memberCountCache);
const [signupCount, setSignupCount] = useState(signupCountCache);
const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache);
const [fullActivity, setFullActivity] = useState(false);
useEffect(() => {
requester('/charts/membercount/', 'GET')
@ -47,6 +48,33 @@ export function Charts(props) {
<Container>
<Header size='large'>Charts</Header>
<Header size='medium'>Summary</Header>
{memberCount && signupCount &&
<>
<p>
The total member count is {memberCount.slice().reverse()[0].member_count} members,
compared to {memberCount.slice().reverse()[30].member_count} members 30 days ago.
</p>
<p>
The green member count is {memberCount.slice().reverse()[0].green_count} members,
compared to {memberCount.slice().reverse()[30].green_count} members 30 days ago.
</p>
<p>
The older than six months member count is {memberCount.slice().reverse()[0].six_month_plus_count} members,
compared to {memberCount.slice().reverse()[30].six_month_plus_count} members 30 days ago.
</p>
<p>
The vetted member count is {memberCount.slice().reverse()[0].vetted_count} members,
compared to {memberCount.slice().reverse()[30].vetted_count} members 30 days ago.
</p>
<p>
There were {signupCount.slice().reverse()[0].signup_count} signups so far this month,
and {signupCount.slice().reverse()[1].signup_count} signups last month.
</p>
</>
}
<Header size='medium'>Member Counts</Header>
<p>Daily since March 2nd, 2020.</p>
@ -87,18 +115,99 @@ export function Charts(props) {
}
</p>
<p>The Member Count is the amount of Prepaid, Current, Due, and Overdue members on Spaceport.</p>
<p>Member Count: number of active paying members on Spaceport.</p>
<p>The Green Count is the amount of Prepaid and Current members.</p>
<p>Green Count: number of Prepaid and Current members.</p>
<p>
{memberCount &&
<ResponsiveContainer width='100%' height={300}>
<LineChart data={memberCount}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip />
<Legend />
<Line
type='monotone'
dataKey='member_count'
name='Member Count'
stroke='#8884d8'
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
<Line
type='monotone'
dataKey='six_month_plus_count'
name='Six Months+'
stroke='red'
strokeWidth={2}
dot={false}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
}
</p>
<p>Member Count: same as above.</p>
<p>Six Months+: number of active memberships older than six months.</p>
<p>
{memberCount &&
<ResponsiveContainer width='100%' height={300}>
<LineChart data={memberCount}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
<Tooltip />
<Legend />
<Line
type='monotone'
dataKey='member_count'
name='Member Count'
stroke='#8884d8'
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
<Line
type='monotone'
dataKey='vetted_count'
name='Vetted Count'
stroke='purple'
strokeWidth={2}
dot={false}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
}
</p>
<p>Member Count: same as above.</p>
<p>Vetted Count: number of active vetted members.</p>
<Header size='medium'>Space Activity</Header>
<p>Daily since March 7th, 2020, updates hourly.</p>
{fullActivity ?
<p>Daily since March 7th, 2020, updates hourly.</p>
:
<p>
Last four weeks, updates hourly.
{' '}<Button size='tiny' onClick={() => setFullActivity(true)} >View All</Button>
</p>
}
<p>
{spaceActivity &&
<ResponsiveContainer width='100%' height={300}>
<BarChart data={spaceActivity}>
<BarChart data={fullActivity ? spaceActivity : spaceActivity.slice(-28)}>
<XAxis dataKey='date' minTickGap={10} />
<YAxis />
<CartesianGrid strokeDasharray='3 3'/>
@ -111,14 +220,14 @@ export function Charts(props) {
name='Card Scans'
fill='#8884d8'
maxBarSize={20}
animationDuration={1000}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
}
</p>
<p>Cards Scans is the number of individual members who have scanned to enter the space.</p>
<p>Cards Scans: number of individual members who have scanned to enter the space.</p>
<Header size='medium'>Signup Count</Header>
@ -146,14 +255,14 @@ export function Charts(props) {
type='monotone'
dataKey='vetted_count'
fill='#80b3d3'
name='Vetted Count'
name='Later Vetted Count'
maxBarSize={20}
animationDuration={1200}
/>
<Bar
type='monotone'
dataKey='retain_count'
name='Retain Count'
name='Retained Count'
fill='#82ca9d'
maxBarSize={20}
animationDuration={1400}
@ -163,11 +272,11 @@ export function Charts(props) {
}
</p>
<p>The Signup Count is the number of brand new account registrations that month.</p>
<p>Signup Count: number of brand new account registrations that month.</p>
<p>The Vetted Count is the number of those signups who eventually got vetted (at a later date).</p>
<p>Later Vetted Count: number of those signups who eventually got vetted (at a later date).</p>
<p>The Retain Count is the number of those signups who are still a member currently.</p>
<p>Retained Count: number of those signups who are still a member currently.</p>
</Container>
);

View File

@ -72,13 +72,19 @@ export function Classes(props) {
<Header size='large'>Class List</Header>
<Header size='medium'>Upcoming</Header>
<p>Ordered by nearest date.</p>
{classes ?
<ClassTable classes={classes.filter(x => x.datetime > now)} />
<ClassTable classes={classes.filter(x => x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
:
<p>Loading...</p>
}
<Header size='medium'>Recent</Header>
<p>Ordered by nearest date.</p>
{classes ?
<ClassTable classes={classes.filter(x => x.datetime < now)} />
:
@ -92,6 +98,7 @@ export function ClassDetail(props) {
const [clazz, setClass] = useState(false);
const [refreshCount, refreshClass] = useReducer(x => x + 1, 0);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const { token, user, refreshUser } = props;
const { id } = useParams();
const userTraining = clazz && clazz.students.find(x => x.user == user.id);
@ -108,6 +115,8 @@ export function ClassDetail(props) {
}, [refreshCount]);
const handleSignup = () => {
if (loading) return;
setLoading(true);
const data = { attendance_status: 'Waiting for payment', session: id };
requester('/training/', 'POST', token, data)
.then(res => {
@ -120,6 +129,8 @@ export function ClassDetail(props) {
};
const handleToggle = (newStatus) => {
if (loading) return;
setLoading(true);
const data = { attendance_status: newStatus, session: id };
requester('/training/'+userTraining.id+'/', 'PUT', token, data)
.then(res => {
@ -132,6 +143,10 @@ export function ClassDetail(props) {
});
};
useEffect(() => {
setLoading(false);
}, [userTraining]);
// TODO: calculate yesterday and lock signups
return (
@ -198,11 +213,11 @@ export function ClassDetail(props) {
<p>Status: {userTraining.attendance_status}</p>
<p>
{userTraining.attendance_status === 'Withdrawn' ?
<Button onClick={() => handleToggle('Waiting for payment')}>
<Button loading={loading} onClick={() => handleToggle('Waiting for payment')}>
Sign back up
</Button>
:
<Button onClick={() => handleToggle('Withdrawn')}>
<Button loading={loading} onClick={() => handleToggle('Withdrawn')}>
Withdraw from Class
</Button>
}
@ -226,7 +241,7 @@ export function ClassDetail(props) {
((clazz.max_students && clazz.student_count >= clazz.max_students) ?
<p>The class is full.</p>
:
<Button onClick={handleSignup}>
<Button loading={loading} onClick={handleSignup}>
Sign me up!
</Button>
)

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom';
import moment from 'moment-timezone';
import './light.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react';
@ -128,12 +128,15 @@ function MemberInfo(props) {
};
export function Home(props) {
const { user } = props;
const { user, token } = props;
const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false')));
const [refreshCount, refreshStats] = useReducer(x => x + 1, 0);
const location = useLocation();
const bypass_code = location.hash.replace('#', '');
useEffect(() => {
requester('/stats/', 'GET')
requester('/stats/', 'GET', token)
.then(res => {
setStats(res);
localStorage.setItem('stats', JSON.stringify(res));
@ -142,17 +145,21 @@ export function Home(props) {
console.log(err);
setStats(false);
});
}, [refreshCount]);
}, [refreshCount, token]);
const getStat = (x) => stats && stats[x] ? stats[x] : '?';
const getZeroStat = (x) => stats && stats[x] ? stats[x] : '0';
const getDateStat = (x) => stats && stats[x] ? moment.utc(stats[x]).tz('America/Edmonton').format('ll') : '?';
const mcPlayers = stats && stats['minecraft_players'] ? stats['minecraft_players'] : [];
const mumbleUsers = stats && stats['mumble_users'] ? stats['mumble_users'] : [];
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x] > 60 ? 'Free' : 'In Use' : '?';
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').format('llll') : 'Unknown';
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').fromNow() : '';
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x]['time'] > 60 ? 'Free' : 'In Use' : '?';
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').format('llll') : 'Unknown';
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : '';
const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['username'] ? stats.track[x]['username'] : 'Unknown';
const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] > 200 ? 'Armed' : 'Disarmed' : 'Unknown';
return (
<Container>
@ -172,9 +179,18 @@ export function Home(props) {
</div>
:
<div>
<LoginForm {...props} />
{bypass_code ?
<Message warning>
<Message.Header>Outside Registration</Message.Header>
<p>This page allows you to sign up from outside of Protospace.</p>
</Message>
:
<>
<LoginForm {...props} />
<Divider section horizontal>Or</Divider>
<Divider section horizontal>Or</Divider>
</>
}
<SignupForm {...props} />
</div>
@ -201,11 +217,10 @@ export function Home(props) {
<p>Next monthly clean: {getDateStat('next_clean')}</p>
<p>Member count: {getStat('member_count')} <Link to='/charts'>[more]</Link></p>
<p>Green members: {getStat('green_count')}</p>
<p>Old members: {getStat('paused_count')}</p>
<p>Card scans today: {getZeroStat('card_scans')}</p>
<p>
Minecraft players: {mcPlayers.length} <Popup content={
Minecraft players: {mcPlayers.length} {mcPlayers.length > 5 && '🔥'} <Popup content={
<React.Fragment>
<p>
Server IP:<br />
@ -217,6 +232,22 @@ export function Home(props) {
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
{' '}<a href='http://games.protospace.ca:8123/?worldname=world&mapname=flat&zoom=3&x=74&y=64&z=354' target='_blank'>[map]</a>
</p>
<p>
Mumble users: {mumbleUsers.length} <Popup content={
<React.Fragment>
<p>
Server IP:<br />
mumble.protospace.ca
</p>
<p>
Users:<br />
{mumbleUsers.length ? mumbleUsers.map(x => <React.Fragment>{x}<br /></React.Fragment>) : 'None'}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
<p>
@ -225,7 +256,8 @@ export function Home(props) {
<p>
Last use:<br />
{getTrackLast('TROTECS300')}<br />
{getTrackAgo('TROTECS300')}
{getTrackAgo('TROTECS300')}<br />
by {getTrackName('TROTECS300')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
@ -237,11 +269,14 @@ export function Home(props) {
<p>
Last use:<br />
{getTrackLast('FRICKIN-LASER')}<br />
{getTrackAgo('FRICKIN-LASER')}
{getTrackAgo('FRICKIN-LASER')}<br />
by {getTrackName('FRICKIN-LASER')}
</p>
</React.Fragment>
} trigger={<a>[more]</a>} />
</p>
{user && user.member.vetted_date && <p>Alarm status: {alarmStat()}</p>}
</div>
</Segment>

View File

@ -68,8 +68,12 @@ class AttendanceSheet extends React.Component {
function AttendanceRow(props) {
const { student, token, refreshClass } = props;
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const handleMark = (newStatus) => {
if (loading) return;
if (student.attendance_status == newStatus) return;
setLoading(newStatus);
const data = { ...student, attendance_status: newStatus };
requester('/training/'+student.id+'/', 'PATCH', token, data)
.then(res => {
@ -86,11 +90,19 @@ function AttendanceRow(props) {
onClick: () => handleMark(name),
toggle: true,
active: student.attendance_status === name,
loading: loading === name,
});
useEffect(() => {
setLoading(false);
}, [student.attendance_status]);
return (
<div className='attendance-row'>
<p>{student.student_name}:</p>
<p>
<Link to={'/members/'+student.student_id}>{student.student_name}</Link>
{student.attendance_status === 'Waiting for payment' && ' (Waiting for payment)'}:
</p>
<Button {...makeProps('Withdrawn')}>
Withdrawn
@ -118,9 +130,11 @@ function AttendanceRow(props) {
);
}
let attendanceOpenCache = false;
export function InstructorClassAttendance(props) {
const { clazz, token, refreshClass, user } = props;
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(attendanceOpenCache);
const [input, setInput] = useState({});
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
@ -201,7 +215,7 @@ export function InstructorClassAttendance(props) {
</Form>
</div>
:
<Button onClick={() => setOpen(true)}>
<Button onClick={() => {setOpen(true); attendanceOpenCache = true;}}>
Edit Attendance
</Button>
}
@ -321,7 +335,7 @@ export function InstructorClassDetail(props) {
export function InstructorClassList(props) {
const { course, setCourse, token } = props;
const [open, setOpen] = useState(false);
const [input, setInput] = useState({});
const [input, setInput] = useState({ max_students: null });
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);

View File

@ -110,7 +110,7 @@ export function SignupForm(props) {
return (
<Form onSubmit={handleSubmit}>
<Header size='medium'>Sign Up from Protospace</Header>
<Header size='medium'>Sign Up to Spaceport</Header>
<Form.Group widths='equal'>
<Form.Input

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import './light.css';
import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Input, Item, Menu, Message, Segment, Table } from 'semantic-ui-react';
import { statusColor, isAdmin, BasicTable, staticUrl, requester } from './utils.js';
import { statusColor, isAdmin, isInstructor, BasicTable, staticUrl, requester } from './utils.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { AdminMemberInfo, AdminMemberPause, AdminMemberForm, AdminMemberCards, AdminMemberTraining, AdminMemberCertifications } from './AdminMembers.js';
import { AdminMemberTransactions } from './AdminTransactions.js';
@ -107,7 +107,7 @@ export function Members(props) {
{x.member.preferred_name} {x.member.last_name}
</Item.Header>
<Item.Description>Status: {x.member.status || 'Unknown'}</Item.Description>
<Item.Description>Joined: {x.member.current_start_date || 'Unknown'}</Item.Description>
<Item.Description>Joined: {x.member.application_date || 'Unknown'}</Item.Description>
</Item.Content>
</Item>
)
@ -154,7 +154,7 @@ export function MemberDetail(props) {
<Header size='large'>{member.preferred_name} {member.last_name}</Header>
<Grid stackable columns={2}>
<Grid.Column>
<Grid.Column width={isAdmin(user) ? 8 : 5}>
<p>
<Image rounded size='medium' src={member.photo_large ? staticUrl + '/' + member.photo_large : '/nophoto.png'} />
</p>
@ -174,7 +174,7 @@ export function MemberDetail(props) {
</Table.Row>
<Table.Row>
<Table.Cell>Joined:</Table.Cell>
<Table.Cell>{member.current_start_date || 'Unknown'}</Table.Cell>
<Table.Cell>{member.application_date || 'Unknown'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Public Bio:</Table.Cell>
@ -189,7 +189,11 @@ export function MemberDetail(props) {
}
</Grid.Column>
<Grid.Column>
<Grid.Column width={isAdmin(user) ? 8 : 11}>
{isInstructor(user) && !isAdmin(user) && <Segment padded>
<AdminMemberTraining result={result} refreshResult={refreshResult} {...props} />
</Segment>}
{isAdmin(user) && <Segment padded>
<AdminMemberForm result={result} refreshResult={refreshResult} {...props} />
</Segment>}

View File

@ -37,7 +37,7 @@ function ResetForm() {
});
return (
<Form onSubmit={handleSubmit}>
<Form onSubmit={handleSubmit} error={error.email == 'Not found.'}>
<Form.Input
label='Email'
name='email'
@ -45,10 +45,16 @@ function ResetForm() {
error={error.email}
/>
<Message
error
header='Email not found in Spaceport'
content='You can only use this form if you have an account with this new member portal.'
/>
<Form.Button loading={loading} error={error.non_field_errors}>
Submit
</Form.Button>
{success && <div>Success!</div>}
{success && <div>Success! Be sure to check your spam folder.</div>}
</Form>
);
};

View File

@ -13,6 +13,8 @@ export function Paymaster(props) {
const [locker, setLocker] = useState('5.00');
const [donate, setDonate] = useState('20.00');
const monthly_fees = user.member.monthly_fees || 55;
return (
<Container>
<Header size='large'>Paymaster</Header>
@ -62,27 +64,27 @@ export function Paymaster(props) {
<Header size='medium'>Member Dues</Header>
<Grid stackable padded columns={3}>
<Grid.Column>
<p>Pay ${user.member.monthly_fees}.00 once:</p>
<p>Pay ${monthly_fees}.00 once:</p>
<PayPalPayNow
amount={user.member.monthly_fees}
amount={monthly_fees}
name='Protospace Membership'
custom={JSON.stringify({ member: user.member.id })}
/>
</Grid.Column>
<Grid.Column>
<p>Subscribe ${user.member.monthly_fees}.00 / month:</p>
<p>Subscribe ${monthly_fees}.00 / month:</p>
<PayPalSubscribe
amount={user.member.monthly_fees}
amount={monthly_fees}
name='Protospace Membership'
custom={JSON.stringify({ member: user.member.id })}
/>
</Grid.Column>
<Grid.Column>
<p>Pay ${user.member.monthly_fees * 11}.00 for a year:</p>
<p>Pay ${monthly_fees * 11}.00 for a year:</p>
<PayPalPayNow
amount={user.member.monthly_fees * 11}
amount={monthly_fees * 11}
name='Protospace Membership'
custom={JSON.stringify({ deal: 12, member: user.member.id })}
/>

View File

@ -57,6 +57,16 @@ export function CertList(props) {
<Table.Cell>{member.cnc_cert_date ? 'Yes, ' + member.cnc_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Rabbit Laser</Table.Cell>
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Trotec Laser</Table.Cell>
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);

View File

@ -23,14 +23,14 @@ export function TransactionEditor(props) {
});
const accountOptions = [
{ key: '0', text: 'Cash (CAD Lock Box)', value: 'Cash' },
{ key: '0', text: 'Cash (Lock Box)', value: 'Cash' },
{ key: '1', text: 'Interac (Email) Transfer (TD)', value: 'Interac' },
{ key: '2', text: 'Square (Credit)', value: 'Square Pmt' },
{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
{ key: '4', text: 'Deposit to TD (Not Interac)', value: 'TD Chequing' },
{ key: '5', text: 'PayPal', value: 'PayPal' },
{ key: '6', text: 'Member Balance / Protocash', value: 'Member' },
{ key: '7', text: 'Supense (Clearing) Acct / Membership Adjustment', value: 'Clearing' },
{ key: '2', text: 'Square (Credit Card)', value: 'Square Pmt' },
//{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
{ key: '4', text: 'Cheque / Deposit to TD', value: 'TD Chequing' },
//{ key: '5', text: 'Member Balance / Protocash', value: 'Member' },
{ key: '6', text: 'Membership Adjustment / Clearing', value: 'Clearing' },
{ key: '7', text: 'PayPal', value: 'PayPal' },
];
const sourceOptions = [
@ -53,9 +53,9 @@ export function TransactionEditor(props) {
{ key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' },
{ key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' },
{ key: '3', text: 'Donations', value: 'Donation' },
{ key: '4', text: 'Consumables (Specify which in memo)', value: 'Consumables' },
{ key: '4', text: 'Consumables (Explain in memo)', value: 'Consumables' },
{ key: '5', text: 'Purchase of Locker / Goods / Merch / Stock', value: 'Purchases' },
{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
//{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
{ key: '7', text: 'Reimbursement (Enter a negative value)', value: 'Reimburse' },
{ key: '8', text: 'Other (Explain in memo)', value: 'Other' },
];
@ -94,14 +94,14 @@ export function TransactionEditor(props) {
/>
<Form.Select
label='Account'
label='Payment Method / Account'
fluid
options={accountOptions}
{...makeProps('account_type')}
onChange={handleValues}
/>
<Form.Group widths='equal'>
{/* <Form.Group widths='equal'>
<Form.Input
label='Payment Method'
fluid
@ -114,7 +114,7 @@ export function TransactionEditor(props) {
{...makeProps('info_source')}
onChange={handleValues}
/>
</Form.Group>
</Form.Group> */}
<Form.Group widths='equal'>
<Form.Input
@ -124,7 +124,7 @@ export function TransactionEditor(props) {
/>
<Form.Input
label='# Membership Months'
label='Number of Membership Months'
fluid
{...makeProps('number_of_membership_months')}
/>
@ -349,10 +349,10 @@ class TransactionTable extends React.Component {
<Table.Cell>Account:</Table.Cell>
<Table.Cell>{transaction.account_type}</Table.Cell>
</Table.Row>
<Table.Row>
{/* <Table.Row>
<Table.Cell>Payment Method:</Table.Cell>
<Table.Cell>{transaction.payment_method}</Table.Cell>
</Table.Row>
</Table.Row> */}
<Table.Row>
<Table.Cell>Info Source:</Table.Cell>
<Table.Cell>{transaction.info_source}</Table.Cell>

57
webclient/src/dark.css Normal file
View File

@ -0,0 +1,57 @@
.darkmode-layer, .darkmode-toggle {
z-index: 500;
}
.darkmode--activated .ui.image {
mix-blend-mode: difference;
filter: brightness(75%);
}
.darkmode--activated i.green.circle.icon {
mix-blend-mode: difference;
color: #21ba4582 !important;
}
.darkmode--activated i.yellow.circle.icon {
mix-blend-mode: difference;
color: #fbbd0882 !important;
}
.darkmode--activated i.red.circle.icon {
mix-blend-mode: difference;
color: #db282882 !important;
}
.darkmode--activated .footer {
mix-blend-mode: difference;
}
.darkmode--activated .ql-toolbar.ql-snow,
.darkmode--activated .ql-container.ql-snow,
.darkmode--activated .ui.segment,
.darkmode--activated .ui.form .field input,
.darkmode--activated .ui.form .field .selection.dropdown,
.darkmode--activated .ui.form .field .ui.checkbox label::before,
.darkmode--activated .ui.form .field textarea {
border: 1px solid rgba(34,36,38,.50) !important;
}
.darkmode--activated .ui.basic.table tbody tr {
border-bottom: 1px solid rgba(34,36,38,.50) !important;
}
.darkmode--activated .ui.button {
background: #c9c9c9 !important;
}
.darkmode--activated .ui.red.button {
mix-blend-mode: difference;
background: #db282882 !important;
}
.darkmode--activated .ui.green.button,
.darkmode--activated .ui.button.toggle.active {
mix-blend-mode: difference;
background: #21ba4582 !important;
}

View File

@ -70,7 +70,17 @@ export const requester = (route, method, token, data) => {
if (!response.ok) {
throw customError(response);
}
return method === 'DELETE' ? {} : response.json();
if (method === 'DELETE') {
return {};
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.indexOf('application/json') !== -1) {
return response.json();
} else {
return response;
}
})
.catch(error => {
const code = error.data ? error.data.status : null;

View File

@ -3664,7 +3664,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9:
dependencies:
ms "2.0.0"
debug@^3.0.0, debug@^3.1.1, debug@^3.2.5:
debug@^3.1.1, debug@^3.2.5:
version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@ -4376,9 +4376,9 @@ eventemitter3@^2.0.3:
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
eventemitter3@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
events@^3.0.0:
version "3.1.0"
@ -4767,11 +4767,9 @@ flush-write-stream@^1.0.0:
readable-stream "^2.3.6"
follow-redirects@^1.0.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb"
integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==
dependencies:
debug "^3.0.0"
version "1.13.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
for-in@^0.1.3:
version "0.1.8"
@ -5335,9 +5333,9 @@ http-proxy-middleware@0.19.1:
micromatch "^3.1.10"
http-proxy@^1.17.0:
version "1.18.0"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
version "1.18.1"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
dependencies:
eventemitter3 "^4.0.0"
follow-redirects "^1.0.0"
@ -8842,7 +8840,7 @@ raf@^3.4.0, raf@^3.4.1:
dependencies:
performance-now "^2.1.0"
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
@ -9754,6 +9752,13 @@ serialize-javascript@^2.1.2:
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
serialize-javascript@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-3.1.0.tgz#8bf3a9170712664ef2561b44b691eafe399214ea"
integrity sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==
dependencies:
randombytes "^2.1.0"
serve-index@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"