diff --git a/apiserver/apiserver/api/emails/welcome.html b/apiserver/apiserver/api/emails/welcome.html
new file mode 100644
index 0000000..a3a5df0
--- /dev/null
+++ b/apiserver/apiserver/api/emails/welcome.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ Hi [name],
+
+ You just signed up to Spaceport with the username: [username]
+
+ To manage your Protospace membership go to:
+
+
+ You have automatically been added to our forum Spacebar at:
+
+
+ Please introduce yourself here:
+
+
+ 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:
+
+
+ Good luck,
+ Spaceport
+
+
+
diff --git a/apiserver/apiserver/api/emails/welcome.txt b/apiserver/apiserver/api/emails/welcome.txt
new file mode 100644
index 0000000..265a6ae
--- /dev/null
+++ b/apiserver/apiserver/api/emails/welcome.txt
@@ -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
diff --git a/apiserver/apiserver/api/models.py b/apiserver/apiserver/api/models.py
index fe473b6..316053c 100644
--- a/apiserver/apiserver/api/models.py
+++ b/apiserver/apiserver/api/models.py
@@ -117,6 +117,7 @@ class Course(models.Model):
name = models.TextField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
is_old = models.BooleanField(default=False)
+ tags = models.CharField(max_length=128, blank=True)
history = HistoricalRecords()
diff --git a/apiserver/apiserver/api/serializers.py b/apiserver/apiserver/api/serializers.py
index a79771c..8a27db9 100644
--- a/apiserver/apiserver/api/serializers.py
+++ b/apiserver/apiserver/api/serializers.py
@@ -449,12 +449,13 @@ class StudentTrainingSerializer(TrainingSerializer):
class CourseSerializer(serializers.ModelSerializer):
class Meta:
model = models.Course
- fields = ['id', 'name', 'is_old', 'description']
+ fields = ['id', 'name', 'is_old', 'description', 'tags']
class SessionSerializer(serializers.ModelSerializer):
student_count = serializers.SerializerMethodField()
course_data = serializers.SerializerMethodField()
instructor_name = serializers.SerializerMethodField()
+ instructor_id = serializers.SerializerMethodField()
datetime = serializers.DateTimeField()
course = serializers.PrimaryKeyRelatedField(queryset=models.Course.objects.all())
students = TrainingSerializer(many=True, read_only=True)
@@ -479,6 +480,12 @@ class SessionSerializer(serializers.ModelSerializer):
name = 'Unknown'
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):
students = None
diff --git a/apiserver/apiserver/api/throttles.py b/apiserver/apiserver/api/throttles.py
index ade5c33..c2d0b4f 100644
--- a/apiserver/apiserver/api/throttles.py
+++ b/apiserver/apiserver/api/throttles.py
@@ -22,6 +22,8 @@ class LoggingThrottle(throttling.BaseThrottle):
pass
elif path.startswith('/stats/'):
return True
+ elif path == '/sessions/' and user == None:
+ return True
if request.data:
data = request.data.dict()
diff --git a/apiserver/apiserver/api/utils.py b/apiserver/apiserver/api/utils.py
index 5285519..90700b2 100644
--- a/apiserver/apiserver/api/utils.py
+++ b/apiserver/apiserver/api/utils.py
@@ -21,7 +21,7 @@ from django.db.models import Sum
from django.core.cache import cache
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
STATIC_FOLDER = 'data/static/'
@@ -374,6 +374,14 @@ def register_user(data, user):
utils.alert_tanner(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!')
time.sleep(1)
diff --git a/apiserver/apiserver/api/utils_email.py b/apiserver/apiserver/api/utils_email.py
new file mode 100644
index 0000000..7a16d96
--- /dev/null
+++ b/apiserver/apiserver/api/utils_email.py
@@ -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)
diff --git a/apiserver/apiserver/api/utils_paypal.py b/apiserver/apiserver/api/utils_paypal.py
index a3676df..bf135a4 100644
--- a/apiserver/apiserver/api/utils_paypal.py
+++ b/apiserver/apiserver/api/utils_paypal.py
@@ -353,7 +353,7 @@ def process_paypal_ipn(data):
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')
update_ipn(ipn, 'Accepted, category')
return create_category_tx(data, member, custom_json)
diff --git a/apiserver/apiserver/api/views.py b/apiserver/apiserver/api/views.py
index 0e331f3..d6e17cb 100644
--- a/apiserver/apiserver/api/views.py
+++ b/apiserver/apiserver/api/views.py
@@ -4,7 +4,7 @@ logger = logging.getLogger(__name__)
from django.contrib.auth.models import User, Group
from django.shortcuts import get_object_or_404, redirect
from django.db 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.http import HttpResponse, Http404, FileResponse
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):
- permission_classes = [AllowMetadata | IsAuthenticated, IsAdminOrReadOnly | IsInstructorOrReadOnly]
+ permission_classes = [AllowMetadata | IsAuthenticatedOrReadOnly, IsAdminOrReadOnly | IsInstructorOrReadOnly]
queryset = models.Course.objects.annotate(date=Max('sessions__datetime')).order_by('-date')
def get_serializer_class(self):
@@ -241,11 +241,27 @@ class CourseViewSet(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):
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:
return models.Session.objects.all()
@@ -323,7 +339,7 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
if data.get('member_id', None):
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'])
user = member.user
@@ -338,9 +354,9 @@ class TrainingViewSet(Base, Retrieve, Create, Update):
else:
training = models.Training.objects.filter(user=user, session=session)
if training.exists():
- raise exceptions.ValidationError('Already registered')
+ raise exceptions.ValidationError(dict(non_field_errors='Already registered'))
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:
status = 'Confirmed'
serializer.save(user=user, attendance_status=status)
@@ -736,12 +752,23 @@ class PasteView(views.APIView):
permission_classes = [IsAuthenticatedOrReadOnly]
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):
if 'paste' in request.data:
- cache.set('paste', request.data['paste'][:20000])
- 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'
+ cache.set(key, request.data['paste'][:20000])
+ return Response(dict(paste=cache.get(key, '')))
else:
raise exceptions.ValidationError(dict(paste='This field is required.'))
diff --git a/apiserver/apiserver/settings.py b/apiserver/apiserver/settings.py
index 9a596b5..b44399c 100644
--- a/apiserver/apiserver/settings.py
+++ b/apiserver/apiserver/settings.py
@@ -282,7 +282,7 @@ DEFAULT_FROM_EMAIL = 'Protospace Portal '
if DEBUG: logger.info('Debug mode ON')
logger.info('Test logging for each thread')
-APP_VERSION = 1 # TODO: automate this
+APP_VERSION = 2 # TODO: automate this
#import logging_tree
#logging_tree.printout()
diff --git a/apiserver/export_class_report.py b/apiserver/export_class_report.py
new file mode 100644
index 0000000..834c7a6
--- /dev/null
+++ b/apiserver/export_class_report.py
@@ -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(),
+ ))
+
diff --git a/apiserver/output.csv b/apiserver/output.csv
new file mode 100644
index 0000000..8ea4349
--- /dev/null
+++ b/apiserver/output.csv
@@ -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
diff --git a/webclient/src/App.js b/webclient/src/App.js
index 8daf355..87d38b3 100644
--- a/webclient/src/App.js
+++ b/webclient/src/App.js
@@ -18,7 +18,7 @@ import { Admin } from './Admin.js';
import { Paste } from './Paste.js';
import { Sign } from './Sign.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 { Charts } from './Charts.js';
import { Auth } from './Auth.js';
@@ -27,7 +27,7 @@ import { PasswordReset, ConfirmReset } from './PasswordReset.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { Footer } from './Footer.js';
-const APP_VERSION = 1; // TODO: automate this
+const APP_VERSION = 2; // TODO: automate this
function App() {
const [token, setToken] = useState(localStorage.getItem('token', ''));
@@ -112,206 +112,216 @@ function App() {
-
-
-
-
-
-
+
+
+
+
- {window.location.hostname !== 'my.protospace.ca' &&
- ~~~~~ Development site ~~~~~
- }
-
+
+
+
+
+
+
+
-
-
+ {user && isAdmin(user) && }
-
-
-
+ {user && isAdmin(user) && }
+
+
-
-
-
-
-
-
-
+ {user &&
+
+
+ }
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {user && user.member.set_details ?
+
-
-
+
+
+
+
+
-
-
-
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
+
+
-
-
-
-
-
+
+
-
-
-
-
-
-
+ {user && user.member.set_details ?
+
+
+
+
- {user && isAdmin(user) &&
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user && isAdmin(user) &&
+
+
+
+ }
+
+ {user && isAdmin(user) &&
+
+
+
+ }
+
+
+
+
+
+ :
+
+
}
-
- {user && isAdmin(user) &&
-
-
-
- }
-
-
-
-
- :
-
-
-
- }
-
-
+
+
+
diff --git a/webclient/src/Classes.js b/webclient/src/Classes.js
index c961986..d639e57 100644
--- a/webclient/src/Classes.js
+++ b/webclient/src/Classes.js
@@ -1,16 +1,19 @@
import React, { useState, useEffect, useReducer } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
import './light.css';
-import { Button, Container, Divider, Dropdown, Form, Grid, Header, Icon, Image, 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 { isAdmin, isInstructor, getInstructor, BasicTable, requester } from './utils.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { InstructorClassDetail, InstructorClassAttendance } from './InstructorClasses.js';
import { PayPalPayNow } from './PayPal.js';
+import { tags } from './Courses.js';
function ClassTable(props) {
const { classes } = props;
+ const now = new Date().toISOString();
+
return (
@@ -27,8 +30,8 @@ function ClassTable(props) {
{classes.length ?
classes.map(x =>
-
- {x.course_data.name}
+
+ {x.course_data.name}
{moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
@@ -37,7 +40,16 @@ function ClassTable(props) {
{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
{getInstructor(x)}
{x.cost === '0.00' ? 'Free' : '$'+x.cost}
- {x.student_count} {!!x.max_students && '/ '+x.max_students}
+
+ {!!x.max_students ?
+ x.max_students <= x.student_count ?
+ 'Full'
+ :
+ x.student_count + ' / ' + x.max_students
+ :
+ x.student_count
+ }
+
)
:
@@ -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 (
+ <>
+
+ {sortedClasses.map(x =>
+
+
+
+ {!!x.course.tags && x.course.tags.split(',').map(name =>
+
+ )}
+
+
+
+
+
+ Date
+ Time
+ Cost
+ Students
+
+
+
+
+ {x.classes.map(x =>
+
+
+
+ {moment.utc(x.datetime).tz('America/Edmonton').format('MMM Do')}
+
+
+
+
+ {x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
+
+
+ {x.cost === '0.00' ? 'Free' : '$'+x.cost}
+
+
+ {!!x.max_students ?
+ x.max_students <= x.student_count ?
+ 'Full'
+ :
+ x.student_count + ' / ' + x.max_students
+ :
+ x.student_count
+ }
+
+
+ )}
+
+
+
+ )}
+
+ >
+ );
+};
+
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 (
+
+
+
+ Upcoming Protospace Classes
+
+ {classes ?
+ x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
+ :
+ Loading...
+ }
+
+ );
+};
export function Classes(props) {
const [classes, setClasses] = useState(classesCache);
- const { token } = props;
+ const [sortByCourse, setSortByCourse] = useState(sortCache);
+ const [tagFilter, setTagFilter] = useState(tagFilterCache);
+ const { token, user } = props;
useEffect(() => {
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 (
@@ -73,22 +216,70 @@ export function Classes(props) {
Click here to view the list of all courses.
-
-
- Ordered by nearest date.
-
- {classes ?
- x.datetime > now).sort((a, b) => a.datetime > b.datetime ? 1 : -1)} />
- :
- Loading...
+ {!!user && !!classes.length && !!classes.filter(byTeaching).length &&
+ <>
+
+
+ >
}
-
+
+
-
Ordered by nearest date.
+
+
- {classes ?
- x.datetime < now)} />
+
+ Filter by tag:
+
+ {Object.entries(tags).map(([name, color]) =>
+
+ )}
+
+
+
+ {tagFilter && }
+
+
+
+ {classes.length ?
+ sortByCourse ?
+
+ :
+
:
Loading...
}
diff --git a/webclient/src/Courses.js b/webclient/src/Courses.js
index 27c05fb..fd226c2 100644
--- a/webclient/src/Courses.js
+++ b/webclient/src/Courses.js
@@ -1,17 +1,35 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Switch, Route, Link, useParams } from 'react-router-dom';
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 { isInstructor, getInstructor, requester } from './utils.js';
import { NotFound, PleaseLogin } from './Misc.js';
import { InstructorCourseList, InstructorCourseDetail } from './InstructorCourses.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 tagFilterCache = false;
export function Courses(props) {
const [courses, setCourses] = useState(courseCache);
+ const [tagFilter, setTagFilter] = useState(tagFilterCache);
const { token, user } = props;
useEffect(() => {
@@ -25,6 +43,8 @@ export function Courses(props) {
});
}, []);
+ const byTag = (x) => tagFilter ? x.tags.includes(tagFilter) : true;
+
return (
@@ -33,21 +53,58 @@ export function Courses(props) {
}
+
+ Filter by tag:
+
+ {Object.entries(tags).map(([name, color]) =>
+
+ )}
+
+
+
+ {tagFilter && }
+
+
{courses ?
Name
+
{courses.length ?
- courses.map(x =>
+ courses.filter(byTag).map(x =>
{x.name}
+
+ {!!x.tags && x.tags.split(',').map(name =>
+
+ )}
+
)
:
@@ -80,6 +137,8 @@ export function CourseDetail(props) {
});
}, []);
+ const now = new Date().toISOString();
+
return (
{!error ?
@@ -113,21 +172,32 @@ export function CourseDetail(props) {
Time
Instructor
Cost
+ Students
{course.sessions.length ?
course.sessions.sort((a, b) => a.datetime < b.datetime ? 1 : -1).slice(0,10).map(x =>
-
+
- {moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
+ {moment.utc(x.datetime).tz('America/Edmonton').format('ll')}
{x.is_cancelled ? 'Cancelled' : moment.utc(x.datetime).tz('America/Edmonton').format('LT')}
{getInstructor(x)}
{x.cost === '0.00' ? 'Free' : '$'+x.cost}
+
+ {!!x.max_students ?
+ x.max_students <= x.student_count ?
+ 'Full'
+ :
+ x.student_count + ' / ' + x.max_students
+ :
+ x.student_count
+ }
+
)
:
diff --git a/webclient/src/Home.js b/webclient/src/Home.js
index 41e324e..9762eb2 100644
--- a/webclient/src/Home.js
+++ b/webclient/src/Home.js
@@ -235,7 +235,7 @@ export function Home(props) {
Main Website
Protospace Wiki — [register]
Protospace Forum — [register]
- {!!user && Google Drive
}
+ {!!user && Google Drive
}
{!!user && isAdmin(user) && Property Management Portal
}
refreshStats()} />
diff --git a/webclient/src/InstructorClasses.js b/webclient/src/InstructorClasses.js
index b6f46b3..b7cee2c 100644
--- a/webclient/src/InstructorClasses.js
+++ b/webclient/src/InstructorClasses.js
@@ -209,7 +209,11 @@ export function InstructorClassAttendance(props) {
/>
-
+
Submit
diff --git a/webclient/src/Paymaster.js b/webclient/src/Paymaster.js
index aef1d20..1a492ea 100644
--- a/webclient/src/Paymaster.js
+++ b/webclient/src/Paymaster.js
@@ -11,6 +11,8 @@ export function Paymaster(props) {
const { user } = props;
const [pop, setPop] = useState('20.00');
const [locker, setLocker] = useState('5.00');
+ const [consumables, setConsumables] = useState('20.00');
+ const [consumablesMemo, setConsumablesMemo] = useState('');
const [donate, setDonate] = useState('20.00');
const [memo, setMemo] = useState('');
@@ -93,26 +95,44 @@ export function Paymaster(props) {
+
+
+ Pay for materials you use (ie. welding gas, 3D printing, blades, etc).
+
+
+
+ Custom amount:
+
+
+ setConsumables(v.value)}
+ />
+
+
+
+ Please explain what you bought:
+ setConsumablesMemo(v.value)}
+ />
+
+
+
+
+
+
-
-
- Donate $5.00:
-
-
-
-
- Donate $10.00:
-
-
+
Custom amount:
@@ -126,19 +146,21 @@ export function Paymaster(props) {
/>
+
+ Optional memo:
+ setMemo(v.value)}
+ />
+
+
-
- Add an optional memo to your donation:
- setMemo(v.value)}
- />
diff --git a/webclient/src/light.css b/webclient/src/light.css
index f07fdfb..fd7be49 100644
--- a/webclient/src/light.css
+++ b/webclient/src/light.css
@@ -129,6 +129,10 @@ body {
margin: 0 1.5em 1em !important;
}
+.coursetags .ui.tag.label {
+ margin-top: 1rem;
+}
+
.footer {
margin-top: -20rem;
background: black;