Merge branch 'master' into usage_tracking
This commit is contained in:
commit
43507024b7
40
apiserver/apiserver/api/emails/welcome.html
Normal file
40
apiserver/apiserver/api/emails/welcome.html
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
<style type="text/css">p.MsoNormal,p.MsoNoSpacing{margin:0}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>Hi [name],<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>You just signed up to Spaceport with the username: [username]<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>To manage your Protospace membership go to:<br></div>
|
||||||
|
<div><a href="https://my.protospace.ca">https://my.protospace.ca</a><br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>You have automatically been added to our forum Spacebar at:<br></div>
|
||||||
|
<div><a href="https://forum.protospace.ca">https://forum.protospace.ca</a><br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>Please introduce yourself here:<br></div>
|
||||||
|
<div><a href="https://forum.protospace.ca/c/chattymcchatface/new-user-introductions/31">https://forum.protospace.ca/c/chattymcchatface/new-user-introductions/31</a><br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>If you have any questions, you will get the fastest response there.<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>Your next goal is to become vetted after:<br></div>
|
||||||
|
<div>- paying your member dues<br></div>
|
||||||
|
<div>- being a member for four weeks<br></div>
|
||||||
|
<div>- attending a New Member Orientation<br></div>
|
||||||
|
<div>- finding two members to sponsor (vouch for) you<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>You can meet members Tuesday evenings during our open house.<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>Mark [date] on your calendar as the day you can get vetted.<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>Sign up for a New Member Orientation here:<br></div>
|
||||||
|
<div><a href="https://my.protospace.ca/classes">https://my.protospace.ca/classes</a><br></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>Good luck,<br></div>
|
||||||
|
<div>Spaceport<br></div>
|
||||||
|
<div><br></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
30
apiserver/apiserver/api/emails/welcome.txt
Normal file
30
apiserver/apiserver/api/emails/welcome.txt
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
Hi [name],
|
||||||
|
|
||||||
|
You just signed up to Spaceport with the username: [username]
|
||||||
|
|
||||||
|
To manage your Protospace membership go to:
|
||||||
|
https://my.protospace.ca
|
||||||
|
|
||||||
|
You have automatically been added to our forum Spacebar at:
|
||||||
|
https://forum.protospace.ca
|
||||||
|
|
||||||
|
Please introduce yourself here:
|
||||||
|
https://forum.protospace.ca/c/chattymcchatface/new-user-introductions/31
|
||||||
|
|
||||||
|
If you have any questions, you will get the fastest response there.
|
||||||
|
|
||||||
|
Your next goal is to become vetted after:
|
||||||
|
- paying your member dues
|
||||||
|
- being a member for four weeks
|
||||||
|
- attending a New Member Orientation
|
||||||
|
- finding two members to sponsor (vouch for) you
|
||||||
|
|
||||||
|
You can meet members Tuesday evenings during our open house.
|
||||||
|
|
||||||
|
Mark [date] on your calendar as the day you can get vetted.
|
||||||
|
|
||||||
|
Sign up for a New Member Orientation here:
|
||||||
|
https://my.protospace.ca/classes
|
||||||
|
|
||||||
|
Good luck,
|
||||||
|
Spaceport
|
|
@ -117,6 +117,7 @@ class Course(models.Model):
|
||||||
name = models.TextField(blank=True, null=True)
|
name = models.TextField(blank=True, null=True)
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
is_old = models.BooleanField(default=False)
|
is_old = models.BooleanField(default=False)
|
||||||
|
tags = models.CharField(max_length=128, blank=True)
|
||||||
|
|
||||||
history = HistoricalRecords()
|
history = HistoricalRecords()
|
||||||
|
|
||||||
|
|
|
@ -449,12 +449,13 @@ class StudentTrainingSerializer(TrainingSerializer):
|
||||||
class CourseSerializer(serializers.ModelSerializer):
|
class CourseSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Course
|
model = models.Course
|
||||||
fields = ['id', 'name', 'is_old', 'description']
|
fields = ['id', 'name', 'is_old', 'description', 'tags']
|
||||||
|
|
||||||
class SessionSerializer(serializers.ModelSerializer):
|
class SessionSerializer(serializers.ModelSerializer):
|
||||||
student_count = serializers.SerializerMethodField()
|
student_count = serializers.SerializerMethodField()
|
||||||
course_data = serializers.SerializerMethodField()
|
course_data = serializers.SerializerMethodField()
|
||||||
instructor_name = serializers.SerializerMethodField()
|
instructor_name = serializers.SerializerMethodField()
|
||||||
|
instructor_id = serializers.SerializerMethodField()
|
||||||
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)
|
||||||
|
@ -479,6 +480,12 @@ class SessionSerializer(serializers.ModelSerializer):
|
||||||
name = 'Unknown'
|
name = 'Unknown'
|
||||||
return obj.old_instructor or name
|
return obj.old_instructor or name
|
||||||
|
|
||||||
|
def get_instructor_id(self, obj):
|
||||||
|
if obj.instructor and hasattr(obj.instructor, 'member'):
|
||||||
|
return obj.instructor.member.id
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
class SessionListSerializer(SessionSerializer):
|
class SessionListSerializer(SessionSerializer):
|
||||||
students = None
|
students = None
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,8 @@ class LoggingThrottle(throttling.BaseThrottle):
|
||||||
pass
|
pass
|
||||||
elif path.startswith('/stats/'):
|
elif path.startswith('/stats/'):
|
||||||
return True
|
return True
|
||||||
|
elif path == '/sessions/' and user == None:
|
||||||
|
return True
|
||||||
|
|
||||||
if request.data:
|
if request.data:
|
||||||
data = request.data.dict()
|
data = request.data.dict()
|
||||||
|
|
|
@ -21,7 +21,7 @@ from django.db.models import Sum
|
||||||
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
|
||||||
|
|
||||||
from . import models, serializers, utils_ldap, utils_stats, utils_auth, utils
|
from . import models, serializers, utils_ldap, utils_stats, utils_auth, utils, utils_email
|
||||||
from .. import settings
|
from .. import settings
|
||||||
|
|
||||||
STATIC_FOLDER = 'data/static/'
|
STATIC_FOLDER = 'data/static/'
|
||||||
|
@ -374,6 +374,14 @@ def register_user(data, user):
|
||||||
utils.alert_tanner(msg)
|
utils.alert_tanner(msg)
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
|
|
||||||
|
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Sending welcome email...')
|
||||||
|
try:
|
||||||
|
utils_email.send_welcome_email(user.member)
|
||||||
|
except BaseException as e: # TODO: remove, just for testing
|
||||||
|
msg = 'Problem sending welcome email: ' + str(e)
|
||||||
|
logger.exception(msg)
|
||||||
|
alert_tanner(msg)
|
||||||
|
|
||||||
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Done!')
|
if data['request_id']: utils_stats.set_progress(data['request_id'], 'Done!')
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
46
apiserver/apiserver/api/utils_email.py
Normal file
46
apiserver/apiserver/api/utils_email.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
from .. import settings
|
||||||
|
|
||||||
|
EMAIL_DIR = os.path.join(settings.BASE_DIR, 'apiserver/api/emails/')
|
||||||
|
|
||||||
|
def send_welcome_email(member):
|
||||||
|
vetting_date = member.application_date + timedelta(days=28)
|
||||||
|
|
||||||
|
def replace_fields(text):
|
||||||
|
return text.replace(
|
||||||
|
'[name]', member.first_name,
|
||||||
|
).replace(
|
||||||
|
'[username]', member.user.username,
|
||||||
|
).replace(
|
||||||
|
'[date]', vetting_date.strftime('%A, %B %d'),
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(EMAIL_DIR + 'welcome.txt', 'r') as f:
|
||||||
|
email_text = replace_fields(f.read())
|
||||||
|
|
||||||
|
with open(EMAIL_DIR + 'welcome.html', 'r') as f:
|
||||||
|
email_html = replace_fields(f.read())
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject='Welcome to Protospace!',
|
||||||
|
message=email_text,
|
||||||
|
from_email=None, # defaults to DEFAULT_FROM_EMAIL
|
||||||
|
recipient_list=[member.user.email, 'portal@tannercollin.com'],
|
||||||
|
html_message=email_html,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Sent welcome email:\n' + email_text)
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
msg = 'Problem sending welcome email to ' + member.user.email + ': ' + str(e)
|
||||||
|
utils.alert_tanner(msg)
|
||||||
|
logger.exception(msg)
|
|
@ -353,7 +353,7 @@ def process_paypal_ipn(data):
|
||||||
defaults=dict(user=user),
|
defaults=dict(user=user),
|
||||||
)
|
)
|
||||||
|
|
||||||
if custom_json.get('category', False) in ['Snacks', 'OnAcct', 'Donation']:
|
if custom_json.get('category', False) in ['Snacks', 'OnAcct', 'Donation', 'Consumables']:
|
||||||
logger.info('IPN - Category matched')
|
logger.info('IPN - Category matched')
|
||||||
update_ipn(ipn, 'Accepted, category')
|
update_ipn(ipn, 'Accepted, category')
|
||||||
return create_category_tx(data, member, custom_json)
|
return create_category_tx(data, member, custom_json)
|
||||||
|
|
|
@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
|
||||||
from django.contrib.auth.models import User, Group
|
from django.contrib.auth.models import User, Group
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Max, F, Count
|
from django.db.models import Max, F, Count, Q
|
||||||
from django.db.utils import OperationalError
|
from django.db.utils import OperationalError
|
||||||
from django.http import HttpResponse, Http404, FileResponse
|
from django.http import HttpResponse, Http404, FileResponse
|
||||||
from django.core.files.base import File
|
from django.core.files.base import File
|
||||||
|
@ -230,7 +230,7 @@ class CardViewSet(Base, Create, Retrieve, Update, Destroy):
|
||||||
|
|
||||||
|
|
||||||
class CourseViewSet(Base, List, Retrieve, Create, Update):
|
class CourseViewSet(Base, List, Retrieve, Create, Update):
|
||||||
permission_classes = [AllowMetadata | IsAuthenticated, IsAdminOrReadOnly | IsInstructorOrReadOnly]
|
permission_classes = [AllowMetadata | IsAuthenticatedOrReadOnly, IsAdminOrReadOnly | IsInstructorOrReadOnly]
|
||||||
queryset = models.Course.objects.annotate(date=Max('sessions__datetime')).order_by('-date')
|
queryset = models.Course.objects.annotate(date=Max('sessions__datetime')).order_by('-date')
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
|
@ -241,11 +241,27 @@ class CourseViewSet(Base, List, Retrieve, Create, Update):
|
||||||
|
|
||||||
|
|
||||||
class SessionViewSet(Base, List, Retrieve, Create, Update):
|
class SessionViewSet(Base, List, Retrieve, Create, Update):
|
||||||
permission_classes = [AllowMetadata | IsAuthenticated, IsAdminOrReadOnly | IsInstructorOrReadOnly]
|
permission_classes = [AllowMetadata | IsAuthenticatedOrReadOnly, IsAdminOrReadOnly | IsInstructorOrReadOnly]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.action == 'list':
|
if self.action == 'list':
|
||||||
return models.Session.objects.order_by('-datetime')[:50]
|
week_ago = now() - datetime.timedelta(days=7)
|
||||||
|
year_ago = now() - datetime.timedelta(days=365)
|
||||||
|
|
||||||
|
return models.Session.objects.annotate(
|
||||||
|
course_count=Count(
|
||||||
|
'course__sessions',
|
||||||
|
filter=Q(
|
||||||
|
course__sessions__datetime__gte=year_ago,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).filter(
|
||||||
|
datetime__gte=week_ago,
|
||||||
|
).order_by(
|
||||||
|
'-course_count',
|
||||||
|
'-course_id',
|
||||||
|
'datetime',
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return models.Session.objects.all()
|
return models.Session.objects.all()
|
||||||
|
|
||||||
|
@ -323,7 +339,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
||||||
|
|
||||||
if data.get('member_id', None):
|
if data.get('member_id', None):
|
||||||
if not (is_admin_director(user) or session.instructor == user):
|
if not (is_admin_director(user) or session.instructor == user):
|
||||||
raise exceptions.ValidationError('Not allowed to register others')
|
raise exceptions.ValidationError(dict(non_field_errors='Not allowed to register others'))
|
||||||
|
|
||||||
member = get_object_or_404(models.Member, id=data['member_id'])
|
member = get_object_or_404(models.Member, id=data['member_id'])
|
||||||
user = member.user
|
user = member.user
|
||||||
|
@ -338,9 +354,9 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
|
||||||
else:
|
else:
|
||||||
training = models.Training.objects.filter(user=user, session=session)
|
training = models.Training.objects.filter(user=user, session=session)
|
||||||
if training.exists():
|
if training.exists():
|
||||||
raise exceptions.ValidationError('Already registered')
|
raise exceptions.ValidationError(dict(non_field_errors='Already registered'))
|
||||||
if user == session.instructor:
|
if user == session.instructor:
|
||||||
raise exceptions.ValidationError('You are teaching this session')
|
raise exceptions.ValidationError(dict(non_field_errors='You are teaching this session'))
|
||||||
if status == 'Waiting for payment' and session.cost == 0:
|
if status == 'Waiting for payment' and session.cost == 0:
|
||||||
status = 'Confirmed'
|
status = 'Confirmed'
|
||||||
serializer.save(user=user, attendance_status=status)
|
serializer.save(user=user, attendance_status=status)
|
||||||
|
@ -736,12 +752,23 @@ class PasteView(views.APIView):
|
||||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
return Response(dict(paste=cache.get('paste', '')))
|
if request.user.id == 9:
|
||||||
|
key = 'special_paste'
|
||||||
|
logging.info('Using special paste for a special someone.')
|
||||||
|
else:
|
||||||
|
key = 'paste'
|
||||||
|
|
||||||
|
return Response(dict(paste=cache.get(key, '')))
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
if 'paste' in request.data:
|
if 'paste' in request.data:
|
||||||
cache.set('paste', request.data['paste'][:20000])
|
if request.user.id == 9:
|
||||||
return Response(dict(paste=cache.get('paste', '')))
|
key = 'special_paste'
|
||||||
|
logging.info('Using special paste for a special someone.')
|
||||||
|
else:
|
||||||
|
key = 'paste'
|
||||||
|
cache.set(key, request.data['paste'][:20000])
|
||||||
|
return Response(dict(paste=cache.get(key, '')))
|
||||||
else:
|
else:
|
||||||
raise exceptions.ValidationError(dict(paste='This field is required.'))
|
raise exceptions.ValidationError(dict(paste='This field is required.'))
|
||||||
|
|
||||||
|
|
|
@ -282,7 +282,7 @@ DEFAULT_FROM_EMAIL = 'Protospace Portal <portal@mg.protospace.ca>'
|
||||||
if DEBUG: logger.info('Debug mode ON')
|
if DEBUG: logger.info('Debug mode ON')
|
||||||
logger.info('Test logging for each thread')
|
logger.info('Test logging for each thread')
|
||||||
|
|
||||||
APP_VERSION = 1 # TODO: automate this
|
APP_VERSION = 2 # TODO: automate this
|
||||||
|
|
||||||
#import logging_tree
|
#import logging_tree
|
||||||
#logging_tree.printout()
|
#logging_tree.printout()
|
||||||
|
|
22
apiserver/export_class_report.py
Normal file
22
apiserver/export_class_report.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import django, sys, os
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'apiserver.settings'
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from apiserver.api import models
|
||||||
|
|
||||||
|
sessions = models.Session.objects.filter(datetime__gte='2021-01-01')
|
||||||
|
|
||||||
|
with open('output.csv', 'w', newline='') as csvfile:
|
||||||
|
fields = ['date', 'name', 'num_students']
|
||||||
|
writer = csv.DictWriter(csvfile, fieldnames=fields)
|
||||||
|
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for s in sessions:
|
||||||
|
writer.writerow(dict(
|
||||||
|
date=s.datetime.date(),
|
||||||
|
name=s.course.name,
|
||||||
|
num_students=s.students.filter(attendance_status='Attended').count(),
|
||||||
|
))
|
||||||
|
|
104
apiserver/output.csv
Normal file
104
apiserver/output.csv
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
date,name,num_students
|
||||||
|
2021-07-25,New Members: Orientation and Basic Safety,5
|
||||||
|
2021-07-17,Monthly Cleanup and Group Lunch,0
|
||||||
|
2021-08-07,Woodworking Tools 1: Intro to Saws,5
|
||||||
|
2021-08-08,Woodworking Tools 1: Intro to Saws,1
|
||||||
|
2021-08-07,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",6
|
||||||
|
2021-08-08,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",1
|
||||||
|
2021-08-07,Introduction to the Wood Lathe,4
|
||||||
|
2021-08-08,Introduction to the Wood Lathe,0
|
||||||
|
2021-08-07,New Members: Orientation and Basic Safety,3
|
||||||
|
2021-08-22,New Members: Orientation and Basic Safety,6
|
||||||
|
2021-08-21,Woodworking Tools 1: Intro to Saws,3
|
||||||
|
2021-08-21,Monthly Cleanup and Group Lunch,0
|
||||||
|
2021-09-11,Annual General Meeting,0
|
||||||
|
2021-09-11,Monthly Members Meeting,0
|
||||||
|
2021-09-25,Metal: Metal Cutting & Manual Lathe,5
|
||||||
|
2021-08-21,Introduction to the Wood Lathe,5
|
||||||
|
2021-09-02,New Members: Orientation and Basic Safety,3
|
||||||
|
2021-09-12,Basic CNC Wood Router,6
|
||||||
|
2021-09-04,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",2
|
||||||
|
2021-09-12,Woodworking Tools 1: Intro to Saws,4
|
||||||
|
2021-09-12,Introduction to the Wood Lathe,2
|
||||||
|
2021-09-11,New Members: Orientation and Basic Safety,1
|
||||||
|
2021-10-21,Monthly Members Meeting,0
|
||||||
|
2021-09-18,Monthly Cleanup and Group Lunch,0
|
||||||
|
2021-09-23,New Members: Orientation and Basic Safety,5
|
||||||
|
2021-10-03,New Members: Orientation and Basic Safety,1
|
||||||
|
2021-09-18,Basic CNC Wood Router,6
|
||||||
|
2021-10-02,Woodworking Tools 1: Intro to Saws,3
|
||||||
|
2021-10-09,Basic CNC Wood Router,5
|
||||||
|
2021-10-23,Laser: Cutting and Engraving,5
|
||||||
|
2021-11-06,Laser: Cutting and Engraving,3
|
||||||
|
2021-10-23,Laser: Cutting and Engraving,6
|
||||||
|
2021-10-16,Laser II: Trotec Course (For Rabbit-Certified members),0
|
||||||
|
2021-10-16,Laser II: Trotec Course (For Rabbit-Certified members),3
|
||||||
|
2021-10-23,Laser II: Trotec Course (For Rabbit-Certified members),1
|
||||||
|
2021-10-23,Laser II: Trotec Course (For Rabbit-Certified members),0
|
||||||
|
2021-10-30,Laser II: Trotec Course (For Rabbit-Certified members),8
|
||||||
|
2021-10-30,Laser II: Trotec Course (For Rabbit-Certified members),0
|
||||||
|
2021-10-22,New Members: Orientation and Basic Safety,1
|
||||||
|
2021-10-17,Woodworking Tools 1: Intro to Saws,5
|
||||||
|
2021-10-17,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",8
|
||||||
|
2021-10-17,Introduction to the Wood Lathe,3
|
||||||
|
2021-10-16,Monthly Cleanup and Group Lunch,0
|
||||||
|
2021-11-21,Woodworking Tools 1: Intro to Saws,4
|
||||||
|
2021-11-06,CAD/CAM for The Complete Beginner: Fusion 360,4
|
||||||
|
2021-11-07,New Members: Orientation and Basic Safety,6
|
||||||
|
2021-11-07,Laser II: Trotec Course (For Rabbit-Certified members),0
|
||||||
|
2021-11-05,Electronics Night,0
|
||||||
|
2021-11-13,Basic CNC Wood Router,4
|
||||||
|
2021-11-28,Basic CNC Wood Router,4
|
||||||
|
2021-12-03,Electronics Night,0
|
||||||
|
2021-11-25,New Members: Orientation and Basic Safety,7
|
||||||
|
2021-12-11,New Members: Orientation and Basic Safety,5
|
||||||
|
2021-11-19,Monthly Members Meeting,0
|
||||||
|
2021-11-20,Monthly Cleanup and Group Lunch,0
|
||||||
|
2022-01-15,CAD/CAM for The Complete Beginner: Fusion 360,0
|
||||||
|
2021-12-18,Monthly Cleanup and Group Lunch,0
|
||||||
|
2022-01-16,Woodworking Tools 1: Intro to Saws,6
|
||||||
|
2022-03-13,Woodworking Tools 1: Intro to Saws,0
|
||||||
|
2022-05-15,Woodworking Tools 1: Intro to Saws,0
|
||||||
|
2021-11-27,CRT Retro Computing Club,0
|
||||||
|
2021-12-23,New Members: Orientation and Basic Safety,1
|
||||||
|
2021-12-11,Roots2STEM tour,5
|
||||||
|
2021-12-18,Basic CNC Wood Router,4
|
||||||
|
2021-12-11,Woodworking Tools 1: Intro to Saws,3
|
||||||
|
2021-12-11,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",5
|
||||||
|
2021-12-12,Introduction to the Wood Lathe,1
|
||||||
|
2021-12-19,CRT Retro Computing Club,0
|
||||||
|
2022-01-23,3D Printing: Introduction to 3D Printing,7
|
||||||
|
2022-01-09,New Members: Orientation and Basic Safety,5
|
||||||
|
2022-01-22,New Members: Orientation and Basic Safety,4
|
||||||
|
2022-01-29,Basic CNC Wood Router,0
|
||||||
|
2022-01-07,Electronics Night,0
|
||||||
|
2022-02-04,Electronics Night,0
|
||||||
|
2022-02-13,Woodworking Tools 1: Intro to Saws,0
|
||||||
|
2022-02-03,New Members: Orientation and Basic Safety,0
|
||||||
|
2022-02-27,Basic CNC Wood Router,0
|
||||||
|
2022-03-05,Tormach: CAM and Tormach Intro,0
|
||||||
|
2022-02-05,CAD: Introduction to 3D CAD (Fusion),0
|
||||||
|
2022-01-30,Laser: Cutting and Engraving,0
|
||||||
|
2022-01-22,CAD/CAM for The Complete Beginner Part II (Classroom Booking Edition),0
|
||||||
|
2022-02-12,Metal: Metal Cutting & Manual Lathe,0
|
||||||
|
2022-01-21,Monthly Members Meeting,0
|
||||||
|
2022-02-12,New Members: Orientation and Basic Safety,0
|
||||||
|
2022-02-24,New Members: Orientation and Basic Safety,0
|
||||||
|
2022-03-26,Basic CNC Wood Router,0
|
||||||
|
2022-01-29,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",0
|
||||||
|
2022-02-06,Laser: Cutting and Engraving,0
|
||||||
|
2022-01-28,New Members: Orientation and Basic Safety,2
|
||||||
|
2022-01-23,New Members: Orientation and Basic Safety,2
|
||||||
|
2022-01-25,New Members: Orientation and Basic Safety,1
|
||||||
|
2022-01-29,New Members: Orientation and Basic Safety,0
|
||||||
|
2022-02-17,Monthly Members Meeting,0
|
||||||
|
2022-02-19,Monthly Cleanup and Group Lunch,0
|
||||||
|
2022-01-30,Intro to Blade Sharpening,0
|
||||||
|
2022-01-29,Welding 101 - MIG Welding and Safety,0
|
||||||
|
2021-12-30,Woodworking Tools 1: Intro to Saws,0
|
||||||
|
2022-01-27,Woodworking Tools 1: Intro to Saws,2
|
||||||
|
2022-02-03,Woodworking Tools 1: Intro to Saws,0
|
||||||
|
2022-02-05,"Woodworking Tools 2: Jointer, Thickness Planer, Drum Sander",0
|
||||||
|
2022-02-12,Roots 2 STEM classes,0
|
||||||
|
2022-02-12,Laser II: Trotec Course (For Rabbit-Certified members),0
|
||||||
|
2022-02-19,Laser II: Trotec Course (For Rabbit-Certified members),0
|
|
|
@ -18,7 +18,7 @@ import { Admin } from './Admin.js';
|
||||||
import { Paste } from './Paste.js';
|
import { Paste } from './Paste.js';
|
||||||
import { Sign } from './Sign.js';
|
import { Sign } from './Sign.js';
|
||||||
import { Courses, CourseDetail } from './Courses.js';
|
import { Courses, CourseDetail } from './Courses.js';
|
||||||
import { Classes, ClassDetail } from './Classes.js';
|
import { ClassFeed, 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 { Auth } from './Auth.js';
|
||||||
|
@ -27,7 +27,7 @@ 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';
|
||||||
|
|
||||||
const APP_VERSION = 1; // TODO: automate this
|
const APP_VERSION = 2; // TODO: automate this
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [token, setToken] = useState(localStorage.getItem('token', ''));
|
const [token, setToken] = useState(localStorage.getItem('token', ''));
|
||||||
|
@ -112,6 +112,12 @@ function App() {
|
||||||
<div className='content-wrap'>
|
<div className='content-wrap'>
|
||||||
<div className='content-wrap-inside'>
|
<div className='content-wrap-inside'>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route exact path='/classfeed'>
|
||||||
|
<ClassFeed />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path='/'>
|
||||||
<Container>
|
<Container>
|
||||||
<div className='hero'>
|
<div className='hero'>
|
||||||
<Link to='/'>
|
<Link to='/'>
|
||||||
|
@ -243,6 +249,10 @@ function App() {
|
||||||
<Subscribe />
|
<Subscribe />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route exact path='/classes'>
|
||||||
|
<Classes token={token} user={user} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{user && user.member.set_details ?
|
{user && user.member.set_details ?
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path='/account'>
|
<Route path='/account'>
|
||||||
|
@ -271,6 +281,7 @@ function App() {
|
||||||
<Route path='/courses/:id'>
|
<Route path='/courses/:id'>
|
||||||
<CourseDetail token={token} user={user} />
|
<CourseDetail token={token} user={user} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path='/courses'>
|
<Route path='/courses'>
|
||||||
<Courses token={token} user={user} />
|
<Courses token={token} user={user} />
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -278,9 +289,6 @@ function App() {
|
||||||
<Route path='/classes/:id'>
|
<Route path='/classes/:id'>
|
||||||
<ClassDetail token={token} user={user} refreshUser={refreshUser} />
|
<ClassDetail token={token} user={user} refreshUser={refreshUser} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='/classes'>
|
|
||||||
<Classes token={token} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path='/members/:id'>
|
<Route path='/members/:id'>
|
||||||
<MemberDetail token={token} user={user} />
|
<MemberDetail token={token} user={user} />
|
||||||
|
@ -312,6 +320,8 @@ function App() {
|
||||||
}
|
}
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
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 } from 'react-router-dom';
|
||||||
import './light.css';
|
import './light.css';
|
||||||
import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
import { Label, Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { isAdmin, isInstructor, getInstructor, BasicTable, requester } from './utils.js';
|
import { isAdmin, isInstructor, getInstructor, BasicTable, requester } from './utils.js';
|
||||||
import { NotFound, PleaseLogin } from './Misc.js';
|
import { NotFound, PleaseLogin } from './Misc.js';
|
||||||
import { InstructorClassDetail, InstructorClassAttendance } from './InstructorClasses.js';
|
import { InstructorClassDetail, InstructorClassAttendance } from './InstructorClasses.js';
|
||||||
import { PayPalPayNow } from './PayPal.js';
|
import { PayPalPayNow } from './PayPal.js';
|
||||||
|
import { tags } from './Courses.js';
|
||||||
|
|
||||||
function ClassTable(props) {
|
function ClassTable(props) {
|
||||||
const { classes } = props;
|
const { classes } = props;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table basic='very'>
|
<Table basic='very'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
|
@ -27,8 +30,8 @@ function ClassTable(props) {
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{classes.length ?
|
{classes.length ?
|
||||||
classes.map(x =>
|
classes.map(x =>
|
||||||
<Table.Row key={x.id}>
|
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
|
||||||
<Table.Cell>{x.course_data.name}</Table.Cell>
|
<Table.Cell> {x.course_data.name}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Link to={'/classes/'+x.id}>
|
<Link to={'/classes/'+x.id}>
|
||||||
{moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
|
{moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
|
||||||
|
@ -37,7 +40,16 @@ function ClassTable(props) {
|
||||||
<Table.Cell>{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}</Table.Cell>
|
<Table.Cell>{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}</Table.Cell>
|
||||||
<Table.Cell>{getInstructor(x)}</Table.Cell>
|
<Table.Cell>{getInstructor(x)}</Table.Cell>
|
||||||
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost}</Table.Cell>
|
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost}</Table.Cell>
|
||||||
<Table.Cell>{x.student_count} {!!x.max_students && '/ '+x.max_students}</Table.Cell>
|
<Table.Cell>
|
||||||
|
{!!x.max_students ?
|
||||||
|
x.max_students <= x.student_count ?
|
||||||
|
'Full'
|
||||||
|
:
|
||||||
|
x.student_count + ' / ' + x.max_students
|
||||||
|
:
|
||||||
|
x.student_count
|
||||||
|
}
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
)
|
)
|
||||||
:
|
:
|
||||||
|
@ -48,11 +60,138 @@ function ClassTable(props) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function NewClassTable(props) {
|
||||||
|
const { classes } = props;
|
||||||
|
|
||||||
|
let sortedClasses = [];
|
||||||
|
if (classes.length) {
|
||||||
|
for (const clazz of classes) {
|
||||||
|
const course_data = clazz.course_data;
|
||||||
|
const course = sortedClasses.find(x => x?.course?.id === course_data?.id);
|
||||||
|
|
||||||
|
if (course) {
|
||||||
|
course.classes.push(clazz);
|
||||||
|
} else {
|
||||||
|
sortedClasses.push({
|
||||||
|
course: course_data,
|
||||||
|
classes: [clazz],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ margin: '0 -1.5rem 0 -0.5rem', display: 'flex', flexWrap: 'wrap' }}>
|
||||||
|
{sortedClasses.map(x =>
|
||||||
|
<Segment style={{ margin: '1rem 1rem 0 0', width: '30rem' }}>
|
||||||
|
<Header size='medium'>
|
||||||
|
<Link to={'/courses/'+x.course.id}>
|
||||||
|
{x.course.name}
|
||||||
|
</Link>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{!!x.course.tags && x.course.tags.split(',').map(name =>
|
||||||
|
<Label color={tags[name]} tag>
|
||||||
|
{name}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<Table compact unstackable singleLine basic='very'>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>Date</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Time</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Cost</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Students</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{x.classes.map(x =>
|
||||||
|
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
|
||||||
|
<Table.Cell>
|
||||||
|
<Link to={'/classes/'+x.id}>
|
||||||
|
{moment.utc(x.datetime).tz('America/Edmonton').format('MMM Do')}
|
||||||
|
</Link>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell>
|
||||||
|
{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost}</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell>
|
||||||
|
{!!x.max_students ?
|
||||||
|
x.max_students <= x.student_count ?
|
||||||
|
'Full'
|
||||||
|
:
|
||||||
|
x.student_count + ' / ' + x.max_students
|
||||||
|
:
|
||||||
|
x.student_count
|
||||||
|
}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</Segment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
let classesCache = false;
|
let classesCache = false;
|
||||||
|
let sortCache = true;
|
||||||
|
let tagFilterCache = false;
|
||||||
|
|
||||||
|
export function ClassFeed(props) {
|
||||||
|
const [classes, setClasses] = useState(classesCache);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const get = async() => {
|
||||||
|
requester('/sessions/', 'GET', '')
|
||||||
|
.then(res => {
|
||||||
|
setClasses(res.results);
|
||||||
|
classesCache = res.results;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
get();
|
||||||
|
const interval = setInterval(get, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<p/>
|
||||||
|
|
||||||
|
<Header size='large'>Upcoming Protospace Classes</Header>
|
||||||
|
|
||||||
|
{classes ?
|
||||||
|
<ClassTable classes={classes.filter(x => x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
|
||||||
|
:
|
||||||
|
<p>Loading...</p>
|
||||||
|
}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function Classes(props) {
|
export function Classes(props) {
|
||||||
const [classes, setClasses] = useState(classesCache);
|
const [classes, setClasses] = useState(classesCache);
|
||||||
const { token } = props;
|
const [sortByCourse, setSortByCourse] = useState(sortCache);
|
||||||
|
const [tagFilter, setTagFilter] = useState(tagFilterCache);
|
||||||
|
const { token, user } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requester('/sessions/', 'GET', token)
|
requester('/sessions/', 'GET', token)
|
||||||
|
@ -65,7 +204,11 @@ export function Classes(props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const byTeaching = (x) => x.instructor_id === user.member.id;
|
||||||
|
const byDate = (a, b) => a.datetime > b.datetime ? 1 : -1;
|
||||||
|
const byTag = (x) => tagFilter ? x.course_data.tags.includes(tagFilter) : true;
|
||||||
|
|
||||||
|
console.log(tagFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
@ -73,22 +216,70 @@ export function Classes(props) {
|
||||||
|
|
||||||
<p><Link to={'/courses'}>Click here to view the list of all courses.</Link></p>
|
<p><Link to={'/courses'}>Click here to view the list of all courses.</Link></p>
|
||||||
|
|
||||||
<Header size='medium'>Upcoming</Header>
|
{!!user && !!classes.length && !!classes.filter(byTeaching).length &&
|
||||||
|
<>
|
||||||
<p>Ordered by nearest date.</p>
|
<Header size='medium'>Classes You're Teaching</Header>
|
||||||
|
<ClassTable classes={classes.slice().filter(byTeaching).sort(byDate)} />
|
||||||
{classes ?
|
</>
|
||||||
<ClassTable classes={classes.filter(x => x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
|
|
||||||
:
|
|
||||||
<p>Loading...</p>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<Header size='medium'>Recent</Header>
|
<p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSortByCourse(true);
|
||||||
|
sortCache = true;
|
||||||
|
}}
|
||||||
|
active={sortByCourse}
|
||||||
|
>
|
||||||
|
Sort by course
|
||||||
|
</Button>
|
||||||
|
|
||||||
<p>Ordered by nearest date.</p>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setSortByCourse(false);
|
||||||
|
sortCache = false;
|
||||||
|
}}
|
||||||
|
active={!sortByCourse}
|
||||||
|
>
|
||||||
|
Sort by date
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
|
||||||
{classes ?
|
<p>
|
||||||
<ClassTable classes={classes.filter(x => x.datetime < now)} />
|
Filter by tag:
|
||||||
|
<div className='coursetags'>
|
||||||
|
{Object.entries(tags).map(([name, color]) =>
|
||||||
|
<Label
|
||||||
|
onClick={() => {
|
||||||
|
setTagFilter(name);
|
||||||
|
tagFilterCache = name;
|
||||||
|
}}
|
||||||
|
as='a'
|
||||||
|
color={color}
|
||||||
|
tag
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{tagFilter && <Button
|
||||||
|
onClick={() => {
|
||||||
|
setTagFilter(false);
|
||||||
|
tagFilterCache = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear {tagFilter} filter
|
||||||
|
</Button>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
{classes.length ?
|
||||||
|
sortByCourse ?
|
||||||
|
<NewClassTable classes={classes.filter(byTag)} />
|
||||||
|
:
|
||||||
|
<ClassTable classes={classes.slice().filter(byTag).sort(byDate)} />
|
||||||
:
|
:
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,35 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } 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 { Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
import { Button, Label, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, Menu, Message, Segment, Table } from 'semantic-ui-react';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { isInstructor, getInstructor, requester } from './utils.js';
|
import { isInstructor, getInstructor, requester } from './utils.js';
|
||||||
import { NotFound, PleaseLogin } from './Misc.js';
|
import { NotFound, PleaseLogin } from './Misc.js';
|
||||||
import { InstructorCourseList, InstructorCourseDetail } from './InstructorCourses.js';
|
import { InstructorCourseList, InstructorCourseDetail } from './InstructorCourses.js';
|
||||||
import { InstructorClassList } from './InstructorClasses.js';
|
import { InstructorClassList } from './InstructorClasses.js';
|
||||||
|
|
||||||
|
export const tags = {
|
||||||
|
Protospace: 'black',
|
||||||
|
Laser: 'red',
|
||||||
|
Wood: 'brown',
|
||||||
|
CNC: 'orange',
|
||||||
|
Niche: 'yellow',
|
||||||
|
//name: 'olive',
|
||||||
|
Electronics: 'green',
|
||||||
|
Computers: 'teal',
|
||||||
|
Metal: 'blue',
|
||||||
|
//name: 'violet',
|
||||||
|
Event: 'purple',
|
||||||
|
Outing: 'pink',
|
||||||
|
Misc: 'grey',
|
||||||
|
};
|
||||||
|
|
||||||
let courseCache = false;
|
let courseCache = false;
|
||||||
|
let tagFilterCache = false;
|
||||||
|
|
||||||
export function Courses(props) {
|
export function Courses(props) {
|
||||||
const [courses, setCourses] = useState(courseCache);
|
const [courses, setCourses] = useState(courseCache);
|
||||||
|
const [tagFilter, setTagFilter] = useState(tagFilterCache);
|
||||||
const { token, user } = props;
|
const { token, user } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -25,6 +43,8 @@ export function Courses(props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const byTag = (x) => tagFilter ? x.tags.includes(tagFilter) : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Header size='large'>Courses</Header>
|
<Header size='large'>Courses</Header>
|
||||||
|
@ -33,21 +53,58 @@ export function Courses(props) {
|
||||||
<InstructorCourseList courses={courses} setCourses={setCourses} {...props} />
|
<InstructorCourseList courses={courses} setCourses={setCourses} {...props} />
|
||||||
</Segment>}
|
</Segment>}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Filter by tag:
|
||||||
|
<div className='coursetags'>
|
||||||
|
{Object.entries(tags).map(([name, color]) =>
|
||||||
|
<Label
|
||||||
|
onClick={() => {
|
||||||
|
setTagFilter(name);
|
||||||
|
tagFilterCache = name;
|
||||||
|
}}
|
||||||
|
as='a'
|
||||||
|
color={color}
|
||||||
|
tag
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{tagFilter && <Button
|
||||||
|
onClick={() => {
|
||||||
|
setTagFilter(false);
|
||||||
|
tagFilterCache = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear {tagFilter} filter
|
||||||
|
</Button>}
|
||||||
|
</p>
|
||||||
|
|
||||||
{courses ?
|
{courses ?
|
||||||
<Table basic='very'>
|
<Table basic='very'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell></Table.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{courses.length ?
|
{courses.length ?
|
||||||
courses.map(x =>
|
courses.filter(byTag).map(x =>
|
||||||
<Table.Row key={x.id}>
|
<Table.Row key={x.id}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Link to={'/courses/'+x.id}>{x.name}</Link>
|
<Link to={'/courses/'+x.id}>{x.name}</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{!!x.tags && x.tags.split(',').map(name =>
|
||||||
|
<Label color={tags[name]} tag>
|
||||||
|
{name}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
)
|
)
|
||||||
:
|
:
|
||||||
|
@ -80,6 +137,8 @@ export function CourseDetail(props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
{!error ?
|
{!error ?
|
||||||
|
@ -113,21 +172,32 @@ export function CourseDetail(props) {
|
||||||
<Table.HeaderCell>Time</Table.HeaderCell>
|
<Table.HeaderCell>Time</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Instructor</Table.HeaderCell>
|
<Table.HeaderCell>Instructor</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Cost</Table.HeaderCell>
|
<Table.HeaderCell>Cost</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Students</Table.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{course.sessions.length ?
|
{course.sessions.length ?
|
||||||
course.sessions.sort((a, b) => a.datetime < b.datetime ? 1 : -1).slice(0,10).map(x =>
|
course.sessions.sort((a, b) => a.datetime < b.datetime ? 1 : -1).slice(0,10).map(x =>
|
||||||
<Table.Row key={x.id}>
|
<Table.Row key={x.id} active={x.datetime < now || x.is_cancelled}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Link to={'/classes/'+x.id}>
|
<Link to={'/classes/'+x.id}>
|
||||||
{moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
|
{moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
|
||||||
</Link>
|
</Link>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}</Table.Cell>
|
<Table.Cell>{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}</Table.Cell>
|
||||||
<Table.Cell>{getInstructor(x)}</Table.Cell>
|
<Table.Cell>{getInstructor(x)}</Table.Cell>
|
||||||
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost}</Table.Cell>
|
<Table.Cell>{x.cost === '0.00' ? 'Free' : '$'+x.cost}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{!!x.max_students ?
|
||||||
|
x.max_students <= x.student_count ?
|
||||||
|
'Full'
|
||||||
|
:
|
||||||
|
x.student_count + ' / ' + x.max_students
|
||||||
|
:
|
||||||
|
x.student_count
|
||||||
|
}
|
||||||
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
)
|
)
|
||||||
:
|
:
|
||||||
|
|
|
@ -235,7 +235,7 @@ export function Home(props) {
|
||||||
<p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p>
|
<p><a href='http://protospace.ca/' target='_blank' rel='noopener noreferrer'>Main Website</a></p>
|
||||||
<p><a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>Protospace Wiki</a> — <Link to='/auth/wiki'>[register]</Link></p>
|
<p><a href='http://wiki.protospace.ca/Welcome_to_Protospace' target='_blank' rel='noopener noreferrer'>Protospace Wiki</a> — <Link to='/auth/wiki'>[register]</Link></p>
|
||||||
<p><a href='https://forum.protospace.ca' target='_blank' rel='noopener noreferrer'>Protospace Forum</a> — <Link to='/auth/discourse'>[register]</Link></p>
|
<p><a href='https://forum.protospace.ca' target='_blank' rel='noopener noreferrer'>Protospace Forum</a> — <Link to='/auth/discourse'>[register]</Link></p>
|
||||||
{!!user && <p><a href='https://drive.google.com/open?id=0By-vvp6fxFekfmU1cmdxaVRlaldiYXVyTE9rRnNVNjhkc3FjdkFIbjBwQkZ3MVVQX2Ezc3M' target='_blank' rel='noopener noreferrer'>Google Drive</a></p>}
|
{!!user && <p><a href='https://drive.google.com/drive/folders/0By-vvp6fxFekfmU1cmdxaVRlaldiYXVyTE9rRnNVNjhkc3FjdkFIbjBwQkZ3MVVQX2Ezc3M?resourcekey=0-qVLjcYr8ZCmLypdINk2svg' target='_blank' rel='noopener noreferrer'>Google Drive</a></p>}
|
||||||
{!!user && isAdmin(user) && <p><a href='https://estancia.hippocmms.ca/' target='_blank' rel='noopener noreferrer'>Property Management Portal</a></p>}
|
{!!user && isAdmin(user) && <p><a href='https://estancia.hippocmms.ca/' target='_blank' rel='noopener noreferrer'>Property Management Portal</a></p>}
|
||||||
|
|
||||||
<img className='swordfish' src='/swordfish.png' onClick={() => refreshStats()} />
|
<img className='swordfish' src='/swordfish.png' onClick={() => refreshStats()} />
|
||||||
|
|
|
@ -209,7 +209,11 @@ export function InstructorClassAttendance(props) {
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Button loading={loading} error={error.non_field_errors}>
|
<Form.Button
|
||||||
|
loading={loading}
|
||||||
|
error={error.non_field_errors}
|
||||||
|
disabled={!input.member_id}
|
||||||
|
>
|
||||||
Submit
|
Submit
|
||||||
</Form.Button>
|
</Form.Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -11,6 +11,8 @@ export function Paymaster(props) {
|
||||||
const { user } = props;
|
const { user } = props;
|
||||||
const [pop, setPop] = useState('20.00');
|
const [pop, setPop] = useState('20.00');
|
||||||
const [locker, setLocker] = useState('5.00');
|
const [locker, setLocker] = useState('5.00');
|
||||||
|
const [consumables, setConsumables] = useState('20.00');
|
||||||
|
const [consumablesMemo, setConsumablesMemo] = useState('');
|
||||||
const [donate, setDonate] = useState('20.00');
|
const [donate, setDonate] = useState('20.00');
|
||||||
const [memo, setMemo] = useState('');
|
const [memo, setMemo] = useState('');
|
||||||
|
|
||||||
|
@ -93,26 +95,44 @@ export function Paymaster(props) {
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Header size='medium'>Consumables</Header>
|
||||||
|
|
||||||
|
<p>Pay for materials you use (ie. welding gas, 3D printing, blades, etc).</p>
|
||||||
|
|
||||||
|
<Grid stackable padded columns={1}>
|
||||||
|
<Grid.Column>
|
||||||
|
Custom amount:
|
||||||
|
|
||||||
|
<div className='pay-custom'>
|
||||||
|
<Input
|
||||||
|
fluid
|
||||||
|
label={{ basic: true, content: '$' }}
|
||||||
|
labelPosition='left'
|
||||||
|
value={consumables}
|
||||||
|
onChange={(e, v) => setConsumables(v.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please explain what you bought:<br/>
|
||||||
|
<Input
|
||||||
|
value={consumablesMemo}
|
||||||
|
maxLength={50}
|
||||||
|
onChange={(e, v) => setConsumablesMemo(v.value)}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PayPalPayNow
|
||||||
|
amount={consumables}
|
||||||
|
name='Protospace Consumables'
|
||||||
|
custom={JSON.stringify({ category: 'Consumables', member: user.member.id, memo: consumablesMemo })}
|
||||||
|
/>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Header size='medium'>Donate</Header>
|
<Header size='medium'>Donate</Header>
|
||||||
<Grid stackable padded columns={3}>
|
|
||||||
<Grid.Column>
|
|
||||||
<p>Donate $5.00:</p>
|
|
||||||
<PayPalPayNow
|
|
||||||
amount={5}
|
|
||||||
name='Protospace Donation'
|
|
||||||
custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })}
|
|
||||||
/>
|
|
||||||
</Grid.Column>
|
|
||||||
|
|
||||||
<Grid.Column>
|
|
||||||
<p>Donate $10.00:</p>
|
|
||||||
<PayPalPayNow
|
|
||||||
amount={10}
|
|
||||||
name='Protospace Donation'
|
|
||||||
custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })}
|
|
||||||
/>
|
|
||||||
</Grid.Column>
|
|
||||||
|
|
||||||
|
<Grid stackable padded columns={1}>
|
||||||
<Grid.Column>
|
<Grid.Column>
|
||||||
Custom amount:
|
Custom amount:
|
||||||
|
|
||||||
|
@ -126,19 +146,21 @@ export function Paymaster(props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Optional memo:<br/>
|
||||||
|
<Input
|
||||||
|
value={memo}
|
||||||
|
maxLength={50}
|
||||||
|
onChange={(e, v) => setMemo(v.value)}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
<PayPalPayNow
|
<PayPalPayNow
|
||||||
amount={donate}
|
amount={donate}
|
||||||
name='Protospace Donation'
|
name='Protospace Donation'
|
||||||
custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })}
|
custom={JSON.stringify({ category: 'Donation', member: user.member.id, memo: memo })}
|
||||||
/>
|
/>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
|
|
||||||
Add an optional memo to your donation:
|
|
||||||
<Input
|
|
||||||
value={memo}
|
|
||||||
maxLength={50}
|
|
||||||
onChange={(e, v) => setMemo(v.value)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Header size='medium'>Locker Storage</Header>
|
<Header size='medium'>Locker Storage</Header>
|
||||||
|
|
|
@ -129,6 +129,10 @@ body {
|
||||||
margin: 0 1.5em 1em !important;
|
margin: 0 1.5em 1em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coursetags .ui.tag.label {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: -20rem;
|
margin-top: -20rem;
|
||||||
background: black;
|
background: black;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user