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

View File

@ -9,13 +9,18 @@ class Command(BaseCommand):
def generate_stats(self): def generate_stats(self):
utils_stats.calc_next_events() 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() signup_count = utils_stats.calc_signup_counts()
# do this hourly in case an admin causes a change # do this hourly in case an admin causes a change
models.StatsMemberCount.objects.update_or_create( models.StatsMemberCount.objects.update_or_create(
date=utils.today_alberta_tz(), 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( models.StatsSignupCount.objects.update_or_create(

View File

@ -14,6 +14,8 @@ class Command(BaseCommand):
players = utils_stats.check_minecraft_server() players = utils_stats.check_minecraft_server()
self.stdout.write('Found Minecraft players: ' + str(players)) 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( self.stdout.write('Completed tasks in {} s'.format(
str(time.time() - start)[:4] 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_medium = models.CharField(max_length=64, blank=True, null=True)
photo_small = 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) 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) set_details = models.BooleanField(default=False)
first_name = models.CharField(max_length=32) 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) wood_cert_date = models.DateField(blank=True, null=True, default=None)
wood2_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) 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) paused_date = models.DateField(blank=True, null=True)
monthly_fees = models.IntegerField(default=55, 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) date = models.DateField(default=today_alberta_tz)
member_count = models.IntegerField() member_count = models.IntegerField()
green_count = models.IntegerField() green_count = models.IntegerField()
six_month_plus_count = models.IntegerField()
vetted_count = models.IntegerField()
class StatsSignupCount(models.Model): class StatsSignupCount(models.Model):
month = models.DateField() month = models.DateField()

View File

@ -7,11 +7,11 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.validators import UniqueValidator from rest_framework.validators import UniqueValidator
from rest_auth.registration.serializers import RegisterSerializer 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 from rest_auth.serializers import UserDetailsSerializer
import re import re
from . import models, fields, utils, utils_ldap from . import models, fields, utils, utils_ldap, utils_auth
from .. import settings, secrets from .. import settings, secrets
class TransactionSerializer(serializers.ModelSerializer): class TransactionSerializer(serializers.ModelSerializer):
@ -24,7 +24,7 @@ class TransactionSerializer(serializers.ModelSerializer):
'Square Pmt', 'Square Pmt',
'Member', 'Member',
'Clearing', 'Clearing',
'Cash' 'Cash',
]) ])
info_source = serializers.ChoiceField([ info_source = serializers.ChoiceField([
'Web', 'Web',
@ -38,7 +38,18 @@ class TransactionSerializer(serializers.ModelSerializer):
'IPN Trigger', 'IPN Trigger',
'Intranet Receipt', 'Intranet Receipt',
'Automatic', 'Automatic',
'Manual' 'Manual',
])
category = serializers.ChoiceField([
'Membership',
'OnAcct',
'Snacks',
'Donation',
'Consumables',
'Purchases',
'Garage Sale',
'Reimburse',
'Other',
]) ])
member_id = serializers.IntegerField() member_id = serializers.IntegerField()
member_name = serializers.SerializerMethodField() member_name = serializers.SerializerMethodField()
@ -95,6 +106,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
'last_name', 'last_name',
'status', 'status',
'current_start_date', 'current_start_date',
'application_date',
'photo_small', 'photo_small',
'photo_large', 'photo_large',
'public_bio', 'public_bio',
@ -103,6 +115,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
def get_status(self, obj): def get_status(self, obj):
return 'Former Member' if obj.paused_date else obj.status return 'Former Member' if obj.paused_date else obj.status
# member viewing his own details # member viewing his own details
class MemberSerializer(serializers.ModelSerializer): class MemberSerializer(serializers.ModelSerializer):
status = serializers.SerializerMethodField() status = serializers.SerializerMethodField()
@ -144,6 +157,8 @@ class MemberSerializer(serializers.ModelSerializer):
'wood_cert_date', 'wood_cert_date',
'wood2_cert_date', 'wood2_cert_date',
'cnc_cert_date', 'cnc_cert_date',
'rabbit_cert_date',
'trotec_cert_date',
] ]
def get_status(self, obj): def get_status(self, obj):
@ -163,7 +178,6 @@ class MemberSerializer(serializers.ModelSerializer):
instance.photo_small = small instance.photo_small = small
instance.photo_medium = medium instance.photo_medium = medium
instance.photo_large = large instance.photo_large = large
instance.card_photo = utils.gen_card_photo(instance)
return super().update(instance, validated_data) return super().update(instance, validated_data)
@ -173,6 +187,7 @@ class AdminMemberSerializer(MemberSerializer):
street_address = serializers.CharField(required=False) street_address = serializers.CharField(required=False)
city = serializers.CharField(required=False) city = serializers.CharField(required=False)
postal_code = serializers.CharField(required=False) postal_code = serializers.CharField(required=False)
monthly_fees = serializers.ChoiceField([10, 30, 35, 50, 55])
class Meta: class Meta:
model = models.Member model = models.Member
@ -193,6 +208,25 @@ class AdminMemberSerializer(MemberSerializer):
'is_staff', '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 # member viewing member list or search result
class SearchSerializer(serializers.Serializer): class SearchSerializer(serializers.Serializer):
@ -204,6 +238,24 @@ class SearchSerializer(serializers.Serializer):
serializer = OtherMemberSerializer(obj) serializer = OtherMemberSerializer(obj)
return serializer.data 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 # admin viewing search result
class AdminSearchSerializer(serializers.Serializer): class AdminSearchSerializer(serializers.Serializer):
cards = serializers.SerializerMethodField() cards = serializers.SerializerMethodField()
@ -289,6 +341,7 @@ class TrainingSerializer(serializers.ModelSerializer):
session = serializers.PrimaryKeyRelatedField(queryset=models.Session.objects.all()) session = serializers.PrimaryKeyRelatedField(queryset=models.Session.objects.all())
student_name = serializers.SerializerMethodField() student_name = serializers.SerializerMethodField()
student_email = serializers.SerializerMethodField() student_email = serializers.SerializerMethodField()
student_id = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Training model = models.Training
@ -309,6 +362,12 @@ class TrainingSerializer(serializers.ModelSerializer):
member = models.Member.objects.get(id=obj.member_id) member = models.Member.objects.get(id=obj.member_id)
return member.old_email 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): class StudentTrainingSerializer(TrainingSerializer):
attendance_status = serializers.ChoiceField(['Waiting for payment', 'Withdrawn']) attendance_status = serializers.ChoiceField(['Waiting for payment', 'Withdrawn'])
@ -321,6 +380,8 @@ class SessionSerializer(serializers.ModelSerializer):
datetime = serializers.DateTimeField() datetime = serializers.DateTimeField()
course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all()) course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all())
students = TrainingSerializer(many=True, read_only=True) 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: class Meta:
model = models.Session model = models.Session
@ -447,16 +508,38 @@ class MyPasswordChangeSerializer(PasswordChangeSerializer):
if utils_ldap.is_configured(): if utils_ldap.is_configured():
if utils_ldap.set_password(data) != 200: 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() super().save()
class MyPasswordResetSerializer(PasswordResetSerializer): class MyPasswordResetSerializer(PasswordResetSerializer):
def validate_email(self, email): def validate_email(self, email):
if not User.objects.filter(email__iexact=email).exists(): if not User.objects.filter(email__iexact=email).exists():
logging.info('Email not found: ' + email)
raise ValidationError('Not found.') raise ValidationError('Not found.')
return super().validate_email(email) 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): class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
def save(self): def save(self):
data = dict( data = dict(
@ -466,7 +549,25 @@ class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
if utils_ldap.is_configured(): if utils_ldap.is_configured():
if utils_ldap.set_password(data) != 200: 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() super().save()
@ -504,3 +605,13 @@ class HistorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.HistoryIndex model = models.HistoryIndex
fields = '__all__' 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 django.utils.timezone import now, pytz
from . import models, serializers, utils_ldap 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/' STATIC_FOLDER = 'data/static/'
@ -234,28 +229,29 @@ def gen_card_photo(member):
# check font size # check font size
font_sizes = (60, 72) font_sizes = (60, 72)
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1]) 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: if size[0] > CARD_TEXT_SIZE_LIMIT:
font_sizes = (36, 48) font_sizes = (36, 48)
font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0]) font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0])
x = CARD_PHOTO_MARGIN_SIDE x = CARD_PHOTO_MARGIN_SIDE
y = my + CARD_PHOTO_MARGIN_TOP + 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]) font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE + 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) 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 y = CARD_PHOTO_MARGIN_SIDE
draw.text((475, y), str(member.id), (0,0,0), font=font) draw.text((475, y), str(member.id), (0,0,0), font=font)
file_name = str(uuid4()) + '.jpg' bio = io.BytesIO()
card_template.save(STATIC_FOLDER + file_name, quality=95) card_template.save(bio, 'JPEG', quality=95)
bio.seek(0)
return file_name return bio
ALLOWED_TAGS = [ 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 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 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: try:
member = models.Member.objects.get(old_email__iexact=data['email']) member = models.Member.objects.get(old_email__iexact=data['email'])
except models.Member.DoesNotExist: 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) logger.info(msg)
raise ValidationError(dict(email=msg)) raise ValidationError(dict(email=msg))
except models.Member.MultipleObjectsReturned: except models.Member.MultipleObjectsReturned:
@ -318,15 +310,18 @@ def link_old_member(data, user):
if result == 200: if result == 200:
if utils_ldap.set_password(data) != 200: if utils_ldap.set_password(data) != 200:
msg = 'Problem connecting to LDAP server: set.' msg = 'Problem connecting to LDAP server: set.'
alert_tanner(msg)
logger.info(msg) logger.info(msg)
raise ValidationError(dict(non_field_errors=msg)) raise ValidationError(dict(non_field_errors=msg))
elif result == 404: elif result == 404:
if utils_ldap.create_user(data) != 200: if utils_ldap.create_user(data) != 200:
msg = 'Problem connecting to LDAP server: create.' msg = 'Problem connecting to LDAP server: create.'
alert_tanner(msg)
logger.info(msg) logger.info(msg)
raise ValidationError(dict(non_field_errors=msg)) raise ValidationError(dict(non_field_errors=msg))
else: else:
msg = 'Problem connecting to LDAP server: find.' msg = 'Problem connecting to LDAP server: find.'
alert_tanner(msg)
logger.info(msg) logger.info(msg)
raise ValidationError(dict(non_field_errors=msg)) raise ValidationError(dict(non_field_errors=msg))
@ -342,9 +337,8 @@ def link_old_member(data, user):
models.Training.objects.filter(member_id=member.id).update(user=user) models.Training.objects.filter(member_id=member.id).update(user=user)
def create_new_member(data, user): def create_new_member(data, user):
if old_models: members = models.Member.objects
old_members = old_models.Members.objects.using('old_portal') if members.filter(old_email__iexact=data['email']).exists():
if old_members.filter(email__iexact=data['email']).exists():
msg = 'Account was found in old portal.' msg = 'Account was found in old portal.'
logger.info(msg) logger.info(msg)
raise ValidationError(dict(email=msg)) raise ValidationError(dict(email=msg))
@ -359,11 +353,13 @@ def create_new_member(data, user):
pass pass
else: else:
msg = 'Problem connecting to LDAP server.' msg = 'Problem connecting to LDAP server.'
alert_tanner(msg)
logger.info(msg) logger.info(msg)
raise ValidationError(dict(non_field_errors=msg)) raise ValidationError(dict(non_field_errors=msg))
if utils_ldap.create_user(data) != 200: if utils_ldap.create_user(data) != 200:
msg = 'Problem connecting to LDAP server: create.' msg = 'Problem connecting to LDAP server: create.'
alert_tanner(msg)
logger.info(msg) logger.info(msg)
raise ValidationError(dict(non_field_errors=msg)) raise ValidationError(dict(non_field_errors=msg))
@ -393,7 +389,6 @@ def gen_member_forms(member):
packet = io.BytesIO() packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter) 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(34, 683, data['first_name'])
can.drawString(218, 683, data['last_name']) can.drawString(218, 683, data['last_name'])
can.drawString(403, 683, data['preferred_name']) can.drawString(403, 683, data['preferred_name'])
@ -407,7 +402,7 @@ def gen_member_forms(member):
packet = io.BytesIO() packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter) can = canvas.Canvas(packet, pagesize=letter)
can.drawRightString(600, 775, '{} {} ({})'.format( can.drawRightString(600, 770, '{} {} ({})'.format(
data['first_name'], data['first_name'],
data['last_name'], data['last_name'],
data['id'], 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 import requests
from apiserver import secrets from apiserver import secrets
from apiserver.api import utils
def is_configured(): def is_configured():
return bool(secrets.LDAP_API_URL and secrets.LDAP_API_KEY) return bool(secrets.LDAP_API_URL and secrets.LDAP_API_KEY)
@ -13,7 +14,7 @@ def ldap_api(route, data):
try: try:
headers = {'Authorization': 'Token ' + secrets.LDAP_API_KEY} headers = {'Authorization': 'Token ' + secrets.LDAP_API_KEY}
url = secrets.LDAP_API_URL + route 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 return r.status_code
except BaseException as e: except BaseException as e:
logger.error('LDAP {} - {} - {}'.format(url, e.__class__.__name__, str(e))) logger.error('LDAP {} - {} - {}'.format(url, e.__class__.__name__, str(e)))
@ -39,3 +40,37 @@ def set_password(data):
password=data['password1'], password=data['password1'],
) )
return ldap_api('set-password', ldap_data) 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__) logger = logging.getLogger(__name__)
import time import time
from datetime import date, datetime from datetime import date, datetime, timedelta
import requests import requests
from django.core.cache import cache from django.core.cache import cache
from django.utils.timezone import now, pytz from django.utils.timezone import now, pytz
@ -22,8 +22,10 @@ DEFAULTS = {
'bay_108_temp': None, 'bay_108_temp': None,
'bay_110_temp': None, 'bay_110_temp': None,
'minecraft_players': [], 'minecraft_players': [],
'mumble_users': [],
'card_scans': 0, 'card_scans': 0,
'track': {}, 'track': {},
'alarm': {},
} }
def changed_card(): def changed_card():
@ -62,11 +64,16 @@ def calc_member_counts():
paused_count = members.count() - member_count paused_count = members.count() - member_count
green_count = num_current + num_prepaid 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('member_count', member_count)
cache.set('paused_count', paused_count) cache.set('paused_count', paused_count)
cache.set('green_count', green_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(): def calc_signup_counts():
month_beginning = today_alberta_tz().replace(day=1) month_beginning = today_alberta_tz().replace(day=1)
@ -114,6 +121,21 @@ def check_minecraft_server():
return [] 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(): def calc_card_scans():
date = today_alberta_tz() date = today_alberta_tz()
cards = models.Card.objects cards = models.Card.objects

View File

@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.db.models import Max 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.files.base import File
from django.core.cache import cache from django.core.cache import cache
from django.utils.timezone import now 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.decorators import action, api_view
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly
from rest_framework.response import Response 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 rest_auth.registration.views import RegisterView
from fuzzywuzzy import fuzz, process from fuzzywuzzy import fuzz, process
from collections import OrderedDict from collections import OrderedDict
@ -20,7 +20,7 @@ import datetime, time
import requests 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 ( from .permissions import (
is_admin_director, is_admin_director,
AllowMetadata, AllowMetadata,
@ -50,6 +50,8 @@ class SearchViewSet(Base, Retrieve):
def get_serializer_class(self): def get_serializer_class(self):
if is_admin_director(self.request.user) and self.action == 'retrieve': if is_admin_director(self.request.user) and self.action == 'retrieve':
return serializers.AdminSearchSerializer return serializers.AdminSearchSerializer
elif self.request.user.member.is_instructor and self.action == 'retrieve':
return serializers.InstructorSearchSerializer
else: else:
return serializers.SearchSerializer return serializers.SearchSerializer
@ -82,6 +84,7 @@ class SearchViewSet(Base, Retrieve):
result_objects = [queryset.get(id=x) for x in result_ids] result_objects = [queryset.get(id=x) for x in result_ids]
queryset = result_objects queryset = result_objects
logging.info('Search for: {}, results: {}'.format(search, len(queryset)))
elif self.action == 'create': elif self.action == 'create':
utils.gen_search_strings() # update cache utils.gen_search_strings() # update cache
queryset = queryset.order_by('-vetted_date') queryset = queryset.order_by('-vetted_date')
@ -137,12 +140,24 @@ class MemberViewSet(Base, Retrieve, Update):
member = self.get_object() member = self.get_object()
member.current_start_date = utils.today_alberta_tz() member.current_start_date = utils.today_alberta_tz()
member.paused_date = None member.paused_date = None
if not member.monthly_fees:
member.monthly_fees = 55
member.save() member.save()
utils.tally_membership_months(member) utils.tally_membership_months(member)
utils.gen_member_forms(member) utils.gen_member_forms(member)
utils_stats.changed_card() utils_stats.changed_card()
return Response(200) 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): class CardViewSet(Base, Create, Retrieve, Update, Destroy):
permission_classes = [AllowMetadata | IsAdmin] permission_classes = [AllowMetadata | IsAdmin]
@ -200,6 +215,38 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
else: else:
return serializers.StudentTrainingSerializer 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: turn these into @actions
# TODO: check if full, but not for instructors # TODO: check if full, but not for instructors
# TODO: if already paid, skip to confirmed # 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(): if (user and training1.exists()) or training2.exists():
raise exceptions.ValidationError(dict(non_field_errors='Already registered.')) raise exceptions.ValidationError(dict(non_field_errors='Already registered.'))
if session.course.id == 249: self.update_cert(session, member, status)
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()
serializer.save(user=user, member_id=member.id, attendance_status=status) serializer.save(user=user, member_id=member.id, attendance_status=status)
else: else:
@ -261,19 +296,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
else: else:
member = models.Member.objects.get(id=training.member_id) member = models.Member.objects.get(id=training.member_id)
if session.course.id == 249: self.update_cert(session, member, status)
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()
class TransactionViewSet(Base, List, Create, Retrieve, Update): class TransactionViewSet(Base, List, Create, Retrieve, Update):
@ -440,6 +463,11 @@ class StatsViewSet(viewsets.ViewSet, List):
cached_stats = cache.get_many(stats_keys) cached_stats = cache.get_many(stats_keys)
stats = utils_stats.DEFAULTS.copy() stats = utils_stats.DEFAULTS.copy()
stats.update(cached_stats) 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) return Response(stats)
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
@ -462,11 +490,27 @@ class StatsViewSet(viewsets.ViewSet, List):
except KeyError: except KeyError:
raise exceptions.ValidationError(dict(data='This field is required.')) raise exceptions.ValidationError(dict(data='This field is required.'))
@action(detail=False, methods=['post'])
def 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']) @action(detail=False, methods=['post'])
def track(self, request): def track(self, request):
if 'name' in request.data: if 'name' in request.data:
track = cache.get('track', {}) 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) cache.set('track', track)
return Response(200) return Response(200)
else: else:
@ -500,14 +544,18 @@ class BackupView(views.APIView):
backup_user = secrets.BACKUP_TOKENS.get(auth_token, None) backup_user = secrets.BACKUP_TOKENS.get(auth_token, None)
if backup_user: if backup_user:
logger.info('Backup user: ' + backup_user['name'])
backup_path = cache.get(backup_user['cache_key'], None) backup_path = cache.get(backup_user['cache_key'], None)
if not backup_path: if not backup_path:
logger.error('Backup not found')
raise Http404 raise Http404
if str(now().date()) not in backup_path: if str(now().date()) not in backup_path:
# sanity check - make sure it's actually today's backup # 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( backup_url = 'https://static.{}/backups/{}'.format(
settings.PRODUCTION_HOST, settings.PRODUCTION_HOST,
@ -598,6 +646,9 @@ class PasswordResetView(PasswordResetView):
class PasswordResetConfirmView(PasswordResetConfirmView): class PasswordResetConfirmView(PasswordResetConfirmView):
serializer_class = serializers.MyPasswordResetConfirmSerializer serializer_class = serializers.MyPasswordResetConfirmSerializer
class SpaceportAuthView(LoginView):
serializer_class = serializers.SpaceportAuthSerializer
@api_view() @api_view()
def null_view(request, *args, **kwargs): def null_view(request, *args, **kwargs):

