Merge branch 'master' of github.com:Protospace/spaceport into webgl-footer
This commit is contained in:
commit
8f536b0242
|
@ -22,4 +22,6 @@ That means you have the right to study, change, and distribute the software and
|
|||
|
||||
Thanks to the Protospace Portal Committee.
|
||||
|
||||
Thanks to Emrah for lockout certification code, Pat for LDAP code, and Murray for the blank member form PDF.
|
||||
|
||||
Thanks to all the devs behind Python, Django, DRF, Node, React, Quill, and Bleach.
|
||||
|
|
|
@ -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]
|
||||
))
|
|
@ -1,6 +1,7 @@
|
|||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils.timezone import now
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
|
||||
from apiserver import secrets, settings
|
||||
from apiserver.api import models
|
||||
|
@ -25,6 +26,7 @@ backup_id_string = lambda x: '{}\t{}\t{}'.format(
|
|||
class Command(BaseCommand):
|
||||
help = 'Generate backups.'
|
||||
|
||||
@transaction.atomic
|
||||
def generate_backups(self):
|
||||
backup_users = secrets.BACKUP_TOKENS.values()
|
||||
|
||||
|
|
|
@ -9,13 +9,18 @@ class Command(BaseCommand):
|
|||
|
||||
def generate_stats(self):
|
||||
utils_stats.calc_next_events()
|
||||
member_count, green_count = utils_stats.calc_member_counts()
|
||||
member_count, green_count, six_month_plus_count, vetted_count = utils_stats.calc_member_counts()
|
||||
signup_count = utils_stats.calc_signup_counts()
|
||||
|
||||
# do this hourly in case an admin causes a change
|
||||
models.StatsMemberCount.objects.update_or_create(
|
||||
date=utils.today_alberta_tz(),
|
||||
defaults=dict(member_count=member_count, green_count=green_count),
|
||||
defaults=dict(
|
||||
member_count=member_count,
|
||||
green_count=green_count,
|
||||
six_month_plus_count=six_month_plus_count,
|
||||
vetted_count=vetted_count,
|
||||
),
|
||||
)
|
||||
|
||||
models.StatsSignupCount.objects.update_or_create(
|
||||
|
|
|
@ -14,6 +14,8 @@ class Command(BaseCommand):
|
|||
|
||||
players = utils_stats.check_minecraft_server()
|
||||
self.stdout.write('Found Minecraft players: ' + str(players))
|
||||
users = utils_stats.check_mumble_server()
|
||||
self.stdout.write('Found Mumble users: ' + str(users))
|
||||
|
||||
self.stdout.write('Completed tasks in {} s'.format(
|
||||
str(time.time() - start)[:4]
|
||||
|
|
|
@ -21,7 +21,6 @@ class Member(models.Model):
|
|||
photo_medium = models.CharField(max_length=64, blank=True, null=True)
|
||||
photo_small = models.CharField(max_length=64, blank=True, null=True)
|
||||
member_forms = models.CharField(max_length=64, blank=True, null=True)
|
||||
card_photo = models.CharField(max_length=64, blank=True, null=True)
|
||||
|
||||
set_details = models.BooleanField(default=False)
|
||||
first_name = models.CharField(max_length=32)
|
||||
|
@ -53,6 +52,8 @@ class Member(models.Model):
|
|||
wood_cert_date = models.DateField(blank=True, null=True, default=None)
|
||||
wood2_cert_date = models.DateField(blank=True, null=True, default=None)
|
||||
cnc_cert_date = models.DateField(blank=True, null=True, default=None)
|
||||
rabbit_cert_date = models.DateField(blank=True, null=True, default=None)
|
||||
trotec_cert_date = models.DateField(blank=True, null=True, default=None)
|
||||
paused_date = models.DateField(blank=True, null=True)
|
||||
monthly_fees = models.IntegerField(default=55, blank=True, null=True)
|
||||
|
||||
|
@ -141,6 +142,8 @@ class StatsMemberCount(models.Model):
|
|||
date = models.DateField(default=today_alberta_tz)
|
||||
member_count = models.IntegerField()
|
||||
green_count = models.IntegerField()
|
||||
six_month_plus_count = models.IntegerField()
|
||||
vetted_count = models.IntegerField()
|
||||
|
||||
class StatsSignupCount(models.Model):
|
||||
month = models.DateField()
|
||||
|
|
|
@ -7,11 +7,11 @@ from rest_framework import serializers
|
|||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_auth.registration.serializers import RegisterSerializer
|
||||
from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer
|
||||
from rest_auth.serializers import PasswordChangeSerializer, PasswordResetSerializer, PasswordResetConfirmSerializer, LoginSerializer
|
||||
from rest_auth.serializers import UserDetailsSerializer
|
||||
import re
|
||||
|
||||
from . import models, fields, utils, utils_ldap
|
||||
from . import models, fields, utils, utils_ldap, utils_auth
|
||||
from .. import settings, secrets
|
||||
|
||||
class TransactionSerializer(serializers.ModelSerializer):
|
||||
|
@ -24,7 +24,7 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||
'Square Pmt',
|
||||
'Member',
|
||||
'Clearing',
|
||||
'Cash'
|
||||
'Cash',
|
||||
])
|
||||
info_source = serializers.ChoiceField([
|
||||
'Web',
|
||||
|
@ -38,7 +38,18 @@ class TransactionSerializer(serializers.ModelSerializer):
|
|||
'IPN Trigger',
|
||||
'Intranet Receipt',
|
||||
'Automatic',
|
||||
'Manual'
|
||||
'Manual',
|
||||
])
|
||||
category = serializers.ChoiceField([
|
||||
'Membership',
|
||||
'OnAcct',
|
||||
'Snacks',
|
||||
'Donation',
|
||||
'Consumables',
|
||||
'Purchases',
|
||||
'Garage Sale',
|
||||
'Reimburse',
|
||||
'Other',
|
||||
])
|
||||
member_id = serializers.IntegerField()
|
||||
member_name = serializers.SerializerMethodField()
|
||||
|
@ -95,6 +106,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
|
|||
'last_name',
|
||||
'status',
|
||||
'current_start_date',
|
||||
'application_date',
|
||||
'photo_small',
|
||||
'photo_large',
|
||||
'public_bio',
|
||||
|
@ -103,6 +115,7 @@ class OtherMemberSerializer(serializers.ModelSerializer):
|
|||
def get_status(self, obj):
|
||||
return 'Former Member' if obj.paused_date else obj.status
|
||||
|
||||
|
||||
# member viewing his own details
|
||||
class MemberSerializer(serializers.ModelSerializer):
|
||||
status = serializers.SerializerMethodField()
|
||||
|
@ -144,6 +157,8 @@ class MemberSerializer(serializers.ModelSerializer):
|
|||
'wood_cert_date',
|
||||
'wood2_cert_date',
|
||||
'cnc_cert_date',
|
||||
'rabbit_cert_date',
|
||||
'trotec_cert_date',
|
||||
]
|
||||
|
||||
def get_status(self, obj):
|
||||
|
@ -163,7 +178,6 @@ class MemberSerializer(serializers.ModelSerializer):
|
|||
instance.photo_small = small
|
||||
instance.photo_medium = medium
|
||||
instance.photo_large = large
|
||||
instance.card_photo = utils.gen_card_photo(instance)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
@ -173,6 +187,7 @@ class AdminMemberSerializer(MemberSerializer):
|
|||
street_address = serializers.CharField(required=False)
|
||||
city = serializers.CharField(required=False)
|
||||
postal_code = serializers.CharField(required=False)
|
||||
monthly_fees = serializers.ChoiceField([10, 30, 35, 50, 55])
|
||||
|
||||
class Meta:
|
||||
model = models.Member
|
||||
|
@ -193,6 +208,25 @@ class AdminMemberSerializer(MemberSerializer):
|
|||
'is_staff',
|
||||
]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'rabbit_cert_date' in validated_data:
|
||||
changed = validated_data['rabbit_cert_date'] != instance.rabbit_cert_date
|
||||
if changed:
|
||||
if validated_data['rabbit_cert_date']:
|
||||
utils_ldap.add_to_group(instance, 'Laser Users')
|
||||
else:
|
||||
utils_ldap.remove_from_group(instance, 'Laser Users')
|
||||
|
||||
if 'trotec_cert_date' in validated_data:
|
||||
changed = validated_data['trotec_cert_date'] != instance.trotec_cert_date
|
||||
if changed:
|
||||
if validated_data['trotec_cert_date']:
|
||||
utils_ldap.add_to_group(instance, 'Trotec Users')
|
||||
else:
|
||||
utils_ldap.remove_from_group(instance, 'Trotec Users')
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
# member viewing member list or search result
|
||||
class SearchSerializer(serializers.Serializer):
|
||||
|
@ -204,6 +238,24 @@ class SearchSerializer(serializers.Serializer):
|
|||
serializer = OtherMemberSerializer(obj)
|
||||
return serializer.data
|
||||
|
||||
# instructor viewing search result
|
||||
class InstructorSearchSerializer(serializers.Serializer):
|
||||
member = serializers.SerializerMethodField()
|
||||
training = serializers.SerializerMethodField()
|
||||
|
||||
def get_member(self, obj):
|
||||
serializer = OtherMemberSerializer(obj)
|
||||
return serializer.data
|
||||
|
||||
def get_training(self, obj):
|
||||
if obj.user:
|
||||
queryset = obj.user.training
|
||||
else:
|
||||
queryset = models.Training.objects.filter(member_id=obj.id)
|
||||
serializer = UserTrainingSerializer(data=queryset, many=True)
|
||||
serializer.is_valid()
|
||||
return serializer.data
|
||||
|
||||
# admin viewing search result
|
||||
class AdminSearchSerializer(serializers.Serializer):
|
||||
cards = serializers.SerializerMethodField()
|
||||
|
@ -289,6 +341,7 @@ class TrainingSerializer(serializers.ModelSerializer):
|
|||
session = serializers.PrimaryKeyRelatedField(queryset=models.Session.objects.all())
|
||||
student_name = serializers.SerializerMethodField()
|
||||
student_email = serializers.SerializerMethodField()
|
||||
student_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.Training
|
||||
|
@ -309,6 +362,12 @@ class TrainingSerializer(serializers.ModelSerializer):
|
|||
member = models.Member.objects.get(id=obj.member_id)
|
||||
return member.old_email
|
||||
|
||||
def get_student_id(self, obj):
|
||||
if obj.user:
|
||||
return obj.user.member.id
|
||||
else:
|
||||
return obj.member_id
|
||||
|
||||
|
||||
class StudentTrainingSerializer(TrainingSerializer):
|
||||
attendance_status = serializers.ChoiceField(['Waiting for payment', 'Withdrawn'])
|
||||
|
@ -321,6 +380,8 @@ class SessionSerializer(serializers.ModelSerializer):
|
|||
datetime = serializers.DateTimeField()
|
||||
course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all())
|
||||
students = TrainingSerializer(many=True, read_only=True)
|
||||
max_students = serializers.IntegerField(min_value=1, max_value=50, allow_null=True)
|
||||
cost = serializers.DecimalField(max_digits=None, decimal_places=2, min_value=0, max_value=200)
|
||||
|
||||
class Meta:
|
||||
model = models.Session
|
||||
|
@ -447,16 +508,38 @@ class MyPasswordChangeSerializer(PasswordChangeSerializer):
|
|||
|
||||
if utils_ldap.is_configured():
|
||||
if utils_ldap.set_password(data) != 200:
|
||||
raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.'))
|
||||
msg = 'Problem connecting to LDAP server: set.'
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
data = dict(
|
||||
username=self.user.username,
|
||||
password=self.data['new_password1'],
|
||||
)
|
||||
|
||||
if utils_auth.is_configured():
|
||||
if utils_auth.set_password(data) != 200:
|
||||
msg = 'Problem connecting to Auth server: set.'
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
super().save()
|
||||
|
||||
class MyPasswordResetSerializer(PasswordResetSerializer):
|
||||
def validate_email(self, email):
|
||||
if not User.objects.filter(email__iexact=email).exists():
|
||||
logging.info('Email not found: ' + email)
|
||||
raise ValidationError('Not found.')
|
||||
return super().validate_email(email)
|
||||
|
||||
def save(self):
|
||||
email = self.data['email']
|
||||
member = User.objects.get(email__iexact=email).member
|
||||
logging.info('Password reset requested for: {} - {} {} ({})'.format(email, member.first_name, member.last_name, member.id))
|
||||
super().save()
|
||||
|
||||
class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
|
||||
def save(self):
|
||||
data = dict(
|
||||
|
@ -466,7 +549,25 @@ class MyPasswordResetConfirmSerializer(PasswordResetConfirmSerializer):
|
|||
|
||||
if utils_ldap.is_configured():
|
||||
if utils_ldap.set_password(data) != 200:
|
||||
raise ValidationError(dict(non_field_errors='Problem connecting to LDAP server: set.'))
|
||||
msg = 'Problem connecting to LDAP server: set.'
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
data = dict(
|
||||
username=self.user.username,
|
||||
password=self.data['new_password1'],
|
||||
)
|
||||
|
||||
if utils_auth.is_configured():
|
||||
if utils_auth.set_password(data) != 200:
|
||||
msg = 'Problem connecting to Auth server: set.'
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
member = self.user.member
|
||||
logging.info('Password reset completed for: {} {} ({})'.format(member.first_name, member.last_name, member.id))
|
||||
|
||||
super().save()
|
||||
|
||||
|
@ -504,3 +605,13 @@ class HistorySerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = models.HistoryIndex
|
||||
fields = '__all__'
|
||||
|
||||
class SpaceportAuthSerializer(LoginSerializer):
|
||||
def authenticate(self, **kwargs):
|
||||
result = super().authenticate(**kwargs)
|
||||
|
||||
if result:
|
||||
data = self.context['request'].data
|
||||
utils_auth.set_password(data)
|
||||
|
||||
return result
|
||||
|
|
|
@ -19,11 +19,6 @@ from django.core.cache import cache
|
|||
from django.utils.timezone import now, pytz
|
||||
|
||||
from . import models, serializers, utils_ldap
|
||||
try:
|
||||
from . import old_models
|
||||
except ImportError:
|
||||
logger.info('Running without old portal data...')
|
||||
old_models = None
|
||||
|
||||
STATIC_FOLDER = 'data/static/'
|
||||
|
||||
|
@ -234,28 +229,29 @@ def gen_card_photo(member):
|
|||
# check font size
|
||||
font_sizes = (60, 72)
|
||||
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
|
||||
size = draw.textsize(member.last_name, font=font)
|
||||
size = draw.textsize(str(member.last_name), font=font)
|
||||
if size[0] > CARD_TEXT_SIZE_LIMIT:
|
||||
font_sizes = (36, 48)
|
||||
|
||||
font = ImageFont.truetype('DejaVuSans.ttf', font_sizes[0])
|
||||
x = CARD_PHOTO_MARGIN_SIDE
|
||||
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE
|
||||
draw.text((x, y), member.first_name, (0,0,0), font=font)
|
||||
draw.text((x, y), str(member.first_name), (0,0,0), font=font)
|
||||
|
||||
font = ImageFont.truetype('DejaVuSans-Bold.ttf', font_sizes[1])
|
||||
y = my + CARD_PHOTO_MARGIN_TOP + CARD_PHOTO_MARGIN_SIDE + font_sizes[1]
|
||||
draw.text((x, y), member.last_name, (0,0,0), font=font)
|
||||
draw.text((x, y), str(member.last_name), (0,0,0), font=font)
|
||||
|
||||
font = ImageFont.truetype('DejaVuSans.ttf', 36)
|
||||
draw.text((x, 800), 'Joined: ' + str(member.application_date), (0,0,0), font=font)
|
||||
draw.text((x, 800), 'Joined: ' + str(member.application_date or 'Unknown'), (0,0,0), font=font)
|
||||
y = CARD_PHOTO_MARGIN_SIDE
|
||||
draw.text((475, y), str(member.id), (0,0,0), font=font)
|
||||
|
||||
file_name = str(uuid4()) + '.jpg'
|
||||
card_template.save(STATIC_FOLDER + file_name, quality=95)
|
||||
bio = io.BytesIO()
|
||||
card_template.save(bio, 'JPEG', quality=95)
|
||||
bio.seek(0)
|
||||
|
||||
return file_name
|
||||
return bio
|
||||
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
|
@ -292,15 +288,11 @@ def link_old_member(data, user):
|
|||
Since this runs AFTER registration, we need to delete the user on any
|
||||
failures or else the username will be taken when they try again
|
||||
'''
|
||||
if not old_models:
|
||||
msg = 'Unable to link, old DB wasn\'t imported.'
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(email=msg))
|
||||
|
||||
try:
|
||||
member = models.Member.objects.get(old_email__iexact=data['email'])
|
||||
except models.Member.DoesNotExist:
|
||||
msg = 'Unable to find email in old portal.'
|
||||
msg = 'Unable to find email in old portal. Maybe try your other email addresses?'
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(email=msg))
|
||||
except models.Member.MultipleObjectsReturned:
|
||||
|
@ -318,15 +310,18 @@ def link_old_member(data, user):
|
|||
if result == 200:
|
||||
if utils_ldap.set_password(data) != 200:
|
||||
msg = 'Problem connecting to LDAP server: set.'
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
elif result == 404:
|
||||
if utils_ldap.create_user(data) != 200:
|
||||
msg = 'Problem connecting to LDAP server: create.'
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
else:
|
||||
msg = 'Problem connecting to LDAP server: find.'
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
|
@ -342,9 +337,8 @@ def link_old_member(data, user):
|
|||
models.Training.objects.filter(member_id=member.id).update(user=user)
|
||||
|
||||
def create_new_member(data, user):
|
||||
if old_models:
|
||||
old_members = old_models.Members.objects.using('old_portal')
|
||||
if old_members.filter(email__iexact=data['email']).exists():
|
||||
members = models.Member.objects
|
||||
if members.filter(old_email__iexact=data['email']).exists():
|
||||
msg = 'Account was found in old portal.'
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(email=msg))
|
||||
|
@ -359,11 +353,13 @@ def create_new_member(data, user):
|
|||
pass
|
||||
else:
|
||||
msg = 'Problem connecting to LDAP server.'
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
if utils_ldap.create_user(data) != 200:
|
||||
msg = 'Problem connecting to LDAP server: create.'
|
||||
alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
raise ValidationError(dict(non_field_errors=msg))
|
||||
|
||||
|
@ -393,7 +389,6 @@ def gen_member_forms(member):
|
|||
|
||||
packet = io.BytesIO()
|
||||
can = canvas.Canvas(packet, pagesize=letter)
|
||||
can.drawString(75, 775, '[ ] Paid [ ] Sponsored & Approved [ ] Vetted [ ] Got Card')
|
||||
can.drawString(34, 683, data['first_name'])
|
||||
can.drawString(218, 683, data['last_name'])
|
||||
can.drawString(403, 683, data['preferred_name'])
|
||||
|
@ -407,7 +402,7 @@ def gen_member_forms(member):
|
|||
|
||||
packet = io.BytesIO()
|
||||
can = canvas.Canvas(packet, pagesize=letter)
|
||||
can.drawRightString(600, 775, '{} {} ({})'.format(
|
||||
can.drawRightString(600, 770, '{} {} ({})'.format(
|
||||
data['first_name'],
|
||||
data['last_name'],
|
||||
data['id'],
|
||||
|
|
28
apiserver/apiserver/api/utils_auth.py
Normal file
28
apiserver/apiserver/api/utils_auth.py
Normal 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)
|
|
@ -4,6 +4,7 @@ logger = logging.getLogger(__name__)
|
|||
import requests
|
||||
|
||||
from apiserver import secrets
|
||||
from apiserver.api import utils
|
||||
|
||||
def is_configured():
|
||||
return bool(secrets.LDAP_API_URL and secrets.LDAP_API_KEY)
|
||||
|
@ -13,7 +14,7 @@ def ldap_api(route, data):
|
|||
try:
|
||||
headers = {'Authorization': 'Token ' + secrets.LDAP_API_KEY}
|
||||
url = secrets.LDAP_API_URL + route
|
||||
r = requests.post(url, data=data, headers=headers, timeout=3)
|
||||
r = requests.post(url, data=data, headers=headers, timeout=5)
|
||||
return r.status_code
|
||||
except BaseException as e:
|
||||
logger.error('LDAP {} - {} - {}'.format(url, e.__class__.__name__, str(e)))
|
||||
|
@ -39,3 +40,37 @@ def set_password(data):
|
|||
password=data['password1'],
|
||||
)
|
||||
return ldap_api('set-password', ldap_data)
|
||||
|
||||
def add_to_group(member, group):
|
||||
try:
|
||||
ldap_data = dict(group=group)
|
||||
|
||||
if member.user:
|
||||
ldap_data['username'] = member.user.username
|
||||
else:
|
||||
ldap_data['email'] = member.old_email
|
||||
|
||||
if ldap_api('add-to-group', ldap_data) != 200: raise
|
||||
except BaseException as e:
|
||||
logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e)))
|
||||
m = '{} {} ({})'.format(member.first_name, member.last_name, member.id)
|
||||
msg = 'Problem adding {} to group {}!'.format(m, group)
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
|
||||
def remove_from_group(member, group):
|
||||
try:
|
||||
ldap_data = dict(group=group)
|
||||
|
||||
if member.user:
|
||||
ldap_data['username'] = member.user.username
|
||||
else:
|
||||
ldap_data['email'] = member.old_email
|
||||
|
||||
if ldap_api('remove-from-group', ldap_data) != 200: raise
|
||||
except BaseException as e:
|
||||
logger.error('LDAP Group - {} - {}'.format(e.__class__.__name__, str(e)))
|
||||
m = '{} {} ({})'.format(member.first_name, member.last_name, member.id)
|
||||
msg = 'Problem removing {} from group {}!'.format(m, group)
|
||||
utils.alert_tanner(msg)
|
||||
logger.info(msg)
|
||||
|
|
|
@ -2,7 +2,7 @@ import logging
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from django.utils.timezone import now, pytz
|
||||
|
@ -22,8 +22,10 @@ DEFAULTS = {
|
|||
'bay_108_temp': None,
|
||||
'bay_110_temp': None,
|
||||
'minecraft_players': [],
|
||||
'mumble_users': [],
|
||||
'card_scans': 0,
|
||||
'track': {},
|
||||
'alarm': {},
|
||||
}
|
||||
|
||||
def changed_card():
|
||||
|
@ -62,11 +64,16 @@ def calc_member_counts():
|
|||
paused_count = members.count() - member_count
|
||||
green_count = num_current + num_prepaid
|
||||
|
||||
six_months_ago = today_alberta_tz() - timedelta(days=183)
|
||||
six_month_plus_count = not_paused.filter(application_date__lte=six_months_ago).count()
|
||||
|
||||
vetted_count = not_paused.filter(vetted_date__isnull=False).count()
|
||||
|
||||
cache.set('member_count', member_count)
|
||||
cache.set('paused_count', paused_count)
|
||||
cache.set('green_count', green_count)
|
||||
|
||||
return member_count, green_count
|
||||
return member_count, green_count, six_month_plus_count, vetted_count
|
||||
|
||||
def calc_signup_counts():
|
||||
month_beginning = today_alberta_tz().replace(day=1)
|
||||
|
@ -114,6 +121,21 @@ def check_minecraft_server():
|
|||
|
||||
return []
|
||||
|
||||
def check_mumble_server():
|
||||
if secrets.MUMBLE:
|
||||
url = secrets.MUMBLE
|
||||
|
||||
try:
|
||||
r = requests.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
users = r.text.split()
|
||||
cache.set('mumble_users', users)
|
||||
return users
|
||||
except BaseException as e:
|
||||
logger.error('Problem checking Mumble: {} - {}'.format(e.__class__.__name__, str(e)))
|
||||
|
||||
return []
|
||||
|
||||
def calc_card_scans():
|
||||
date = today_alberta_tz()
|
||||
cards = models.Card.objects
|
||||
|
|
|
@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
|
|||
from django.contrib.auth.models import User, Group
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.db.models import Max
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.http import HttpResponse, Http404, FileResponse
|
||||
from django.core.files.base import File
|
||||
from django.core.cache import cache
|
||||
from django.utils.timezone import now
|
||||
|
@ -12,7 +12,7 @@ from rest_framework import viewsets, views, mixins, generics, exceptions
|
|||
from rest_framework.decorators import action, api_view
|
||||
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView
|
||||
from rest_auth.views import PasswordChangeView, PasswordResetView, PasswordResetConfirmView, LoginView
|
||||
from rest_auth.registration.views import RegisterView
|
||||
from fuzzywuzzy import fuzz, process
|
||||
from collections import OrderedDict
|
||||
|
@ -20,7 +20,7 @@ import datetime, time
|
|||
|
||||
import requests
|
||||
|
||||
from . import models, serializers, utils, utils_paypal, utils_stats
|
||||
from . import models, serializers, utils, utils_paypal, utils_stats, utils_ldap
|
||||
from .permissions import (
|
||||
is_admin_director,
|
||||
AllowMetadata,
|
||||
|
@ -50,6 +50,8 @@ class SearchViewSet(Base, Retrieve):
|
|||
def get_serializer_class(self):
|
||||
if is_admin_director(self.request.user) and self.action == 'retrieve':
|
||||
return serializers.AdminSearchSerializer
|
||||
elif self.request.user.member.is_instructor and self.action == 'retrieve':
|
||||
return serializers.InstructorSearchSerializer
|
||||
else:
|
||||
return serializers.SearchSerializer
|
||||
|
||||
|
@ -82,6 +84,7 @@ class SearchViewSet(Base, Retrieve):
|
|||
result_objects = [queryset.get(id=x) for x in result_ids]
|
||||
|
||||
queryset = result_objects
|
||||
logging.info('Search for: {}, results: {}'.format(search, len(queryset)))
|
||||
elif self.action == 'create':
|
||||
utils.gen_search_strings() # update cache
|
||||
queryset = queryset.order_by('-vetted_date')
|
||||
|
@ -137,12 +140,24 @@ class MemberViewSet(Base, Retrieve, Update):
|
|||
member = self.get_object()
|
||||
member.current_start_date = utils.today_alberta_tz()
|
||||
member.paused_date = None
|
||||
if not member.monthly_fees:
|
||||
member.monthly_fees = 55
|
||||
member.save()
|
||||
utils.tally_membership_months(member)
|
||||
utils.gen_member_forms(member)
|
||||
utils_stats.changed_card()
|
||||
return Response(200)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def card_photo(self, request, pk=None):
|
||||
if not is_admin_director(self.request.user):
|
||||
raise exceptions.PermissionDenied()
|
||||
member = self.get_object()
|
||||
if not member.photo_large:
|
||||
raise Http404
|
||||
card_photo = utils.gen_card_photo(member)
|
||||
return FileResponse(card_photo, filename='card.jpg')
|
||||
|
||||
|
||||
class CardViewSet(Base, Create, Retrieve, Update, Destroy):
|
||||
permission_classes = [AllowMetadata | IsAdmin]
|
||||
|
@ -200,6 +215,38 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
|||
else:
|
||||
return serializers.StudentTrainingSerializer
|
||||
|
||||
def update_cert(self, session, member, status):
|
||||
# always update cert date incase member is returning and gets recertified
|
||||
if session.course.id == 249:
|
||||
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 261:
|
||||
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 401:
|
||||
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 281:
|
||||
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 283:
|
||||
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 259:
|
||||
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 247:
|
||||
member.rabbit_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
|
||||
if utils_ldap.is_configured():
|
||||
if status == 'Attended':
|
||||
utils_ldap.add_to_group(member, 'Laser Users')
|
||||
else:
|
||||
utils_ldap.remove_from_group(member, 'Laser Users')
|
||||
elif session.course.id == 321:
|
||||
member.trotec_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
|
||||
if utils_ldap.is_configured():
|
||||
if status == 'Attended':
|
||||
utils_ldap.add_to_group(member, 'Trotec Users')
|
||||
else:
|
||||
utils_ldap.remove_from_group(member, 'Trotec Users')
|
||||
member.save()
|
||||
|
||||
# TODO: turn these into @actions
|
||||
# TODO: check if full, but not for instructors
|
||||
# TODO: if already paid, skip to confirmed
|
||||
|
@ -222,19 +269,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
|||
if (user and training1.exists()) or training2.exists():
|
||||
raise exceptions.ValidationError(dict(non_field_errors='Already registered.'))
|
||||
|
||||
if session.course.id == 249:
|
||||
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 261:
|
||||
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 401:
|
||||
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 281:
|
||||
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 283:
|
||||
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 259:
|
||||
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
member.save()
|
||||
self.update_cert(session, member, status)
|
||||
|
||||
serializer.save(user=user, member_id=member.id, attendance_status=status)
|
||||
else:
|
||||
|
@ -261,19 +296,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
|||
else:
|
||||
member = models.Member.objects.get(id=training.member_id)
|
||||
|
||||
if session.course.id == 249:
|
||||
member.orientation_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 261:
|
||||
member.wood_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 401:
|
||||
member.wood2_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 281:
|
||||
member.lathe_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 283:
|
||||
member.mill_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
elif session.course.id == 259:
|
||||
member.cnc_cert_date = utils.today_alberta_tz() if status == 'Attended' else None
|
||||
member.save()
|
||||
self.update_cert(session, member, status)
|
||||
|
||||
|
||||
class TransactionViewSet(Base, List, Create, Retrieve, Update):
|
||||
|
@ -440,6 +463,11 @@ class StatsViewSet(viewsets.ViewSet, List):
|
|||
cached_stats = cache.get_many(stats_keys)
|
||||
stats = utils_stats.DEFAULTS.copy()
|
||||
stats.update(cached_stats)
|
||||
|
||||
user = self.request.user
|
||||
if not user.is_authenticated or not user.member.vetted_date:
|
||||
stats.pop('alarm', None)
|
||||
|
||||
return Response(stats)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
|
@ -462,11 +490,27 @@ class StatsViewSet(viewsets.ViewSet, List):
|
|||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(data='This field is required.'))
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def alarm(self, request):
|
||||
try:
|
||||
alarm = dict(time=time.time(), data=int(request.data['data']))
|
||||
cache.set('alarm', alarm)
|
||||
return Response(200)
|
||||
except ValueError:
|
||||
raise exceptions.ValidationError(dict(data='Invalid integer.'))
|
||||
except KeyError:
|
||||
raise exceptions.ValidationError(dict(data='This field is required.'))
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def track(self, request):
|
||||
if 'name' in request.data:
|
||||
track = cache.get('track', {})
|
||||
track[request.data['name']] = time.time()
|
||||
|
||||
name = request.data['name']
|
||||
username = request.data.get('username', '')
|
||||
username = username.split('.')[0].title()
|
||||
|
||||
track[name] = dict(time=time.time(), username=username)
|
||||
cache.set('track', track)
|
||||
return Response(200)
|
||||
else:
|
||||
|
@ -500,14 +544,18 @@ class BackupView(views.APIView):
|
|||
backup_user = secrets.BACKUP_TOKENS.get(auth_token, None)
|
||||
|
||||
if backup_user:
|
||||
logger.info('Backup user: ' + backup_user['name'])
|
||||
backup_path = cache.get(backup_user['cache_key'], None)
|
||||
|
||||
if not backup_path:
|
||||
logger.error('Backup not found')
|
||||
raise Http404
|
||||
|
||||
if str(now().date()) not in backup_path:
|
||||
# sanity check - make sure it's actually today's backup
|
||||
return Response('Today\'s backup not ready yet', status=400)
|
||||
msg = 'Today\'s backup not ready yet'
|
||||
logger.error(msg)
|
||||
return Response(msg, status=503)
|
||||
|
||||
backup_url = 'https://static.{}/backups/{}'.format(
|
||||
settings.PRODUCTION_HOST,
|
||||
|
@ -598,6 +646,9 @@ class PasswordResetView(PasswordResetView):
|
|||
class PasswordResetConfirmView(PasswordResetConfirmView):
|
||||
serializer_class = serializers.MyPasswordResetConfirmSerializer
|
||||
|
||||
class SpaceportAuthView(LoginView):
|
||||
serializer_class = serializers.SpaceportAuthSerializer
|
||||
|
||||
|
||||
@api_view()
|
||||
def null_view(request, *args, **kwargs):
|
||||
|
|
|
@ -8,3 +8,10 @@ class IgnoreStats(logging.Filter):
|
|||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
class IgnoreLockout(logging.Filter):
|
||||
def filter(self, record):
|
||||
if 'GET /lockout/' in record.msg:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
|
|
@ -40,6 +40,16 @@ LDAP_API_URL = ''
|
|||
# spaceport/ldapserver/secrets.py
|
||||
LDAP_API_KEY = ''
|
||||
|
||||
# Auth API url
|
||||
# should contain the IP and port of the script and machine connected over VPN
|
||||
# with trailing slash
|
||||
AUTH_API_URL = ''
|
||||
|
||||
# Auth API key
|
||||
# should be equal to the auth token value set in
|
||||
# spaceport/authserver/secrets.py
|
||||
AUTH_API_KEY = ''
|
||||
|
||||
# Door cards API token
|
||||
# Set this to random characters
|
||||
# For example, use the output of this:
|
||||
|
@ -50,6 +60,7 @@ DOOR_API_TOKEN = ''
|
|||
DOOR_CODE = ''
|
||||
WIFI_PASS = ''
|
||||
MINECRAFT = ''
|
||||
MUMBLE = ''
|
||||
|
||||
# Portal Email Credentials
|
||||
# For sending password resets, etc.
|
||||
|
|
|
@ -116,6 +116,9 @@ DATABASES = {
|
|||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'data/db.sqlite3'),
|
||||
'OPTIONS': {
|
||||
'timeout': 20, # increased because generate_backups.py blocks
|
||||
},
|
||||
},
|
||||
'old_portal': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
|
@ -221,11 +224,14 @@ LOGGING = {
|
|||
'ignore_stats': {
|
||||
'()': 'apiserver.filters.IgnoreStats',
|
||||
},
|
||||
'ignore_lockout': {
|
||||
'()': 'apiserver.filters.IgnoreLockout',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'filters': ['ignore_stats'],
|
||||
'filters': ['ignore_stats', 'ignore_lockout'],
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'medium'
|
||||
},
|
||||
|
|
|
@ -33,6 +33,7 @@ urlpatterns = [
|
|||
path('', include(router.urls)),
|
||||
path(ADMIN_ROUTE, admin.site.urls),
|
||||
url(r'^rest-auth/login/$', LoginView.as_view(), name='rest_login'),
|
||||
url(r'^spaceport-auth/login/$', views.SpaceportAuthView.as_view(), name='spaceport_auth'),
|
||||
url(r'^rest-auth/logout/$', LogoutView.as_view(), name='rest_logout'),
|
||||
url(r'^password/reset/$', views.PasswordResetView.as_view(), name='rest_password_reset'),
|
||||
url(r'^password/reset/confirm/$', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
|
|
40
apiserver/delete_addresses.py
Executable file
40
apiserver/delete_addresses.py
Executable 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.')
|
|
@ -13,7 +13,7 @@ Install dependencies:
|
|||
$ sudo apt install memcached
|
||||
|
||||
# Python:
|
||||
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv
|
||||
$ sudo apt install build-essential python3 python3-dev python3-pip python3-virtualenv
|
||||
|
||||
# Yarn / nodejs:
|
||||
# from https://yarnpkg.com/lang/en/docs/install/#debian-stable
|
||||
|
@ -111,7 +111,7 @@ Point a domain to the server and reverse proxy requests according to subdomain.
|
|||
|
||||
Domains: `portal.example.com`, `api.portal.example.com`, `static.portal.example.com`, `docs.portal.example.com` should all be reverse proxied.
|
||||
|
||||
Configure nginx:
|
||||
Configure nginx (`/etc/nginx/sites-available/default`):
|
||||
|
||||
.. sourcecode:: text
|
||||
|
||||
|
@ -185,7 +185,7 @@ Install certbot and run it:
|
|||
|
||||
.. sourcecode:: bash
|
||||
|
||||
$ sudo apt install certbot python-certbot-nginx
|
||||
$ sudo apt install certbot python3-certbot-nginx
|
||||
$ sudo certbot --nginx
|
||||
|
||||
Answer the prompts, enable redirect.
|
||||
|
|
|
@ -10,7 +10,7 @@ Install dependencies:
|
|||
.. sourcecode:: bash
|
||||
|
||||
$ sudo apt update
|
||||
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor
|
||||
$ sudo apt install build-essential python3 python3-dev python3-pip python-virtualenv python3-virtualenv supervisor libsasl2-dev libldap2-dev libssl-dev
|
||||
|
||||
Clone the repo:
|
||||
|
||||
|
|
0
apiserver/gen_card_photos.py
Normal file → Executable file
0
apiserver/gen_card_photos.py
Normal file → Executable file
59
apiserver/import_rabbit_group.py
Executable file
59
apiserver/import_rabbit_group.py
Executable 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)
|
21
apiserver/import_six_month_plus_count.py
Executable file
21
apiserver/import_six_month_plus_count.py
Executable 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.')
|
59
apiserver/import_trotec_group.py
Executable file
59
apiserver/import_trotec_group.py
Executable 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)
|
21
apiserver/import_vetted_count.py
Executable file
21
apiserver/import_vetted_count.py
Executable 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
0
apiserver/lockout_auth_update.py
Normal file → Executable file
BIN
apiserver/misc/blank_member_form.odg
Normal file
BIN
apiserver/misc/blank_member_form.odg
Normal file
Binary file not shown.
Binary file not shown.
105
authserver/.gitignore
vendored
Normal file
105
authserver/.gitignore
vendored
Normal 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
17
authserver/README.md
Normal 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.
|
39
authserver/auth_functions.py
Normal file
39
authserver/auth_functions.py
Normal 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
22
authserver/log.py
Normal 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.')
|
6
authserver/requirements.txt
Normal file
6
authserver/requirements.txt
Normal 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
|
12
authserver/secrets.py.example
Normal file
12
authserver/secrets.py.example
Normal 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
29
authserver/server.py
Normal 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')
|
10
ldapserver/gunicorn.conf.py
Normal file
10
ldapserver/gunicorn.conf.py
Normal 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']
|
|
@ -1,3 +1,4 @@
|
|||
from log import logger
|
||||
import time
|
||||
import ldap
|
||||
import ldap.modlist as modlist
|
||||
|
@ -7,14 +8,12 @@ import base64
|
|||
from flask import abort
|
||||
|
||||
HTTP_NOTFOUND = 404
|
||||
BASE_MEMBERS = 'OU=MembersOU,DC=ps,DC=protospace,DC=ca' # prod
|
||||
BASE_GROUPS = 'OU=GroupsOU,DC=ps,DC=protospace,DC=ca' # prod
|
||||
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, './ProtospaceAD.cer')
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, secrets.LDAP_CERTFILE)
|
||||
|
||||
def init_ldap():
|
||||
ldap_conn = ldap.initialize('ldaps://ldap.ps.protospace.ca:636')
|
||||
ldap_conn = ldap.initialize(secrets.LDAP_URL)
|
||||
ldap_conn.set_option(ldap.OPT_REFERRALS, 0)
|
||||
ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
ldap_conn.set_option(ldap.OPT_X_TLS,ldap.OPT_X_TLS_DEMAND)
|
||||
|
@ -23,15 +22,34 @@ def init_ldap():
|
|||
|
||||
return ldap_conn
|
||||
|
||||
def find_user(username):
|
||||
def convert(data):
|
||||
if isinstance(data, dict):
|
||||
return {convert(key): convert(value) for key, value in data.items()}
|
||||
elif isinstance(data, (list, tuple)):
|
||||
if len(data) == 1:
|
||||
return convert(data[0])
|
||||
else:
|
||||
return [convert(element) for element in data]
|
||||
elif isinstance(data, (bytes, bytearray)):
|
||||
try:
|
||||
return data.decode()
|
||||
except UnicodeDecodeError:
|
||||
return data.hex()
|
||||
else:
|
||||
return data
|
||||
|
||||
def find_user(query):
|
||||
'''
|
||||
Search for a user by sAMAccountname
|
||||
Search for a user by sAMAccountname or email
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Looking up user ' + query)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username)
|
||||
results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
|
||||
criteria = '(&(objectClass=user)(|(mail={})(sAMAccountName={}))(!(objectClass=computer)))'.format(query, query)
|
||||
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'])
|
||||
|
||||
logger.info(' Results: ' + str(results))
|
||||
|
||||
if len(results) != 1:
|
||||
abort(HTTP_NOTFOUND)
|
||||
|
@ -40,6 +58,23 @@ def find_user(username):
|
|||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def find_dn(dn):
|
||||
'''
|
||||
Search for a user by dn
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Finding user for dn: ' + dn)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
criteria = '(&(objectClass=user)(!(objectClass=computer)))'
|
||||
results = ldap_conn.search_s(dn, ldap.SCOPE_SUBTREE, criteria, ['sAMAccountName'])
|
||||
|
||||
logger.info(' Results: ' + str(results))
|
||||
|
||||
return results[0][1]['sAMAccountName'][0].decode()
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def create_user(first, last, username, email, password):
|
||||
'''
|
||||
Create a User; required data is first, last, email, username, password
|
||||
|
@ -47,8 +82,9 @@ def create_user(first, last, username, email, password):
|
|||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Creating user: ' + username)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
dn = 'CN={} {},{}'.format(first, last, BASE_MEMBERS)
|
||||
dn = 'CN={} {},{}'.format(first, last, secrets.BASE_MEMBERS)
|
||||
full_name = '{} {}'.format(first, last)
|
||||
|
||||
ldif = [
|
||||
|
@ -64,7 +100,9 @@ def create_user(first, last, username, email, password):
|
|||
('company', [b'Spaceport']),
|
||||
]
|
||||
|
||||
ldap_conn.add_s(dn, ldif)
|
||||
result = ldap_conn.add_s(dn, ldif)
|
||||
|
||||
logger.info(' Result: ' + str(result))
|
||||
|
||||
# set password
|
||||
pass_quotes = '"{}"'.format(password)
|
||||
|
@ -72,47 +110,58 @@ def create_user(first, last, username, email, password):
|
|||
change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
|
||||
result = ldap_conn.modify_s(dn, change_des)
|
||||
|
||||
logger.info(' Result: ' + str(result))
|
||||
|
||||
# 512 will set user account to enabled
|
||||
mod_acct = [(ldap.MOD_REPLACE, 'userAccountControl', b'512')]
|
||||
result = ldap_conn.modify_s(dn, mod_acct)
|
||||
|
||||
logger.info(' Result: ' + str(result))
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def set_password(username, password):
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Setting password for: ' + username)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
criteria = '(&(objectClass=user)(sAMAccountName={})(!(objectClass=computer)))'.format(username)
|
||||
results = ldap_conn.search_s(BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
|
||||
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, ['displayName','sAMAccountName','email'] )
|
||||
|
||||
if len(results) != 1:
|
||||
abort(HTTP_NOTFOUND)
|
||||
|
||||
dn = results[0][0]
|
||||
|
||||
logger.info(' Dn found: ' + dn)
|
||||
|
||||
# set password
|
||||
pass_quotes = '"{}"'.format(password)
|
||||
pass_uni = pass_quotes.encode('utf-16-le')
|
||||
change_des = [(ldap.MOD_REPLACE, 'unicodePwd', [pass_uni])]
|
||||
result = ldap_conn.modify_s(dn, change_des)
|
||||
|
||||
logger.info(' Set password result: ' + str(result))
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def find_group(groupname):
|
||||
'''
|
||||
Search for a group by name or sAMAccountname. Retrun the DN
|
||||
Search for a group by name or sAMAccountname
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Looking up group ' + groupname)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
|
||||
results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] )
|
||||
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['name','groupType'] )
|
||||
|
||||
logger.info(' Results: ' + str(results))
|
||||
|
||||
if len(results) != 1:
|
||||
abort(HTTP_NOTFOUND)
|
||||
|
||||
return results[0][0]
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
|
@ -123,7 +172,7 @@ def create_group(groupname,description):
|
|||
ldap_conn = init_ldap()
|
||||
try:
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
dn = 'CN={},{}'.format(groupname, BASE_GROUPS)
|
||||
dn = 'CN={},{}'.format(groupname, secrets.BASE_GROUPS)
|
||||
|
||||
ldif = [
|
||||
('objectClass', [b'top', b'group']),
|
||||
|
@ -134,28 +183,7 @@ def create_group(groupname,description):
|
|||
]
|
||||
|
||||
rcode = ldap_conn.add_s(dn, ldif)
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def list_group(groupname):
|
||||
'''
|
||||
List users in a Group; required data is GroupName
|
||||
'''
|
||||
members = []
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
group_dn = find_group(groupname)
|
||||
|
||||
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
|
||||
results = ldap_conn.search_s(BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] )
|
||||
members_tmp = results[0][1]['member']
|
||||
for m in members_tmp:
|
||||
members.append(m)
|
||||
# print("m = {}".format(m)) #Debug
|
||||
|
||||
return(members)
|
||||
return rcode
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
@ -164,46 +192,139 @@ def add_to_group(groupname,username):
|
|||
'''
|
||||
Add a user to a Group; required data is GroupName, Username
|
||||
'''
|
||||
print("== Enter add_to_group ==")
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
print(' --- Enter add_to_group with {0}, {1}---'.format(groupname,username))
|
||||
logger.info('Adding ' + username + ' to group: ' + groupname)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
# get DN of the groupname
|
||||
user_dn = find_user(username)
|
||||
group_dn = find_group(groupname)
|
||||
|
||||
#get DN of the username
|
||||
user_dn = find_user(username)
|
||||
|
||||
# -- TODO: Check to see if user is already a member, skip if not needed
|
||||
|
||||
if not is_member(groupname, username):
|
||||
mod_acct = [(ldap.MOD_ADD, 'member', user_dn.encode())]
|
||||
result = ldap_conn.modify_s(group_dn, mod_acct)
|
||||
ldap_conn.modify_s(group_dn, mod_acct)
|
||||
logger.info(' Added.')
|
||||
return True
|
||||
else:
|
||||
logger.info(' Already a member, skipping.')
|
||||
return False
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def remove_from_group(groupname, username):
|
||||
'''
|
||||
Remove a user from a Group; required data is GroupName, Username
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
user_dn = find_user(username)
|
||||
group_dn = find_group(groupname)
|
||||
|
||||
if is_member(groupname, username):
|
||||
mod_acct = [(ldap.MOD_DELETE, 'member', user_dn.encode())]
|
||||
ldap_conn.modify_s(group_dn, mod_acct)
|
||||
return True
|
||||
else:
|
||||
logger.info('Not a member, skipping')
|
||||
return False
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def list_group(groupname):
|
||||
'''
|
||||
List users in a Group; required data is GroupName
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
group_dn = find_group(groupname)
|
||||
|
||||
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
|
||||
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'])
|
||||
members_tmp = results[0][1]
|
||||
members = members_tmp.get('member', [])
|
||||
return [find_dn(dn.decode()) for dn in members]
|
||||
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def is_member(groupname, username):
|
||||
'''
|
||||
Checks to see if a user is a member of a group
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
logger.info('Checking if ' + username + ' is in group: ' + groupname)
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
group_dn = find_group(groupname)
|
||||
user_dn = find_user(username).encode()
|
||||
memflag = False
|
||||
criteria = '(&(objectClass=group)(sAMAccountName={}))'.format(groupname)
|
||||
results = ldap_conn.search_s(secrets.BASE_GROUPS, ldap.SCOPE_SUBTREE, criteria, ['member'] )
|
||||
members_tmp = results[0][1]
|
||||
members = members_tmp.get('member', [])
|
||||
result = user_dn in members
|
||||
|
||||
logger.info(' Result: ' + str(result))
|
||||
return result
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
def dump_users():
|
||||
'''
|
||||
Dump all AD users
|
||||
'''
|
||||
ldap_conn = init_ldap()
|
||||
try:
|
||||
ldap_conn.simple_bind_s(secrets.LDAP_USERNAME, secrets.LDAP_PASSWORD)
|
||||
criteria = '(&(objectClass=user)(objectGUID=*))'
|
||||
attributes = ['cn', 'sAMAccountName', 'mail', 'displayName', 'givenName', 'name', 'sn', 'logonCount', 'objectGUID']
|
||||
results = ldap_conn.search_s(secrets.BASE_MEMBERS, ldap.SCOPE_SUBTREE, criteria, attributes)
|
||||
results = convert(results)
|
||||
|
||||
output = {}
|
||||
for r in results:
|
||||
tmp = r[1]
|
||||
tmp['dn'] = r[0]
|
||||
output[r[1]['sAMAccountName']] = tmp
|
||||
|
||||
import json
|
||||
return json.dumps(output, indent=4)
|
||||
finally:
|
||||
ldap_conn.unbind()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
#guid = '\\b4\\51\\1adce6709c449bd21a812c423e82'
|
||||
#guid = ''.join(['\\%s' % guid[i:i+2] for i in range(0, len(guid), 2)])
|
||||
#print(guid)
|
||||
#criteria = '(&(objectClass=user)(objectGUID={}))'.format(guid)
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
#print(create_user('Elon', 'Tusk', 'elon.tusk', 'elont@example.com', 'protospace*&^g87g6'))
|
||||
#print(find_user('tanner.collin'))
|
||||
#print(set_password('dsaftanner.collin', 'Supersecret@@'))
|
||||
#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
|
||||
create_group("testgroup")
|
||||
print(find_group("testgroup")
|
||||
|
||||
# List Group members
|
||||
print("-- Members of {}".format("Laser Trainers"))
|
||||
group_members = list_group("Laser Trainers")
|
||||
for member in group_members:
|
||||
print('{}'.format(member))
|
||||
|
||||
# add users to test group
|
||||
add_to_group("testgroup","pat.spencer")
|
||||
add_to_group("testgroup","Tanner.Collin")
|
||||
# List Group members
|
||||
print("-- Members of {}".format("testgroup"))
|
||||
group_members = list_group("testgroup")
|
||||
for member in group_members:
|
||||
print('{}'.format(member))
|
||||
#users = list_group('Laser Users')
|
||||
#import json
|
||||
#print(json.dumps(users, indent=4))
|
||||
|
|
59
ldapserver/log.py
Normal file
59
ldapserver/log.py
Normal 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()
|
|
@ -3,6 +3,7 @@ Flask==1.1.1
|
|||
gunicorn==20.0.4
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.1
|
||||
logging-tree==1.8.1
|
||||
MarkupSafe==1.1.1
|
||||
pyasn1==0.4.8
|
||||
pyasn1-modules==0.2.8
|
||||
|
|
|
@ -8,3 +8,9 @@ AUTH_TOKEN = ''
|
|||
|
||||
LDAP_USERNAME = ''
|
||||
LDAP_PASSWORD = ''
|
||||
|
||||
LDAP_CERTFILE = ''
|
||||
LDAP_URL = ''
|
||||
|
||||
BASE_MEMBERS = ''
|
||||
BASE_GROUPS = ''
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from log import logger
|
||||
|
||||
from flask import Flask, abort, request
|
||||
app = Flask(__name__)
|
||||
|
||||
|
@ -13,8 +15,14 @@ def check_auth():
|
|||
|
||||
@app.route('/')
|
||||
def index():
|
||||
logger.info('Index page requested')
|
||||
|
||||
return '<i>SEE YOU SPACE SAMURAI...</i>'
|
||||
|
||||
@app.route('/ping')
|
||||
def ping():
|
||||
return 'pong'
|
||||
|
||||
@app.route('/find-user', methods=['POST'])
|
||||
def find_user():
|
||||
check_auth()
|
||||
|
@ -46,5 +54,25 @@ def set_password():
|
|||
ldap_functions.set_password(username, password)
|
||||
return ''
|
||||
|
||||
@app.route('/add-to-group', methods=['POST'])
|
||||
def add_to_group():
|
||||
check_auth()
|
||||
|
||||
groupname = request.form['group']
|
||||
username = request.form.get('username', None) or request.form.get('email', None)
|
||||
|
||||
ldap_functions.add_to_group(groupname, username)
|
||||
return ''
|
||||
|
||||
@app.route('/remove-from-group', methods=['POST'])
|
||||
def remove_from_group():
|
||||
check_auth()
|
||||
|
||||
groupname = request.form['group']
|
||||
username = request.form.get('username', None) or request.form.get('email', None)
|
||||
|
||||
ldap_functions.remove_from_group(groupname, username)
|
||||
return ''
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0')
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
"react-to-print": "~2.5.1",
|
||||
"recharts": "~1.8.5",
|
||||
"semantic-ui-react": "~0.88.2",
|
||||
"three": "^0.119.1"
|
||||
"three": "^0.119.1",
|
||||
"serialize-javascript": "^3.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
|
BIN
webclient/public/wikilogo.png
Normal file
BIN
webclient/public/wikilogo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -123,7 +123,7 @@ export function AdminMemberCards(props) {
|
|||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [viewCard, setViewCard] = useState(false);
|
||||
const [cardPhoto, setCardPhoto] = useState(false);
|
||||
const { id } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -155,6 +155,18 @@ export function AdminMemberCards(props) {
|
|||
});
|
||||
};
|
||||
|
||||
const getCardPhoto = (e) => {
|
||||
e.preventDefault();
|
||||
requester('/members/' + id + '/card_photo/', 'GET', token)
|
||||
.then(res => res.blob())
|
||||
.then(res => {
|
||||
setCardPhoto(URL.createObjectURL(res));
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const makeProps = (name) => ({
|
||||
name: name,
|
||||
onChange: handleChange,
|
||||
|
@ -176,17 +188,17 @@ export function AdminMemberCards(props) {
|
|||
<Form onSubmit={handleSubmit}>
|
||||
<Header size='small'>Add a Card</Header>
|
||||
|
||||
{result.member.card_photo ?
|
||||
{result.member.photo_large ?
|
||||
<p>
|
||||
<Button onClick={() => setViewCard(true)}>View card image</Button>
|
||||
<Button onClick={(e) => getCardPhoto(e)}>View card image</Button>
|
||||
</p>
|
||||
:
|
||||
<p>No card image, member photo missing!</p>
|
||||
}
|
||||
|
||||
{viewCard && <>
|
||||
{cardPhoto && <>
|
||||
<p>
|
||||
<Image rounded size='medium' src={staticUrl + '/' + result.member.card_photo} />
|
||||
<Image rounded size='medium' src={cardPhoto} />
|
||||
</p>
|
||||
|
||||
<Header size='small'>How to Print a Card</Header>
|
||||
|
@ -221,7 +233,15 @@ export function AdminMemberCards(props) {
|
|||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Button loading={loading} error={error.non_field_errors}>
|
||||
<Form.Checkbox
|
||||
label='Confirmed that the member has been given a tour and knows the alarm code'
|
||||
required
|
||||
{...makeProps('given_tour')}
|
||||
onChange={handleCheck}
|
||||
checked={input.given_tour}
|
||||
/>
|
||||
|
||||
<Form.Button disabled={!input.given_tour} loading={loading} error={error.non_field_errors}>
|
||||
Submit
|
||||
</Form.Button>
|
||||
{success && <div>Success!</div>}
|
||||
|
@ -498,6 +518,12 @@ export function AdminMemberInfo(props) {
|
|||
<Table.Cell>Emergency Contact Phone:</Table.Cell>
|
||||
<Table.Cell>{member.emergency_contact_phone || 'None'}</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<Table.Row>
|
||||
<Table.Cell>On Spaceport:</Table.Cell>
|
||||
<Table.Cell>{member.user ? 'Yes' : 'No'}</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<Table.Row>
|
||||
<Table.Cell>Public Bio:</Table.Cell>
|
||||
</Table.Row>
|
||||
|
@ -541,6 +567,7 @@ export function AdminCert(props) {
|
|||
|
||||
const handleCert = (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
let data = Object();
|
||||
data[field] = moment.utc().tz('America/Edmonton').format('YYYY-MM-DD');
|
||||
|
@ -555,6 +582,7 @@ export function AdminCert(props) {
|
|||
|
||||
const handleUncert = (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
let data = Object();
|
||||
data[field] = null;
|
||||
|
@ -646,6 +674,18 @@ export function AdminMemberCertifications(props) {
|
|||
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
|
||||
<Table.Cell><AdminCert name='CNC' field='cnc_cert_date' {...props} /></Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Rabbit Laser</Table.Cell>
|
||||
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
|
||||
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
|
||||
<Table.Cell><AdminCert name='Rabbit' field='rabbit_cert_date' {...props} /></Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Trotec Laser</Table.Cell>
|
||||
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
|
||||
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
|
||||
<Table.Cell><AdminCert name='Trotec' field='trotec_cert_date' {...props} /></Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useReducer, useContext } from 'react';
|
|||
import { BrowserRouter as Router, Switch, Route, Link, useParams, useHistory } from 'react-router-dom';
|
||||
import './semantic-ui/semantic.min.css';
|
||||
import './light.css';
|
||||
import './dark.css';
|
||||
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
||||
import Darkmode from 'darkmode-js';
|
||||
import { isAdmin, requester } from './utils.js';
|
||||
|
@ -19,6 +20,7 @@ import { Courses, CourseDetail } from './Courses.js';
|
|||
import { Classes, ClassDetail } from './Classes.js';
|
||||
import { Members, MemberDetail } from './Members.js';
|
||||
import { Charts } from './Charts.js';
|
||||
import { Auth } from './Auth.js';
|
||||
import { PasswordReset, ConfirmReset } from './PasswordReset.js';
|
||||
import { NotFound, PleaseLogin } from './Misc.js';
|
||||
import { Footer } from './Footer.js';
|
||||
|
@ -107,6 +109,10 @@ function App() {
|
|||
<img src='/logo-long.svg' className='logo-long' />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{window.location.hostname !== 'my.protospace.ca' &&
|
||||
<p style={{ background: 'yellow' }}>~~~~~ Development site ~~~~~</p>
|
||||
}
|
||||
</Container>
|
||||
|
||||
<Menu>
|
||||
|
@ -216,6 +222,10 @@ function App() {
|
|||
<Charts />
|
||||
</Route>
|
||||
|
||||
<Route path='/auth'>
|
||||
<Auth user={user} />
|
||||
</Route>
|
||||
|
||||
{user && user.member.set_details ?
|
||||
<Switch>
|
||||
<Route path='/account'>
|
||||
|
|
132
webclient/src/Auth.js
Normal file
132
webclient/src/Auth.js
Normal 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>
|
||||
);
|
||||
}
|
|
@ -13,6 +13,7 @@ export function Charts(props) {
|
|||
const [memberCount, setMemberCount] = useState(memberCountCache);
|
||||
const [signupCount, setSignupCount] = useState(signupCountCache);
|
||||
const [spaceActivity, setSpaceActivity] = useState(spaceActivityCache);
|
||||
const [fullActivity, setFullActivity] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
requester('/charts/membercount/', 'GET')
|
||||
|
@ -47,6 +48,33 @@ export function Charts(props) {
|
|||
<Container>
|
||||
<Header size='large'>Charts</Header>
|
||||
|
||||
<Header size='medium'>Summary</Header>
|
||||
|
||||
{memberCount && signupCount &&
|
||||
<>
|
||||
<p>
|
||||
The total member count is {memberCount.slice().reverse()[0].member_count} members,
|
||||
compared to {memberCount.slice().reverse()[30].member_count} members 30 days ago.
|
||||
</p>
|
||||
<p>
|
||||
The green member count is {memberCount.slice().reverse()[0].green_count} members,
|
||||
compared to {memberCount.slice().reverse()[30].green_count} members 30 days ago.
|
||||
</p>
|
||||
<p>
|
||||
The older than six months member count is {memberCount.slice().reverse()[0].six_month_plus_count} members,
|
||||
compared to {memberCount.slice().reverse()[30].six_month_plus_count} members 30 days ago.
|
||||
</p>
|
||||
<p>
|
||||
The vetted member count is {memberCount.slice().reverse()[0].vetted_count} members,
|
||||
compared to {memberCount.slice().reverse()[30].vetted_count} members 30 days ago.
|
||||
</p>
|
||||
<p>
|
||||
There were {signupCount.slice().reverse()[0].signup_count} signups so far this month,
|
||||
and {signupCount.slice().reverse()[1].signup_count} signups last month.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
|
||||
<Header size='medium'>Member Counts</Header>
|
||||
|
||||
<p>Daily since March 2nd, 2020.</p>
|
||||
|
@ -87,18 +115,99 @@ export function Charts(props) {
|
|||
}
|
||||
</p>
|
||||
|
||||
<p>The Member Count is the amount of Prepaid, Current, Due, and Overdue members on Spaceport.</p>
|
||||
<p>Member Count: number of active paying members on Spaceport.</p>
|
||||
|
||||
<p>The Green Count is the amount of Prepaid and Current members.</p>
|
||||
<p>Green Count: number of Prepaid and Current members.</p>
|
||||
|
||||
<p>
|
||||
{memberCount &&
|
||||
<ResponsiveContainer width='100%' height={300}>
|
||||
<LineChart data={memberCount}>
|
||||
<XAxis dataKey='date' minTickGap={10} />
|
||||
<YAxis />
|
||||
<CartesianGrid strokeDasharray='3 3'/>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='member_count'
|
||||
name='Member Count'
|
||||
stroke='#8884d8'
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='six_month_plus_count'
|
||||
name='Six Months+'
|
||||
stroke='red'
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={1500}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
}
|
||||
</p>
|
||||
|
||||
<p>Member Count: same as above.</p>
|
||||
|
||||
<p>Six Months+: number of active memberships older than six months.</p>
|
||||
|
||||
<p>
|
||||
{memberCount &&
|
||||
<ResponsiveContainer width='100%' height={300}>
|
||||
<LineChart data={memberCount}>
|
||||
<XAxis dataKey='date' minTickGap={10} />
|
||||
<YAxis />
|
||||
<CartesianGrid strokeDasharray='3 3'/>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='member_count'
|
||||
name='Member Count'
|
||||
stroke='#8884d8'
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
<Line
|
||||
type='monotone'
|
||||
dataKey='vetted_count'
|
||||
name='Vetted Count'
|
||||
stroke='purple'
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={1500}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
}
|
||||
</p>
|
||||
|
||||
<p>Member Count: same as above.</p>
|
||||
|
||||
<p>Vetted Count: number of active vetted members.</p>
|
||||
|
||||
<Header size='medium'>Space Activity</Header>
|
||||
|
||||
{fullActivity ?
|
||||
<p>Daily since March 7th, 2020, updates hourly.</p>
|
||||
:
|
||||
<p>
|
||||
Last four weeks, updates hourly.
|
||||
{' '}<Button size='tiny' onClick={() => setFullActivity(true)} >View All</Button>
|
||||
</p>
|
||||
}
|
||||
|
||||
<p>
|
||||
{spaceActivity &&
|
||||
<ResponsiveContainer width='100%' height={300}>
|
||||
<BarChart data={spaceActivity}>
|
||||
<BarChart data={fullActivity ? spaceActivity : spaceActivity.slice(-28)}>
|
||||
<XAxis dataKey='date' minTickGap={10} />
|
||||
<YAxis />
|
||||
<CartesianGrid strokeDasharray='3 3'/>
|
||||
|
@ -111,14 +220,14 @@ export function Charts(props) {
|
|||
name='Card Scans'
|
||||
fill='#8884d8'
|
||||
maxBarSize={20}
|
||||
animationDuration={1000}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
}
|
||||
</p>
|
||||
|
||||
<p>Cards Scans is the number of individual members who have scanned to enter the space.</p>
|
||||
<p>Cards Scans: number of individual members who have scanned to enter the space.</p>
|
||||
|
||||
<Header size='medium'>Signup Count</Header>
|
||||
|
||||
|
@ -146,14 +255,14 @@ export function Charts(props) {
|
|||
type='monotone'
|
||||
dataKey='vetted_count'
|
||||
fill='#80b3d3'
|
||||
name='Vetted Count'
|
||||
name='Later Vetted Count'
|
||||
maxBarSize={20}
|
||||
animationDuration={1200}
|
||||
/>
|
||||
<Bar
|
||||
type='monotone'
|
||||
dataKey='retain_count'
|
||||
name='Retain Count'
|
||||
name='Retained Count'
|
||||
fill='#82ca9d'
|
||||
maxBarSize={20}
|
||||
animationDuration={1400}
|
||||
|
@ -163,11 +272,11 @@ export function Charts(props) {
|
|||
}
|
||||
</p>
|
||||
|
||||
<p>The Signup Count is the number of brand new account registrations that month.</p>
|
||||
<p>Signup Count: number of brand new account registrations that month.</p>
|
||||
|
||||
<p>The Vetted Count is the number of those signups who eventually got vetted (at a later date).</p>
|
||||
<p>Later Vetted Count: number of those signups who eventually got vetted (at a later date).</p>
|
||||
|
||||
<p>The Retain Count is the number of those signups who are still a member currently.</p>
|
||||
<p>Retained Count: number of those signups who are still a member currently.</p>
|
||||
|
||||
</Container>
|
||||
);
|
||||
|
|
|
@ -72,13 +72,19 @@ export function Classes(props) {
|
|||
<Header size='large'>Class List</Header>
|
||||
|
||||
<Header size='medium'>Upcoming</Header>
|
||||
|
||||
<p>Ordered by nearest date.</p>
|
||||
|
||||
{classes ?
|
||||
<ClassTable classes={classes.filter(x => x.datetime > now)} />
|
||||
<ClassTable classes={classes.filter(x => x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
|
||||
:
|
||||
<p>Loading...</p>
|
||||
}
|
||||
|
||||
<Header size='medium'>Recent</Header>
|
||||
|
||||
<p>Ordered by nearest date.</p>
|
||||
|
||||
{classes ?
|
||||
<ClassTable classes={classes.filter(x => x.datetime < now)} />
|
||||
:
|
||||
|
@ -92,6 +98,7 @@ export function ClassDetail(props) {
|
|||
const [clazz, setClass] = useState(false);
|
||||
const [refreshCount, refreshClass] = useReducer(x => x + 1, 0);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token, user, refreshUser } = props;
|
||||
const { id } = useParams();
|
||||
const userTraining = clazz && clazz.students.find(x => x.user == user.id);
|
||||
|
@ -108,6 +115,8 @@ export function ClassDetail(props) {
|
|||
}, [refreshCount]);
|
||||
|
||||
const handleSignup = () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const data = { attendance_status: 'Waiting for payment', session: id };
|
||||
requester('/training/', 'POST', token, data)
|
||||
.then(res => {
|
||||
|
@ -120,6 +129,8 @@ export function ClassDetail(props) {
|
|||
};
|
||||
|
||||
const handleToggle = (newStatus) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const data = { attendance_status: newStatus, session: id };
|
||||
requester('/training/'+userTraining.id+'/', 'PUT', token, data)
|
||||
.then(res => {
|
||||
|
@ -132,6 +143,10 @@ export function ClassDetail(props) {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [userTraining]);
|
||||
|
||||
// TODO: calculate yesterday and lock signups
|
||||
|
||||
return (
|
||||
|
@ -198,11 +213,11 @@ export function ClassDetail(props) {
|
|||
<p>Status: {userTraining.attendance_status}</p>
|
||||
<p>
|
||||
{userTraining.attendance_status === 'Withdrawn' ?
|
||||
<Button onClick={() => handleToggle('Waiting for payment')}>
|
||||
<Button loading={loading} onClick={() => handleToggle('Waiting for payment')}>
|
||||
Sign back up
|
||||
</Button>
|
||||
:
|
||||
<Button onClick={() => handleToggle('Withdrawn')}>
|
||||
<Button loading={loading} onClick={() => handleToggle('Withdrawn')}>
|
||||
Withdraw from Class
|
||||
</Button>
|
||||
}
|
||||
|
@ -226,7 +241,7 @@ export function ClassDetail(props) {
|
|||
((clazz.max_students && clazz.student_count >= clazz.max_students) ?
|
||||
<p>The class is full.</p>
|
||||
:
|
||||
<Button onClick={handleSignup}>
|
||||
<Button loading={loading} onClick={handleSignup}>
|
||||
Sign me up!
|
||||
</Button>
|
||||
)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useReducer } from 'react';
|
||||
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Switch, Route, Link, useParams, useLocation } from 'react-router-dom';
|
||||
import moment from 'moment-timezone';
|
||||
import './light.css';
|
||||
import { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Popup, Segment, Table } from 'semantic-ui-react';
|
||||
|
@ -128,12 +128,15 @@ function MemberInfo(props) {
|
|||
};
|
||||
|
||||
export function Home(props) {
|
||||
const { user } = props;
|
||||
const { user, token } = props;
|
||||
const [stats, setStats] = useState(JSON.parse(localStorage.getItem('stats', 'false')));
|
||||
const [refreshCount, refreshStats] = useReducer(x => x + 1, 0);
|
||||
const location = useLocation();
|
||||
|
||||
const bypass_code = location.hash.replace('#', '');
|
||||
|
||||
useEffect(() => {
|
||||
requester('/stats/', 'GET')
|
||||
requester('/stats/', 'GET', token)
|
||||
.then(res => {
|
||||
setStats(res);
|
||||
localStorage.setItem('stats', JSON.stringify(res));
|
||||
|
@ -142,17 +145,21 @@ export function Home(props) {
|
|||
console.log(err);
|
||||
setStats(false);
|
||||
});
|
||||
}, [refreshCount]);
|
||||
}, [refreshCount, token]);
|
||||
|
||||
const getStat = (x) => stats && stats[x] ? stats[x] : '?';
|
||||
const getZeroStat = (x) => stats && stats[x] ? stats[x] : '0';
|
||||
const getDateStat = (x) => stats && stats[x] ? moment.utc(stats[x]).tz('America/Edmonton').format('ll') : '?';
|
||||
|
||||
const mcPlayers = stats && stats['minecraft_players'] ? stats['minecraft_players'] : [];
|
||||
const mumbleUsers = stats && stats['mumble_users'] ? stats['mumble_users'] : [];
|
||||
|
||||
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x] > 60 ? 'Free' : 'In Use' : '?';
|
||||
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').format('llll') : 'Unknown';
|
||||
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]).tz('America/Edmonton').fromNow() : '';
|
||||
const getTrackStat = (x) => stats && stats.track && stats.track[x] ? moment().unix() - stats.track[x]['time'] > 60 ? 'Free' : 'In Use' : '?';
|
||||
const getTrackLast = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').format('llll') : 'Unknown';
|
||||
const getTrackAgo = (x) => stats && stats.track && stats.track[x] ? moment.unix(stats.track[x]['time']).tz('America/Edmonton').fromNow() : '';
|
||||
const getTrackName = (x) => stats && stats.track && stats.track[x] && stats.track[x]['username'] ? stats.track[x]['username'] : 'Unknown';
|
||||
|
||||
const alarmStat = () => stats && stats.alarm && moment().unix() - stats.alarm['time'] < 300 ? stats.alarm['data'] > 200 ? 'Armed' : 'Disarmed' : 'Unknown';
|
||||
|
||||
return (
|
||||
<Container>
|
||||
|
@ -172,9 +179,18 @@ export function Home(props) {
|
|||
</div>
|
||||
:
|
||||
<div>
|
||||
{bypass_code ?
|
||||
<Message warning>
|
||||
<Message.Header>Outside Registration</Message.Header>
|
||||
<p>This page allows you to sign up from outside of Protospace.</p>
|
||||
</Message>
|
||||
:
|
||||
<>
|
||||
<LoginForm {...props} />
|
||||
|
||||
<Divider section horizontal>Or</Divider>
|
||||
</>
|
||||
}
|
||||
|
||||
<SignupForm {...props} />
|
||||
</div>
|
||||
|
@ -201,11 +217,10 @@ export function Home(props) {
|
|||
<p>Next monthly clean: {getDateStat('next_clean')}</p>
|
||||
<p>Member count: {getStat('member_count')} <Link to='/charts'>[more]</Link></p>
|
||||
<p>Green members: {getStat('green_count')}</p>
|
||||
<p>Old members: {getStat('paused_count')}</p>
|
||||
<p>Card scans today: {getZeroStat('card_scans')}</p>
|
||||
|
||||
<p>
|
||||
Minecraft players: {mcPlayers.length} <Popup content={
|
||||
Minecraft players: {mcPlayers.length} {mcPlayers.length > 5 && '🔥'} <Popup content={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Server IP:<br />
|
||||
|
@ -217,6 +232,22 @@ export function Home(props) {
|
|||
</p>
|
||||
</React.Fragment>
|
||||
} trigger={<a>[more]</a>} />
|
||||
{' '}<a href='http://games.protospace.ca:8123/?worldname=world&mapname=flat&zoom=3&x=74&y=64&z=354' target='_blank'>[map]</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mumble users: {mumbleUsers.length} <Popup content={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Server IP:<br />
|
||||
mumble.protospace.ca
|
||||
</p>
|
||||
<p>
|
||||
Users:<br />
|
||||
{mumbleUsers.length ? mumbleUsers.map(x => <React.Fragment>{x}<br /></React.Fragment>) : 'None'}
|
||||
</p>
|
||||
</React.Fragment>
|
||||
} trigger={<a>[more]</a>} />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
@ -225,7 +256,8 @@ export function Home(props) {
|
|||
<p>
|
||||
Last use:<br />
|
||||
{getTrackLast('TROTECS300')}<br />
|
||||
{getTrackAgo('TROTECS300')}
|
||||
{getTrackAgo('TROTECS300')}<br />
|
||||
by {getTrackName('TROTECS300')}
|
||||
</p>
|
||||
</React.Fragment>
|
||||
} trigger={<a>[more]</a>} />
|
||||
|
@ -237,11 +269,14 @@ export function Home(props) {
|
|||
<p>
|
||||
Last use:<br />
|
||||
{getTrackLast('FRICKIN-LASER')}<br />
|
||||
{getTrackAgo('FRICKIN-LASER')}
|
||||
{getTrackAgo('FRICKIN-LASER')}<br />
|
||||
by {getTrackName('FRICKIN-LASER')}
|
||||
</p>
|
||||
</React.Fragment>
|
||||
} trigger={<a>[more]</a>} />
|
||||
</p>
|
||||
|
||||
{user && user.member.vetted_date && <p>Alarm status: {alarmStat()}</p>}
|
||||
</div>
|
||||
|
||||
</Segment>
|
||||
|
|
|
@ -68,8 +68,12 @@ class AttendanceSheet extends React.Component {
|
|||
function AttendanceRow(props) {
|
||||
const { student, token, refreshClass } = props;
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleMark = (newStatus) => {
|
||||
if (loading) return;
|
||||
if (student.attendance_status == newStatus) return;
|
||||
setLoading(newStatus);
|
||||
const data = { ...student, attendance_status: newStatus };
|
||||
requester('/training/'+student.id+'/', 'PATCH', token, data)
|
||||
.then(res => {
|
||||
|
@ -86,11 +90,19 @@ function AttendanceRow(props) {
|
|||
onClick: () => handleMark(name),
|
||||
toggle: true,
|
||||
active: student.attendance_status === name,
|
||||
loading: loading === name,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [student.attendance_status]);
|
||||
|
||||
return (
|
||||
<div className='attendance-row'>
|
||||
<p>{student.student_name}:</p>
|
||||
<p>
|
||||
<Link to={'/members/'+student.student_id}>{student.student_name}</Link>
|
||||
{student.attendance_status === 'Waiting for payment' && ' (Waiting for payment)'}:
|
||||
</p>
|
||||
|
||||
<Button {...makeProps('Withdrawn')}>
|
||||
Withdrawn
|
||||
|
@ -118,9 +130,11 @@ function AttendanceRow(props) {
|
|||
);
|
||||
}
|
||||
|
||||
let attendanceOpenCache = false;
|
||||
|
||||
export function InstructorClassAttendance(props) {
|
||||
const { clazz, token, refreshClass, user } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(attendanceOpenCache);
|
||||
const [input, setInput] = useState({});
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
@ -201,7 +215,7 @@ export function InstructorClassAttendance(props) {
|
|||
</Form>
|
||||
</div>
|
||||
:
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Button onClick={() => {setOpen(true); attendanceOpenCache = true;}}>
|
||||
Edit Attendance
|
||||
</Button>
|
||||
}
|
||||
|
@ -321,7 +335,7 @@ export function InstructorClassDetail(props) {
|
|||
export function InstructorClassList(props) {
|
||||
const { course, setCourse, token } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState({});
|
||||
const [input, setInput] = useState({ max_students: null });
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
|
|
@ -110,7 +110,7 @@ export function SignupForm(props) {
|
|||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Header size='medium'>Sign Up from Protospace</Header>
|
||||
<Header size='medium'>Sign Up to Spaceport</Header>
|
||||
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useReducer } from 'react';
|
|||
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
|
||||
import './light.css';
|
||||
import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Input, Item, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
||||
import { statusColor, isAdmin, BasicTable, staticUrl, requester } from './utils.js';
|
||||
import { statusColor, isAdmin, isInstructor, BasicTable, staticUrl, requester } from './utils.js';
|
||||
import { NotFound, PleaseLogin } from './Misc.js';
|
||||
import { AdminMemberInfo, AdminMemberPause, AdminMemberForm, AdminMemberCards, AdminMemberTraining, AdminMemberCertifications } from './AdminMembers.js';
|
||||
import { AdminMemberTransactions } from './AdminTransactions.js';
|
||||
|
@ -107,7 +107,7 @@ export function Members(props) {
|
|||
{x.member.preferred_name} {x.member.last_name}
|
||||
</Item.Header>
|
||||
<Item.Description>Status: {x.member.status || 'Unknown'}</Item.Description>
|
||||
<Item.Description>Joined: {x.member.current_start_date || 'Unknown'}</Item.Description>
|
||||
<Item.Description>Joined: {x.member.application_date || 'Unknown'}</Item.Description>
|
||||
</Item.Content>
|
||||
</Item>
|
||||
)
|
||||
|
@ -154,7 +154,7 @@ export function MemberDetail(props) {
|
|||
<Header size='large'>{member.preferred_name} {member.last_name}</Header>
|
||||
|
||||
<Grid stackable columns={2}>
|
||||
<Grid.Column>
|
||||
<Grid.Column width={isAdmin(user) ? 8 : 5}>
|
||||
<p>
|
||||
<Image rounded size='medium' src={member.photo_large ? staticUrl + '/' + member.photo_large : '/nophoto.png'} />
|
||||
</p>
|
||||
|
@ -174,7 +174,7 @@ export function MemberDetail(props) {
|
|||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Joined:</Table.Cell>
|
||||
<Table.Cell>{member.current_start_date || 'Unknown'}</Table.Cell>
|
||||
<Table.Cell>{member.application_date || 'Unknown'}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Public Bio:</Table.Cell>
|
||||
|
@ -189,7 +189,11 @@ export function MemberDetail(props) {
|
|||
}
|
||||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<Grid.Column width={isAdmin(user) ? 8 : 11}>
|
||||
{isInstructor(user) && !isAdmin(user) && <Segment padded>
|
||||
<AdminMemberTraining result={result} refreshResult={refreshResult} {...props} />
|
||||
</Segment>}
|
||||
|
||||
{isAdmin(user) && <Segment padded>
|
||||
<AdminMemberForm result={result} refreshResult={refreshResult} {...props} />
|
||||
</Segment>}
|
||||
|
|
|
@ -37,7 +37,7 @@ function ResetForm() {
|
|||
});
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form onSubmit={handleSubmit} error={error.email == 'Not found.'}>
|
||||
<Form.Input
|
||||
label='Email'
|
||||
name='email'
|
||||
|
@ -45,10 +45,16 @@ function ResetForm() {
|
|||
error={error.email}
|
||||
/>
|
||||
|
||||
<Message
|
||||
error
|
||||
header='Email not found in Spaceport'
|
||||
content='You can only use this form if you have an account with this new member portal.'
|
||||
/>
|
||||
|
||||
<Form.Button loading={loading} error={error.non_field_errors}>
|
||||
Submit
|
||||
</Form.Button>
|
||||
{success && <div>Success!</div>}
|
||||
{success && <div>Success! Be sure to check your spam folder.</div>}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,6 +13,8 @@ export function Paymaster(props) {
|
|||
const [locker, setLocker] = useState('5.00');
|
||||
const [donate, setDonate] = useState('20.00');
|
||||
|
||||
const monthly_fees = user.member.monthly_fees || 55;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header size='large'>Paymaster</Header>
|
||||
|
@ -62,27 +64,27 @@ export function Paymaster(props) {
|
|||
<Header size='medium'>Member Dues</Header>
|
||||
<Grid stackable padded columns={3}>
|
||||
<Grid.Column>
|
||||
<p>Pay ${user.member.monthly_fees}.00 once:</p>
|
||||
<p>Pay ${monthly_fees}.00 once:</p>
|
||||
<PayPalPayNow
|
||||
amount={user.member.monthly_fees}
|
||||
amount={monthly_fees}
|
||||
name='Protospace Membership'
|
||||
custom={JSON.stringify({ member: user.member.id })}
|
||||
/>
|
||||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<p>Subscribe ${user.member.monthly_fees}.00 / month:</p>
|
||||
<p>Subscribe ${monthly_fees}.00 / month:</p>
|
||||
<PayPalSubscribe
|
||||
amount={user.member.monthly_fees}
|
||||
amount={monthly_fees}
|
||||
name='Protospace Membership'
|
||||
custom={JSON.stringify({ member: user.member.id })}
|
||||
/>
|
||||
</Grid.Column>
|
||||
|
||||
<Grid.Column>
|
||||
<p>Pay ${user.member.monthly_fees * 11}.00 for a year:</p>
|
||||
<p>Pay ${monthly_fees * 11}.00 for a year:</p>
|
||||
<PayPalPayNow
|
||||
amount={user.member.monthly_fees * 11}
|
||||
amount={monthly_fees * 11}
|
||||
name='Protospace Membership'
|
||||
custom={JSON.stringify({ deal: 12, member: user.member.id })}
|
||||
/>
|
||||
|
|
|
@ -57,6 +57,16 @@ export function CertList(props) {
|
|||
<Table.Cell>{member.cnc_cert_date ? 'Yes, ' + member.cnc_cert_date : 'No'}</Table.Cell>
|
||||
<Table.Cell><Link to='/courses/259'>Tormach: CAM and Tormach Intro</Link></Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Rabbit Laser</Table.Cell>
|
||||
<Table.Cell>{member.rabbit_cert_date ? 'Yes, ' + member.rabbit_cert_date : 'No'}</Table.Cell>
|
||||
<Table.Cell><Link to='/courses/247'>Laser: Cutting and Engraving</Link></Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.Cell>Trotec Laser</Table.Cell>
|
||||
<Table.Cell>{member.trotec_cert_date ? 'Yes, ' + member.trotec_cert_date : 'No'}</Table.Cell>
|
||||
<Table.Cell><Link to='/courses/321'>Laser: Trotec Course</Link></Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
);
|
||||
|
|
|
@ -23,14 +23,14 @@ export function TransactionEditor(props) {
|
|||
});
|
||||
|
||||
const accountOptions = [
|
||||
{ key: '0', text: 'Cash (CAD Lock Box)', value: 'Cash' },
|
||||
{ key: '0', text: 'Cash (Lock Box)', value: 'Cash' },
|
||||
{ key: '1', text: 'Interac (Email) Transfer (TD)', value: 'Interac' },
|
||||
{ key: '2', text: 'Square (Credit)', value: 'Square Pmt' },
|
||||
{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
|
||||
{ key: '4', text: 'Deposit to TD (Not Interac)', value: 'TD Chequing' },
|
||||
{ key: '5', text: 'PayPal', value: 'PayPal' },
|
||||
{ key: '6', text: 'Member Balance / Protocash', value: 'Member' },
|
||||
{ key: '7', text: 'Supense (Clearing) Acct / Membership Adjustment', value: 'Clearing' },
|
||||
{ key: '2', text: 'Square (Credit Card)', value: 'Square Pmt' },
|
||||
//{ key: '3', text: 'Dream Payments (Debit/Credit)', value: 'Dream Pmt' },
|
||||
{ key: '4', text: 'Cheque / Deposit to TD', value: 'TD Chequing' },
|
||||
//{ key: '5', text: 'Member Balance / Protocash', value: 'Member' },
|
||||
{ key: '6', text: 'Membership Adjustment / Clearing', value: 'Clearing' },
|
||||
{ key: '7', text: 'PayPal', value: 'PayPal' },
|
||||
];
|
||||
|
||||
const sourceOptions = [
|
||||
|
@ -53,9 +53,9 @@ export function TransactionEditor(props) {
|
|||
{ key: '1', text: 'Payment On Account (ie. Course Fee)', value: 'OnAcct' },
|
||||
{ key: '2', text: 'Snack / Pop / Coffee', value: 'Snacks' },
|
||||
{ key: '3', text: 'Donations', value: 'Donation' },
|
||||
{ key: '4', text: 'Consumables (Specify which in memo)', value: 'Consumables' },
|
||||
{ key: '4', text: 'Consumables (Explain in memo)', value: 'Consumables' },
|
||||
{ key: '5', text: 'Purchase of Locker / Goods / Merch / Stock', value: 'Purchases' },
|
||||
{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
|
||||
//{ key: '6', text: 'Auction, Garage Sale, Nearly Free Shelf', value: 'Garage Sale' },
|
||||
{ key: '7', text: 'Reimbursement (Enter a negative value)', value: 'Reimburse' },
|
||||
{ key: '8', text: 'Other (Explain in memo)', value: 'Other' },
|
||||
];
|
||||
|
@ -94,14 +94,14 @@ export function TransactionEditor(props) {
|
|||
/>
|
||||
|
||||
<Form.Select
|
||||
label='Account'
|
||||
label='Payment Method / Account'
|
||||
fluid
|
||||
options={accountOptions}
|
||||
{...makeProps('account_type')}
|
||||
onChange={handleValues}
|
||||
/>
|
||||
|
||||
<Form.Group widths='equal'>
|
||||
{/* <Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label='Payment Method'
|
||||
fluid
|
||||
|
@ -114,7 +114,7 @@ export function TransactionEditor(props) {
|
|||
{...makeProps('info_source')}
|
||||
onChange={handleValues}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form.Group> */}
|
||||
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
|
@ -124,7 +124,7 @@ export function TransactionEditor(props) {
|
|||
/>
|
||||
|
||||
<Form.Input
|
||||
label='# Membership Months'
|
||||
label='Number of Membership Months'
|
||||
fluid
|
||||
{...makeProps('number_of_membership_months')}
|
||||
/>
|
||||
|
@ -349,10 +349,10 @@ class TransactionTable extends React.Component {
|
|||
<Table.Cell>Account:</Table.Cell>
|
||||
<Table.Cell>{transaction.account_type}</Table.Cell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
{/* <Table.Row>
|
||||
<Table.Cell>Payment Method:</Table.Cell>
|
||||
<Table.Cell>{transaction.payment_method}</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Row> */}
|
||||
<Table.Row>
|
||||
<Table.Cell>Info Source:</Table.Cell>
|
||||
<Table.Cell>{transaction.info_source}</Table.Cell>
|
||||
|
|
57
webclient/src/dark.css
Normal file
57
webclient/src/dark.css
Normal 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;
|
||||
}
|
|
@ -70,7 +70,17 @@ export const requester = (route, method, token, data) => {
|
|||
if (!response.ok) {
|
||||
throw customError(response);
|
||||
}
|
||||
return method === 'DELETE' ? {} : response.json();
|
||||
|
||||
if (method === 'DELETE') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.indexOf('application/json') !== -1) {
|
||||
return response.json();
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
const code = error.data ? error.data.status : null;
|
||||
|
|
|
@ -3664,7 +3664,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9:
|
|||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@^3.0.0, debug@^3.1.1, debug@^3.2.5:
|
||||
debug@^3.1.1, debug@^3.2.5:
|
||||
version "3.2.6"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
|
||||
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
|
||||
|
@ -4376,9 +4376,9 @@ eventemitter3@^2.0.3:
|
|||
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
|
||||
|
||||
eventemitter3@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
|
||||
integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
||||
events@^3.0.0:
|
||||
version "3.1.0"
|
||||
|
@ -4767,11 +4767,9 @@ flush-write-stream@^1.0.0:
|
|||
readable-stream "^2.3.6"
|
||||
|
||||
follow-redirects@^1.0.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb"
|
||||
integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==
|
||||
dependencies:
|
||||
debug "^3.0.0"
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
|
||||
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
|
||||
|
||||
for-in@^0.1.3:
|
||||
version "0.1.8"
|
||||
|
@ -5335,9 +5333,9 @@ http-proxy-middleware@0.19.1:
|
|||
micromatch "^3.1.10"
|
||||
|
||||
http-proxy@^1.17.0:
|
||||
version "1.18.0"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
|
||||
integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
|
||||
version "1.18.1"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
|
||||
integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
|
||||
dependencies:
|
||||
eventemitter3 "^4.0.0"
|
||||
follow-redirects "^1.0.0"
|
||||
|
@ -8842,7 +8840,7 @@ raf@^3.4.0, raf@^3.4.1:
|
|||
dependencies:
|
||||
performance-now "^2.1.0"
|
||||
|
||||
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
|
||||
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
|
||||
|
@ -9754,6 +9752,13 @@ serialize-javascript@^2.1.2:
|
|||
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
|
||||
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
|
||||
|
||||
serialize-javascript@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-3.1.0.tgz#8bf3a9170712664ef2561b44b691eafe399214ea"
|
||||
integrity sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==
|
||||
dependencies:
|
||||
randombytes "^2.1.0"
|
||||
|
||||
serve-index@^1.9.1:
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"
|
||||
|
|
Loading…
Reference in New Issue
Block a user