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