View File

@ -8,3 +8,10 @@ class IgnoreStats(logging.Filter):
return False return False
else: else:
return True 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 # spaceport/ldapserver/secrets.py
LDAP_API_KEY = '' 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 # Door cards API token
# Set this to random characters # Set this to random characters
# For example, use the output of this: # For example, use the output of this:
@ -50,6 +60,7 @@ DOOR_API_TOKEN = ''
DOOR_CODE = '' DOOR_CODE = ''
WIFI_PASS = '' WIFI_PASS = ''
MINECRAFT = '' MINECRAFT = ''
MUMBLE = ''
# Portal Email Credentials # Portal Email Credentials
# For sending password resets, etc. # For sending password resets, etc.

View File

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

View File

@ -33,6 +33,7 @@ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path(ADMIN_ROUTE, admin.site.urls), path(ADMIN_ROUTE, admin.site.urls),
url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'), url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'),
url(r'^spaceport-auth/login/$', views.SpaceportAuthView.as_view(), name='spaceport_auth'),
url(r'^rest-auth/logout/$', LogoutView.as_view(), name='rest_logout'), 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/$', views.PasswordResetView.as_view(), name='rest_password_reset'),
url(r'^password/reset/confirm/$', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), 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 $ sudo apt install memcached
# Python: # 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: # Yarn / nodejs:
# from https://yarnpkg.com/lang/en/docs/install/#debian-stable # 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. 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 .. sourcecode:: text
@ -185,7 +185,7 @@ Install certbot and run it:
.. sourcecode:: bash .. sourcecode:: bash
$ sudo apt install certbot python-certbot-nginx $ sudo apt install certbot python3-certbot-nginx
$ sudo certbot --nginx $ sudo certbot --nginx
Answer the prompts, enable redirect. Answer the prompts, enable redirect.

