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:
+
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/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 ~~~~~

- } -
+ + +
+ + + +
- - - + {window.location.hostname !== 'my.protospace.ca' && +

~~~~~ Development site ~~~~~

+ } +
- - - + + - - - - - - - - - - - - - - {user && isAdmin(user) && } + + + + + + + + + - {user && isAdmin(user) && } - - + + + + + + - {user && - - - } - -
+ {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.name} + +
+ + {!!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.

-
Upcoming
- -

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 && + <> +
Classes You're Teaching
+ + } -
Recent
+

+ -

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 (
Courses
@@ -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) { +
Consumables
+ +

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
- - -

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)} - />
Locker Storage
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;