View File

@ -10,7 +10,7 @@ Install dependencies:
.. sourcecode:: bash .. sourcecode:: bash
$ sudo apt update $ 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: 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 time
import ldap import ldap
import ldap.modlist as modlist import ldap.modlist as modlist
@ -7,14 +8,12 @@ import base64
from flask import abort from flask import abort
HTTP_NOTFOUND = 404 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_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(): 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_REFERRALS, 0)
ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3) ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
ldap_conn.set_option(ldap.OPT_X_TLS,ldap.OPT_X_TLS_DEMAND) ldap_conn.set_option(ldap.OPT_X_TLS,ldap.OPT_X_TLS_DEMAND)
@ -23,15 +22,34 @@ def init_ldap():
return ldap_conn 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() ldap_conn = init_ldap()
try: try:
logger.info('Looking up user ' + query)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username) criteria = '(&(objectClass=user)(|(mail={})(sAMAccountName={}))(!(objectClass=computer)))'.format(query, query)
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'])
logger.info(' Results: ' + str(results))
if len(results) != 1: if len(results) != 1:
abort(HTTP_NOTFOUND) abort(HTTP_NOTFOUND)
@ -40,6 +58,23 @@ def find_user(username):
finally: finally:
ldap_conn.unbind() 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): def create_user(first, last, username, email, password):
''' '''
Create a User; required data is first, last, email, username, 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() ldap_conn = init_ldap()
try: try:
logger.info('Creating user: ' + username)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) 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) full_name = '{} {}'.format(first, last)
ldif = [ ldif = [
@ -64,7 +100,9 @@ def create_user(first, last, username, email, password):
('company', [b'Spaceport']), ('company', [b'Spaceport']),
] ]
ldap_conn.add_s(dn, ldif) result = ldap_conn.add_s(dn, ldif)
logger.info(' Result: ' + str(result))
# set password # set password
pass_quotes = '"{}"'.format(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])] change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
result = ldap_conn.modify_s(dn, change_des) result = ldap_conn.modify_s(dn, change_des)
logger.info(' Result: ' + str(result))
# 512 will set user account to enabled # 512 will set user account to enabled
mod_acct = [(ldap.MOD_REPLACE, 'userAccountControl', b'512')] mod_acct = [(ldap.MOD_REPLACE, 'userAccountControl', b'512')]
result = ldap_conn.modify_s(dn, mod_acct) result = ldap_conn.modify_s(dn, mod_acct)
logger.info(' Result: ' + str(result))
finally: finally:
ldap_conn.unbind() ldap_conn.unbind()
def set_password(username, password): def set_password(username, password):
ldap_conn = init_ldap() ldap_conn = init_ldap()
try: try:
logger.info('Setting password for: ' + username)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username) 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: if len(results) != 1:
abort(HTTP_NOTFOUND) abort(HTTP_NOTFOUND)
dn = results[0][0] dn = results[0][0]
logger.info(' Dn found: ' + dn)
# set password # set password
pass_quotes = '"{}"'.format(password) pass_quotes = '"{}"'.format(password)
pass_uni = pass_quotes.encode('utf-16-le') pass_uni = pass_quotes.encode('utf-16-le')
change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])] change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
result = ldap_conn.modify_s(dn, change_des) result = ldap_conn.modify_s(dn, change_des)
logger.info(' Set password result: ' + str(result))
finally: finally:
ldap_conn.unbind() ldap_conn.unbind()
def find_group(groupname): 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() ldap_conn = init_ldap()
try: try:
logger.info('Looking up group ' + groupname)
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname) 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: if len(results) != 1:
abort(HTTP_NOTFOUND) abort(HTTP_NOTFOUND)
return results[0][0] return results[0][0]
finally: finally:
ldap_conn.unbind() ldap_conn.unbind()
def create_group(groupname,description): def create_group(groupname, description):
''' '''
Create a Group; required data is sAMAccountName, Description Create a Group; required data is sAMAccountName, Description
''' '''
ldap_conn = init_ldap() ldap_conn = init_ldap()
try: try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) 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 = [ ldif = [
('objectClass', [b'top', b'group']), ('objectClass', [b'top', b'group']),
@ -134,6 +183,51 @@ def create_group(groupname,description):
] ]
rcode = ldap_conn.add_s(dn, ldif) 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: finally:
ldap_conn.unbind() ldap_conn.unbind()
@ -142,68 +236,95 @@ def list_group(groupname):
''' '''
List users in a Group; required data is GroupName List users in a Group; required data is GroupName
''' '''
members = []
ldap_conn = init_ldap() ldap_conn = init_ldap()
try: try:
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
group_dn = find_group(groupname) group_dn = find_group(groupname)
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname) criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] ) results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'])
members_tmp = results[0][1]['member'] members_tmp = results[0][1]
for m in members_tmp: members = members_tmp.get('member', [])
members.append(m) return [find_dn(dn.decode()) for dn in members]
# print("m = {}".format(m)) #Debug
return(members)
finally: finally:
ldap_conn.unbind() 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() ldap_conn = init_ldap()
try: 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) ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
# get DN of the groupname
group_dn = find_group(groupname) 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
#get DN of the username logger.info(' Result: ' + str(result))
user_dn = find_user(username) return result
finally:
ldap_conn.unbind()
# -- TODO: Check to see if user is already a member, skip if not needed 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)
mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())] output = {}
result = ldap_conn.modify_s(group_dn, mod_acct) 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: finally:
ldap_conn.unbind() 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__': if __name__ == '__main__':
pass
#print(create_user('Elon', 'Tusk', 'elon.tusk', 'elont@example.com', 'protospace*&^g87g6'))
#print(find_user('tanner.collin')) #print(find_user('tanner.collin'))
#print(set_password('dsaftanner.collin', 'Supersecret@@')) #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())
# create a new group #users = list_group('Laser Users')
create_group("testgroup") #import json
print(find_group("testgroup") #print(json.dumps(users, indent=4))
# 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))

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 gunicorn==20.0.4
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.1 Jinja2==2.11.1
logging-tree==1.8.1
MarkupSafe==1.1.1 MarkupSafe==1.1.1
pyasn1==0.4.8 pyasn1==0.4.8
pyasn1-modules==0.2.8 pyasn1-modules==0.2.8

View File

@ -8,3 +8,9 @@ AUTH_TOKEN = ''
LDAP_USERNAME = '' LDAP_USERNAME = ''
LDAP_PASSWORD = '' 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 from flask import Flask, abort, request
app = Flask(__name__) app = Flask(__name__)
@ -13,8 +15,14 @@ def check_auth():
@app.route('/') @app.route('/')
def index(): def index():
logger.info('Index page requested')
return '<i>SEE YOU SPACE SAMURAI...</i>' return '<i>SEE YOU SPACE SAMURAI...</i>'
@app.route('/ping')
def ping():
return 'pong'
@app.route('/find-user', methods=['POST']) @app.route('/find-user', methods=['POST'])
def find_user(): def find_user():
check_auth() check_auth()
@ -46,5 +54,25 @@ def set_password():
ldap_functions.set_password(username, password) ldap_functions.set_password(username, password)
return '' 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__': if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0') app.run(debug=True, host='0.0.0.0')

View File

@ -21,7 +21,8 @@
"react-to-print": "~2.5.1", "react-to-print": "~2.5.1",
"recharts": "~1.8.5", "recharts": "~1.8.5",
"semantic-ui-react": "~0.88.2", "semantic-ui-react": "~0.88.2",
"three": "^0.119.1" "three": "^0.119.1",
"serialize-javascript": "^3.1.0"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "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 [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [viewCard, setViewCard] = useState(false); const [cardPhoto, setCardPhoto] = useState(false);
const { id } = useParams(); const { id } = useParams();
useEffect(() => { 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) => ({ const makeProps = (name) => ({
name: name, name: name,
onChange: handleChange, onChange: handleChange,
@ -176,17 +188,17 @@ export function AdminMemberCards(props) {
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Header size='small'>Add a Card</Header> <Header size='small'>Add a Card</Header>
{result.member.card_photo ? {result.member.photo_large ?
<p> <p>
<Button onClick={() => setViewCard(true)}>View card image</Button> <Button onClick={(e) => getCardPhoto(e)}>View card image</Button>
</p> </p>
: :
<p>No card image, member photo missing!</p> <p>No card image, member photo missing!</p>
} }
{viewCard && <> {cardPhoto && <>
<p> <p>
<Image rounded size='medium' src={staticUrl + '/' + result.member.card_photo} /> <Image rounded size='medium' src={cardPhoto} />
</p> </p>
<Header size='small'>How to Print a Card</Header> <Header size='small'>How to Print a Card</Header>
@ -221,7 +233,15 @@ export function AdminMemberCards(props) {
/> />
</Form.Group> </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 Submit
</Form.Button> </Form.Button>
{success && <div>Success!</div>} {success && <div>Success!</div>}
@ -498,6 +518,12 @@ export function AdminMemberInfo(props) {
<Table.Cell>Emergency Contact Phone:</Table.Cell> <Table.Cell>Emergency Contact Phone:</Table.Cell>
<Table.Cell>{member.emergency_contact_phone || 'None'}</Table.Cell> <Table.Cell>{member.emergency_contact_phone || 'None'}</Table.Cell>
</Table.Row> </Table.Row>
<Table.Row>
<Table.Cell>On Spaceport:</Table.Cell>
<Table.Cell>{member.user ? 'Yes' : 'No'}</Table.Cell>
</Table.Row>
<Table.Row> <Table.Row>
<Table.Cell>Public Bio:</Table.Cell> <Table.Cell>Public Bio:</Table.Cell>
</Table.Row> </Table.Row>
@ -541,6 +567,7 @@ export function AdminCert(props) {
const handleCert = (e) => { const handleCert = (e) => {
e.preventDefault(); e.preventDefault();
if (loading) return;
setLoading(true); setLoading(true);
let data = Object(); let data = Object();
data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD'); data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD');
@ -555,6 +582,7 @@ export function AdminCert(props) {
const handleUncert = (e) => { const handleUncert = (e) => {
e.preventDefault(); e.preventDefault();
if (loading) return;
setLoading(true); setLoading(true);
let data = Object(); let data = Object();
data[field] = null; 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><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.Cell><AdminCert name='CNC' field='cnc_cert_date' {...props} /></Table.Cell>
</Table.Row> </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.Body>
</Table> </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 { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom';
import './semantic-ui/semantic.min.css'; import './semantic-ui/semantic.min.css';
import './light.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 { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
import Darkmode from 'darkmode-js'; import Darkmode from 'darkmode-js';
import { isAdmin, requester } from './utils.js'; import { isAdmin, requester } from './utils.js';
@ -19,6 +20,7 @@ import { Courses, CourseDetail } from './Courses.js';
import { Classes, ClassDetail } from './Classes.js'; import { Classes, ClassDetail } from './Classes.js';
import { Members, MemberDetail } from './Members.js'; import { Members, MemberDetail } from './Members.js';
import { Charts } from './Charts.js'; import { Charts } from './Charts.js';
import { Auth } from './Auth.js';
import { PasswordReset, ConfirmReset } from './PasswordReset.js'; import { PasswordReset, ConfirmReset } from './PasswordReset.js';
import { NotFound, PleaseLogin } from './Misc.js'; import { NotFound, PleaseLogin } from './Misc.js';
import { Footer } from './Footer.js'; import { Footer } from './Footer.js';
@ -107,6 +109,10 @@ function App() {
<img src='/logo-long.svg' className='logo-long' /> <img src='/logo-long.svg' className='logo-long' />
</Link> </Link>
</div> </div>
{window.location.hostname !== 'my.protospace.ca' &&
<p style={{ background: 'yellow' }}>~~~~~ Development site ~~~~~</p>
}
</Container> </Container>
<Menu> <Menu>
@ -216,6 +222,10 @@ function App() {
<Charts /> <Charts />
</Route> </Route>
<Route path='/auth'>
<Auth user={user} />
</Route>
{user && user.member.set_details ? {user && user.member.set_details ?
<Switch> <Switch>
<Route path='/account'> <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 [memberCount, setMemberCount] = useState(memberCountCache);
const [signupCount, setSignupCount] = useState(signupCountCache); const [signupCount, setSignupCount] = useState(signupCountCache);
const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache); const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache);
const [fullActivity, setFullActivity] = useState(false);
useEffect(() => { useEffect(() => {
requester('/charts/membercount/', 'GET') requester('/charts/membercount/', 'GET')
@ -47,6 +48,33 @@ export function Charts(props) {
<Container> <Container>
<Header size='large'>Charts</Header> <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> <Header size='medium'>Member Counts</Header>
<p>Daily since March 2nd, 2020.</p> <p>Daily since March 2nd, 2020.</p>
@ -87,18 +115,99 @@ export function Charts(props) {
} }
</p> </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> <Header size='medium'>Space Activity</Header>
{fullActivity ?
<p>Daily since March 7th, 2020, updates hourly.</p> <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> <p>
{spaceActivity && {spaceActivity &&
<ResponsiveContainer width='100%' height={300}> <ResponsiveContainer width='100%' height={300}>
<BarChart data={spaceActivity}> <BarChart data={fullActivity ? spaceActivity : spaceActivity.slice(-28)}>
<XAxis dataKey='date' minTickGap={10} /> <XAxis dataKey='date' minTickGap={10} />
<YAxis /> <YAxis />
<CartesianGrid strokeDasharray='3 3'/> <CartesianGrid strokeDasharray='3 3'/>
@ -111,14 +220,14 @@ export function Charts(props) {
name='Card Scans' name='Card Scans'
fill='#8884d8' fill='#8884d8'
maxBarSize={20} maxBarSize={20}
animationDuration={1000} isAnimationActive={false}
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
} }
</p> </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> <Header size='medium'>Signup Count</Header>
@ -146,14 +255,14 @@ export function Charts(props) {
type='monotone' type='monotone'
dataKey='vetted_count' dataKey='vetted_count'
fill='#80b3d3' fill='#80b3d3'
name='Vetted Count' name='Later Vetted Count'
maxBarSize={20} maxBarSize={20}
animationDuration={1200} animationDuration={1200}
/> />
<Bar <Bar
type='monotone' type='monotone'
dataKey='retain_count' dataKey='retain_count'
name='Retain Count' name='Retained Count'
fill='#82ca9d' fill='#82ca9d'
maxBarSize={20} maxBarSize={20}
animationDuration={1400} animationDuration={1400}
@ -163,11 +272,11 @@ export function Charts(props) {
} }
</p> </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> </Container>
); );

View File

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

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useReducer } from 'react'; 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 moment from 'moment-timezone';
import './light.css'; import './light.css';
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react'; 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) { export function Home(props) {
const { user } = props; const { user, token } = props;
const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false'))); const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false')));
const [refreshCount, refreshStats] = useReducer(x => x + 1, 0); const [refreshCount, refreshStats] = useReducer(x => x + 1, 0);
const location = useLocation();
const bypass_code = location.hash.replace('#', '');
useEffect(() => { useEffect(() => {
requester('/stats/', 'GET') requester('/stats/', 'GET', token)
.then(res => { .then(res => {
setStats(res); setStats(res);
localStorage.setItem('stats', JSON.stringify(res)); localStorage.setItem('stats', JSON.stringify(res));
@ -142,17 +145,21 @@ export function Home(props) {
console.log(err); console.log(err);
setStats(false); setStats(false);
}); });
}, [refreshCount]); }, [refreshCount, token]);
const getStat = (x) => stats && stats[x] ? stats[x] : '?'; const getStat = (x) => stats && stats[x] ? stats[x] : '?';
const getZeroStat = (x) => stats && stats[x] ? stats[x] : '0'; 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 getDateStat = (x) => stats && stats[x] ? moment.utc(stats[x]).tz('America/Edmonton').format('ll') : '?';
const mcPlayers = stats && stats['minecraft_players'] ? stats['minecraft_players'] : []; 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 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]).tz('America/Edmonton').format('llll') : 'Unknown'; 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]).tz('America/Edmonton').fromNow() : ''; 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 ( return (
<Container> <Container>
@ -172,9 +179,18 @@ export function Home(props) {
</div> </div>
: :
<div> <div>
{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} /> <LoginForm {...props} />
<Divider section horizontal>Or</Divider> <Divider section horizontal>Or</Divider>
</>
}
<SignupForm {...props} /> <SignupForm {...props} />
</div> </div>
@ -201,11 +217,10 @@ export function Home(props) {
<p>Next monthly clean: {getDateStat('next_clean')}</p> <p>Next monthly clean: {getDateStat('next_clean')}</p>
<p>Member count: {getStat('member_count')} <Link to='/charts'>[more]</Link></p> <p>Member count: {getStat('member_count')} <Link to='/charts'>[more]</Link></p>
<p>Green members: {getStat('green_count')}</p> <p>Green members: {getStat('green_count')}</p>
<p>Old members: {getStat('paused_count')}</p>
<p>Card scans today: {getZeroStat('card_scans')}</p> <p>Card scans today: {getZeroStat('card_scans')}</p>
<p> <p>
Minecraft players: {mcPlayers.length} <Popup content={ Minecraft players: {mcPlayers.length} {mcPlayers.length > 5 && '🔥'} <Popup content={
<React.Fragment> <React.Fragment>
<p> <p>
Server IP:<br /> Server IP:<br />
@ -217,6 +232,22 @@ export function Home(props) {
</p> </p>
</React.Fragment> </React.Fragment>
} trigger={<a>[more]</a>} /> } 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>
<p> <p>
@ -225,7 +256,8 @@ export function Home(props) {
<p> <p>
Last use:<br /> Last use:<br />
{getTrackLast('TROTECS300')}<br /> {getTrackLast('TROTECS300')}<br />
{getTrackAgo('TROTECS300')} {getTrackAgo('TROTECS300')}<br />
by {getTrackName('TROTECS300')}
</p> </p>
</React.Fragment> </React.Fragment>
} trigger={<a>[more]</a>} /> } trigger={<a>[more]</a>} />
@ -237,11 +269,14 @@ export function Home(props) {
<p> <p>
Last use:<br /> Last use:<br />
{getTrackLast('FRICKIN-LASER')}<br /> {getTrackLast('FRICKIN-LASER')}<br />
{getTrackAgo('FRICKIN-LASER')} {getTrackAgo('FRICKIN-LASER')}<br />
by {getTrackName('FRICKIN-LASER')}
</p> </p>
</React.Fragment> </React.Fragment>
} trigger={<a>[more]</a>} /> } trigger={<a>[more]</a>} />
</p> </p>
{user && user.member.vetted_date && <p>Alarm status: {alarmStat()}</p>}
</div> </div>
</Segment> </Segment>

View File

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

View File

@ -110,7 +110,7 @@ export function SignupForm(props) {
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Header size='medium'>Sign Up from Protospace</Header> <Header size='medium'>Sign Up to Spaceport</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <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 { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import './light.css'; import './light.css';
import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Input, Item, Menu, Message, Segment, Table } from 'semantic-ui-react'; 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 { NotFound, PleaseLogin } from './Misc.js';
import { AdminMemberInfo, AdminMemberPause, AdminMemberForm, AdminMemberCards, AdminMemberTraining, AdminMemberCertifications } from './AdminMembers.js'; import { AdminMemberInfo, AdminMemberPause, AdminMemberForm, AdminMemberCards, AdminMemberTraining, AdminMemberCertifications } from './AdminMembers.js';
import { AdminMemberTransactions } from './AdminTransactions.js'; import { AdminMemberTransactions } from './AdminTransactions.js';
@ -107,7 +107,7 @@ export function Members(props) {
{x.member.preferred_name} {x.member.last_name} {x.member.preferred_name} {x.member.last_name}
</Item.Header> </Item.Header>
<Item.Description>Status: {x.member.status || 'Unknown'}</Item.Description> <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.Content>
</Item> </Item>
) )
@ -154,7 +154,7 @@ export function MemberDetail(props) {
<Header size='large'>{member.preferred_name} {member.last_name}</Header> <Header size='large'>{member.preferred_name} {member.last_name}</Header>
<Grid stackable columns={2}> <Grid stackable columns={2}>
<Grid.Column> <Grid.Column width={isAdmin(user) ? 8 : 5}>
<p> <p>
<Image rounded size='medium' src={member.photo_large ? staticUrl + '/' + member.photo_large : '/nophoto.png'} /> <Image rounded size='medium' src={member.photo_large ? staticUrl + '/' + member.photo_large : '/nophoto.png'} />
</p> </p>
@ -174,7 +174,7 @@ export function MemberDetail(props) {
</Table.Row> </Table.Row>
<Table.Row> <Table.Row>
<Table.Cell>Joined:</Table.Cell> <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.Row> <Table.Row>
<Table.Cell>Public Bio:</Table.Cell> <Table.Cell>Public Bio:</Table.Cell>
@ -189,7 +189,11 @@ export function MemberDetail(props) {
} }
</Grid.Column> </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> {isAdmin(user) && <Segment padded>
<AdminMemberForm result={result} refreshResult={refreshResult} {...props} /> <AdminMemberForm result={result} refreshResult={refreshResult} {...props} />
</Segment>} </Segment>}

View File

@ -37,7 +37,7 @@ function ResetForm() {
}); });
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit} error={error.email == 'Not found.'}>
<Form.Input <Form.Input
label='Email' label='Email'
name='email' name='email'
@ -45,10 +45,16 @@ function ResetForm() {
error={error.email} 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}> <Form.Button loading={loading} error={error.non_field_errors}>
Submit Submit
</Form.Button> </Form.Button>
{success && <div>Success!</div>} {success && <div>Success! Be sure to check your spam folder.</div>}
</Form> </Form>
); );
}; };

View File

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

View File

@ -23,14 +23,14 @@ export function TransactionEditor(props) {
}); });
const accountOptions = [ 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: '1', text: 'Interac (Email) Transfer (TD)', value: 'Interac' },
{ key: '2', text: 'Square (Credit)', value: 'Square Pmt' }, { key: '2', text: 'Square (Credit Card)', value: 'Square Pmt' },
{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' }, //{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
{ key: '4', text: 'Deposit to TD (Not Interac)', value: 'TD Chequing' }, { key: '4', text: 'Cheque / Deposit to TD', value: 'TD Chequing' },
{ key: '5', text: 'PayPal', value: 'PayPal' }, //{ key: '5', text: 'Member Balance / Protocash', value: 'Member' },
{ key: '6', text: 'Member Balance / Protocash', value: 'Member' }, { key: '6', text: 'Membership Adjustment / Clearing', value: 'Clearing' },
{ key: '7', text: 'Supense (Clearing) Acct / Membership Adjustment', value: 'Clearing' }, { key: '7', text: 'PayPal', value: 'PayPal' },
]; ];
const sourceOptions = [ const sourceOptions = [
@ -53,9 +53,9 @@ export function TransactionEditor(props) {
{ key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' }, { key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' },
{ key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' }, { key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' },
{ key: '3', text: 'Donations', value: 'Donation' }, { 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: '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: '7', text: 'Reimbursement (Enter a negative value)', value: 'Reimburse' },
{ key: '8', text: 'Other (Explain in memo)', value: 'Other' }, { key: '8', text: 'Other (Explain in memo)', value: 'Other' },
]; ];
@ -94,14 +94,14 @@ export function TransactionEditor(props) {
/> />
<Form.Select <Form.Select
label='Account' label='Payment Method / Account'
fluid fluid
options={accountOptions} options={accountOptions}
{...makeProps('account_type')} {...makeProps('account_type')}
onChange={handleValues} onChange={handleValues}
/> />
<Form.Group widths='equal'> {/* <Form.Group widths='equal'>
<Form.Input <Form.Input
label='Payment Method' label='Payment Method'
fluid fluid
@ -114,7 +114,7 @@ export function TransactionEditor(props) {
{...makeProps('info_source')} {...makeProps('info_source')}
onChange={handleValues} onChange={handleValues}
/> />
</Form.Group> </Form.Group> */}
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
@ -124,7 +124,7 @@ export function TransactionEditor(props) {
/> />
<Form.Input <Form.Input
label='# Membership Months' label='Number of Membership Months'
fluid fluid
{...makeProps('number_of_membership_months')} {...makeProps('number_of_membership_months')}
/> />
@ -349,10 +349,10 @@ class TransactionTable extends React.Component {
<Table.Cell>Account:</Table.Cell> <Table.Cell>Account:</Table.Cell>
<Table.Cell>{transaction.account_type}</Table.Cell> <Table.Cell>{transaction.account_type}</Table.Cell>
</Table.Row> </Table.Row>
<Table.Row> {/* <Table.Row>
<Table.Cell>Payment Method:</Table.Cell> <Table.Cell>Payment Method:</Table.Cell>
<Table.Cell>{transaction.payment_method}</Table.Cell> <Table.Cell>{transaction.payment_method}</Table.Cell>
</Table.Row> </Table.Row> */}
<Table.Row> <Table.Row>
<Table.Cell>Info Source:</Table.Cell> <Table.Cell>Info Source:</Table.Cell>
<Table.Cell>{transaction.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) { if (!response.ok) {
throw customError(response); 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 => { .catch(error => {
const code = error.data ? error.data.status : null; 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: dependencies:
ms "2.0.0" 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" version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@ -4376,9 +4376,9 @@ eventemitter3@^2.0.3:
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo= integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
eventemitter3@^4.0.0: eventemitter3@^4.0.0:
version "4.0.0" version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
events@^3.0.0: events@^3.0.0:
version "3.1.0" version "3.1.0"
@ -4767,11 +4767,9 @@ flush-write-stream@^1.0.0:
readable-stream "^2.3.6" readable-stream "^2.3.6"
follow-redirects@^1.0.0: follow-redirects@^1.0.0:
version "1.10.0" version "1.13.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ== integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
dependencies:
debug "^3.0.0"
for-in@^0.1.3: for-in@^0.1.3:
version "0.1.8" version "0.1.8"
@ -5335,9 +5333,9 @@ http-proxy-middleware@0.19.1:
micromatch "^3.1.10" micromatch "^3.1.10"
http-proxy@^1.17.0: http-proxy@^1.17.0:
version "1.18.0" version "1.18.1"
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
dependencies: dependencies:
eventemitter3 "^4.0.0" eventemitter3 "^4.0.0"
follow-redirects "^1.0.0" follow-redirects "^1.0.0"
@ -8842,7 +8840,7 @@ raf@^3.4.0, raf@^3.4.1:
dependencies: dependencies:
performance-now "^2.1.0" 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" version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== 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" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== 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: serve-index@^1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"